Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcbf388ef8 | |||
| cbed10052b | |||
| 560213680c | |||
| 7e2542bf1f | |||
| df6bef7345 | |||
| aa57e8c48b | |||
| 822e8a9d0f | |||
| f0d1fb6647 | |||
| 751ffd59a0 | |||
| d0a17a8a31 | |||
| daa2750f89 | |||
| 29423123b3 | |||
| 5c10eb0009 | |||
| dca35a9900 | |||
| 4430d473cb | |||
| 0c27e3b5d8 | |||
| 616d8a039b | |||
| ee3d7714c2 | |||
| ddf896e3f9 | |||
| b9d0199115 | |||
| 1670b8e5ef | |||
| 1c43d12e4d | |||
| 5cf9922822 | |||
| 9a4a95feea | |||
| d3902c4c77 | |||
| 21ec8a33ae | |||
| 79d221de5e | |||
| 24fde20030 | |||
| 4a4409ca85 | |||
| d96d6a4b13 | |||
| 8f6b12d827 | |||
| a11714d07d | |||
| 52f7e68f25 | |||
| 217ac33675 | |||
| f2b8b136af | |||
| 2e07b213d1 | |||
| a2c1eaefba | |||
| 80ff266f9c | |||
| 6b1c5b7e30 | |||
| 80eefad5ea | |||
| 72556af24c | |||
| 2a5466c6c0 | |||
| 2d36a4ec71 | |||
| ded9da7d32 | |||
| 36ed26ad79 | |||
| 4e72a0baac | |||
| 8ca7eb3f49 | |||
| 32d3ff010a | |||
| cb68e1fb5c | |||
| 1bd7c6aba5 | |||
| 8b0e130b08 | |||
| bd1d33a157 |
@@ -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
|
||||
IMAGE_TAG=v1.9.3
|
||||
IMAGE_TAG=v1.9.16
|
||||
PROJECT_NAME=sample-website
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
|
||||
@@ -192,9 +192,6 @@ jobs:
|
||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||
name: Gatekeeper (Product)
|
||||
|
||||
- image: image-processor
|
||||
file: apps/image-service/Dockerfile
|
||||
name: Image Processor
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -202,12 +199,31 @@ jobs:
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
- name: 🔐 Discover Valid Registry Token
|
||||
id: discover_token
|
||||
run: |
|
||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||
TOKENS="${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
||||
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
||||
|
||||
for TOKEN in $TOKENS; do
|
||||
if [ -n "$TOKEN" ]; then
|
||||
for U in $USERS; do
|
||||
if [ -n "$U" ]; then
|
||||
echo "Attempting docker login for a token with user $U..."
|
||||
if echo "$TOKEN" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
||||
echo "✅ Successfully authenticated with a token."
|
||||
echo "::add-mask::$TOKEN"
|
||||
echo "token=$TOKEN" >> $GITHUB_OUTPUT
|
||||
echo "user=$U" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
echo "❌ All available tokens failed to authenticate!"
|
||||
exit 1
|
||||
|
||||
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -219,8 +235,8 @@ jobs:
|
||||
provenance: false
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN=${{ steps.discover_token.outputs.token }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||
git.infra.mintel.me/mmintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
git.infra.mintel.me/mmintel/${{ matrix.image }}:latest
|
||||
|
||||
|
||||
@@ -18,10 +18,16 @@ on:
|
||||
required: true
|
||||
GATEKEEPER_PASSWORD:
|
||||
required: true
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
MINTEL_PRIVATE_TOKEN:
|
||||
required: false
|
||||
GITEA_PAT:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
qa_suite:
|
||||
name: 🛡️ Nightly QA Suite
|
||||
prepare:
|
||||
name: 🏗️ Prepare & Install
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -39,95 +45,157 @@ jobs:
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.MINTEL_PRIVATE_TOKEN || secrets.GITEA_PAT }}" >> .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN || secrets.MINTEL_PRIVATE_TOKEN || secrets.GITEA_PAT }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
id: deps
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: 📦 Cache APT Packages
|
||||
uses: actions/cache@v4
|
||||
- name: 📦 Archive dependencies
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
|
||||
name: node_modules
|
||||
path: |
|
||||
node_modules
|
||||
.npmrc
|
||||
retention-days: 1
|
||||
|
||||
- name: 💾 Cache Chromium
|
||||
id: cache-chromium
|
||||
uses: actions/cache@v4
|
||||
static:
|
||||
name: 🔍 Static Analysis
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
path: /usr/bin/chromium
|
||||
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
|
||||
|
||||
- name: 🔍 Install Chromium (Native & ARM64)
|
||||
if: steps.cache-chromium.outputs.cache-hit != 'true' && steps.deps.outcome == 'success'
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
apt-get update
|
||||
apt-get install -y gnupg wget ca-certificates
|
||||
OS_ID=$(. /etc/os-release && echo $ID)
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
if [ "$OS_ID" = "debian" ]; then
|
||||
apt-get install -y chromium
|
||||
else
|
||||
mkdir -p /etc/apt/keyrings
|
||||
KEY_ID="82BB6851C64F6880"
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
apt-get update
|
||||
apt-get install -y --allow-downgrades chromium
|
||||
fi
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||
|
||||
# ── Quality Gates ─────────────────────────────────────────────────────────
|
||||
|
||||
- name: 🌐 Full Sitemap HTML Validation
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: node_modules
|
||||
- name: 🌐 HTML Validation
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:html
|
||||
|
||||
- name: 🌐 Dynamic Asset Presence & Error Scan
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
- name: 🖼️ Asset Scan
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:assets
|
||||
|
||||
- name: ♿ Accessibility Scan (WCAG)
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
accessibility:
|
||||
name: ♿ Accessibility
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: node_modules
|
||||
- name: 🔍 Install Chromium
|
||||
run: |
|
||||
apt-get update && apt-get install -y gnupg wget ca-certificates
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
mkdir -p /etc/apt/keyrings
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
apt-get update && apt-get install -y --allow-downgrades chromium
|
||||
ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
- name: ♿ WCAG Scan
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:wcag
|
||||
|
||||
- name: 📦 Unused Dependencies Scan (depcheck)
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
analysis:
|
||||
name: 🧪 Maintenance & Links
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: node_modules
|
||||
- name: 📦 Depcheck
|
||||
continue-on-error: true
|
||||
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*"
|
||||
|
||||
- name: 🔗 Markdown & HTML Link Check (Lychee)
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
- name: 🔗 Lychee Link Check
|
||||
uses: lycheeverse/lychee-action@v2
|
||||
with:
|
||||
args: --accept 200,204,429 --timeout 15 content/ app/ public/
|
||||
fail: true
|
||||
|
||||
- name: 🎭 LHCI Desktop Audit
|
||||
id: lhci_desktop
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
performance:
|
||||
name: 🎭 Lighthouse
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: node_modules
|
||||
- name: 🔍 Install Chromium
|
||||
run: |
|
||||
apt-get update && apt-get install -y gnupg wget ca-certificates
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
mkdir -p /etc/apt/keyrings
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
apt-get update && apt-get install -y --allow-downgrades chromium
|
||||
ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
- name: 🎭 LHCI Desktop
|
||||
env:
|
||||
LHCI_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
|
||||
|
||||
- name: 📱 LHCI Mobile Audit
|
||||
id: lhci_mobile
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
- name: 📱 LHCI Mobile
|
||||
env:
|
||||
LHCI_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
@@ -135,7 +203,7 @@ jobs:
|
||||
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [qa_suite]
|
||||
needs: [prepare, static, accessibility, analysis, performance]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -144,22 +212,30 @@ jobs:
|
||||
- name: 🔔 Gotify
|
||||
shell: bash
|
||||
run: |
|
||||
SUITE="${{ needs.qa_suite.result }}"
|
||||
PREPARE="${{ needs.prepare.result }}"
|
||||
STATIC="${{ needs.static.result }}"
|
||||
A11Y="${{ needs.accessibility.result }}"
|
||||
ANALYSIS="${{ needs.analysis.result }}"
|
||||
PERF="${{ needs.performance.result }}"
|
||||
|
||||
PROJECT="${{ inputs.PROJECT_NAME }}"
|
||||
URL="${{ inputs.TARGET_URL }}"
|
||||
|
||||
if [[ "$SUITE" != "success" ]]; then
|
||||
if [[ "$PREPARE" != "success" || "$STATIC" != "success" || "$PERF" != "success" ]]; then
|
||||
PRIORITY=8
|
||||
EMOJI="⚠️"
|
||||
EMOJI="🚨"
|
||||
STATUS_LINE="Nightly QA Failed! Action required."
|
||||
else
|
||||
PRIORITY=2
|
||||
EMOJI="✅"
|
||||
STATUS_LINE="Nightly QA Passed perfectly."
|
||||
STATUS_LINE="Nightly QA Passed."
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI $PROJECT Nightly QA"
|
||||
MESSAGE="$STATUS_LINE\n$URL\nPlease check Pipeline output for details."
|
||||
MESSAGE="$STATUS_LINE
|
||||
Prepare: $PREPARE | Static: $STATIC | A11y: $A11Y
|
||||
Analysis: $ANALYSIS | Perf: $PERF
|
||||
$URL"
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -46,4 +46,8 @@ directus/uploads/directus-health-file
|
||||
# Estimation Engine Data
|
||||
data/crawls/
|
||||
packages/estimation-engine/out/
|
||||
apps/web/out/estimations/
|
||||
apps/web/out/estimations/
|
||||
|
||||
# Memory MCP
|
||||
data/qdrant/
|
||||
packages/memory-mcp/models/
|
||||
@@ -5,37 +5,4 @@ if [ -f "$SCRIPT_DIR/scripts/validate-sdk-imports.sh" ]; then
|
||||
"$SCRIPT_DIR/scripts/validate-sdk-imports.sh" || exit 1
|
||||
fi
|
||||
|
||||
# Check if we are pushing a tag
|
||||
while read local_ref local_sha remote_ref remote_sha
|
||||
do
|
||||
if [[ "$remote_ref" == refs/tags/* ]]; then
|
||||
TAG=${remote_ref#refs/tags/}
|
||||
echo "🏷️ Tag detected: $TAG, ensuring versions are synced..."
|
||||
|
||||
# Run sync script
|
||||
pnpm sync-versions "$TAG"
|
||||
|
||||
# Check for changes in relevant files
|
||||
SYNC_FILES="package.json packages/*/package.json apps/*/package.json .env.example"
|
||||
CHANGES=$(git status --porcelain $SYNC_FILES)
|
||||
|
||||
if [[ -n "$CHANGES" ]]; then
|
||||
echo "📝 Version sync made changes. Integrating into tag..."
|
||||
|
||||
# Stage and commit
|
||||
git add $SYNC_FILES
|
||||
git commit -m "chore: sync versions to $TAG" --no-verify
|
||||
|
||||
# Force update the local tag to point to the new commit
|
||||
git tag -f "$TAG" > /dev/null
|
||||
|
||||
echo "✅ Tag $TAG has been updated locally with synced versions."
|
||||
echo "🚀 Proceeding with push..."
|
||||
|
||||
exit 0
|
||||
else
|
||||
echo "✨ Versions already in sync for $TAG."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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
|
||||
|
||||
# Clean the workspace in case the base image is dirty
|
||||
@@ -37,7 +37,7 @@ COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
# 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
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sample-website",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
39
docker-compose.gatekeeper.yml
Normal file
39
docker-compose.gatekeeper.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
gatekeeper-proxy:
|
||||
image: alpine:latest
|
||||
command: sleep infinity
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- infra
|
||||
labels:
|
||||
- "caddy=http://gatekeeper.localhost"
|
||||
- "caddy.route=/*"
|
||||
- "caddy.route.0_redir=/ /gatekeeper/login 302"
|
||||
- "caddy.route.1_reverse_proxy=gatekeeper-app:3000"
|
||||
|
||||
gatekeeper-app:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- gatekeeper_root_node_modules:/app/node_modules
|
||||
- gatekeeper_pkg_node_modules:/app/packages/gatekeeper/node_modules
|
||||
- gatekeeper_next_cache:/app/packages/gatekeeper/.next
|
||||
- gatekeeper_pnpm_store:/pnpm
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NPM_TOKEN=${NPM_TOKEN:-}
|
||||
networks:
|
||||
- infra
|
||||
command: >
|
||||
sh -c "corepack enable && pnpm config set store-dir /pnpm && pnpm install --no-frozen-lockfile && pnpm --filter @mintel/gatekeeper run dev --hostname 0.0.0.0 --port 3000"
|
||||
|
||||
networks:
|
||||
infra:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
gatekeeper_root_node_modules:
|
||||
gatekeeper_pkg_node_modules:
|
||||
gatekeeper_next_cache:
|
||||
gatekeeper_pnpm_store:
|
||||
16
docker-compose.mcps.yml
Normal file
16
docker-compose.mcps.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: qdrant-mcp
|
||||
ports:
|
||||
- "6333:6333"
|
||||
- "6334:6334"
|
||||
volumes:
|
||||
- ./data/qdrant:/qdrant/storage
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
networks:
|
||||
mcp-network:
|
||||
driver: bridge
|
||||
48
ecosystem.mcps.config.cjs
Normal file
48
ecosystem.mcps.config.cjs
Normal file
@@ -0,0 +1,48 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'gitea-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/gitea-mcp',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'memory-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/memory-mcp',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'umami-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/umami-mcp',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'serpbear-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/serpbear-mcp',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'glitchtip-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/glitchtip-mcp',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'klz-payload-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/klz-payload-mcp',
|
||||
watch: false,
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
12
fix-private.mjs
Normal file
12
fix-private.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import fs from 'fs';
|
||||
import glob from 'glob';
|
||||
|
||||
const files = glob.sync('/Users/marcmintel/Projects/at-mintel/packages/*/package.json');
|
||||
files.forEach(f => {
|
||||
const content = fs.readFileSync(f, 'utf8');
|
||||
if (content.includes('"private": true,')) {
|
||||
console.log(`Fixing ${f}`);
|
||||
const newContent = content.replace(/\s*"private": true,?\n/g, '\n');
|
||||
fs.writeFileSync(f, newContent);
|
||||
}
|
||||
});
|
||||
11
package.json
11
package.json
@@ -5,11 +5,19 @@
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"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:mcps:up": "docker-compose -f docker-compose.mcps.yml up -d",
|
||||
"dev:mcps:down": "docker-compose -f docker-compose.mcps.yml down && pm2 delete ecosystem.mcps.config.cjs || true",
|
||||
"dev:mcps:watch": "pnpm -r --filter=\"./packages/*-mcp\" exec tsc -w",
|
||||
"dev:mcps": "npm run dev:mcps:up && pm2 start ecosystem.mcps.config.cjs --watch && npm run dev:mcps:watch",
|
||||
"start:mcps:run": "pm2 start ecosystem.mcps.config.cjs",
|
||||
"start:mcps": "npm run dev:mcps:up && npm run start:mcps:run",
|
||||
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
||||
"test": "pnpm -r test",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"sync-versions": "tsx scripts/sync-versions.ts --",
|
||||
"release:version": "bash scripts/release.sh",
|
||||
"release": "pnpm build && changeset publish",
|
||||
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
||||
"prepare": "husky"
|
||||
@@ -34,6 +42,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"pm2": "^6.0.14",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.0.0",
|
||||
@@ -47,7 +56,7 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"require-in-the-middle": "^8.0.1"
|
||||
},
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cli",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cloner",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/concept-engine",
|
||||
"version": "1.9.3",
|
||||
"private": true,
|
||||
"version": "1.9.16",
|
||||
"description": "AI-powered web project concept generation and analysis",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/content-engine",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/eslint-config",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/estimation-engine",
|
||||
"version": "1.9.3",
|
||||
"private": true,
|
||||
"version": "1.9.16",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/gatekeeper",
|
||||
"version": "1.9.3",
|
||||
"private": true,
|
||||
"version": "1.9.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -12,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"framer-motion": "^11.18.2",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "16.1.6",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-slate-800 font-serif antialiased selection:bg-slate-900 selection:text-white;
|
||||
@apply bg-[#f5f5f7] text-black/80 font-serif antialiased selection:bg-black/10 selection:text-black;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-sans font-bold text-slate-900 tracking-tighter;
|
||||
@apply font-sans font-bold text-black tracking-tighter;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-4 text-base leading-relaxed text-slate-700;
|
||||
@apply mb-4 text-base leading-relaxed text-black/50;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-slate-900 hover:text-slate-700 transition-colors no-underline;
|
||||
@apply text-black/50 hover:text-black transition-colors no-underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,34 +36,58 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 border border-slate-200 bg-white text-slate-600 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 active:translate-y-0 active:shadow-sm;
|
||||
@apply inline-flex items-center justify-center px-6 py-3 border border-black/10 bg-white text-black/60 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-black/20 hover:text-black hover:bg-white hover:-translate-y-0.5 hover:shadow-xl hover:shadow-black/5 active:translate-y-0 active:shadow-sm;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white;
|
||||
@apply border-black bg-black text-white hover:bg-black/85 hover:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
/* Custom scrollbar - light theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
background: #d1d1d6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
background: #b0b0b8;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
@@ -79,6 +103,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.8s ease-out 0.2s forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.2s ease-in-out 0s 2;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,17 @@ const newsreader = Newsreader({
|
||||
export const metadata: Metadata = {
|
||||
title: "Gatekeeper | Access Control",
|
||||
description: "Mintel Infrastructure Protection",
|
||||
openGraph: {
|
||||
title: "Gatekeeper | Access Control",
|
||||
description: "Mintel Infrastructure Protection",
|
||||
siteName: "Mintel Gatekeeper",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Gatekeeper | Access Control",
|
||||
description: "Mintel Infrastructure Protection",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowRight, ShieldCheck } from "lucide-react";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { GateScene } from "../../components/gate-scene";
|
||||
import { AnimatedLoginForm } from "../../components/animated-login-form";
|
||||
|
||||
interface LoginPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
@@ -17,8 +19,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const email = (formData.get("email") as string || "").trim();
|
||||
const password = (formData.get("password") as string || "").trim();
|
||||
const email = ((formData.get("email") as string) || "").trim();
|
||||
const password = ((formData.get("password") as string) || "").trim();
|
||||
|
||||
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
@@ -116,7 +118,9 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
}
|
||||
|
||||
if (userIdentity) {
|
||||
console.log(`[Login] Success: ${userIdentity} | Redirect: ${targetRedirect}`);
|
||||
console.log(
|
||||
`[Login] Success: ${userIdentity} | Redirect: ${targetRedirect}`,
|
||||
);
|
||||
const cookieStore = await cookies();
|
||||
// Store identity in the cookie (simplified for now, ideally signed)
|
||||
const sessionValue = JSON.stringify({
|
||||
@@ -127,7 +131,9 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
console.log(`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`);
|
||||
console.log(
|
||||
`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`,
|
||||
);
|
||||
|
||||
cookieStore.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
@@ -145,101 +151,81 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative bg-white font-serif antialiased overflow-hidden">
|
||||
{/* Background Decor - Signature mintel.me style */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.03] scale-[1.01]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, #000 1px, transparent 1px), linear-gradient(to bottom, #000 1px, transparent 1px)`,
|
||||
backgroundSize: "clamp(30px, 8vw, 40px) clamp(30px, 8vw, 40px)",
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-screen flex items-center justify-center relative bg-[#f5f5f7] font-serif antialiased overflow-hidden selection:bg-black/10 selection:text-black">
|
||||
{/* 3D Digital Gate Background */}
|
||||
<GateScene />
|
||||
|
||||
<main className="relative z-10 w-full max-w-sm px-8 sm:px-6">
|
||||
<div className="space-y-12 sm:space-y-16 animate-fade-in">
|
||||
{/* Top Icon Box - Signature mintel.me Black Square */}
|
||||
<main className="relative z-10 w-full max-w-[380px] px-6 sm:px-4 pb-24 sm:pb-32 pointer-events-auto">
|
||||
<div className="space-y-10 animate-fade-in">
|
||||
{/* Top Icon Box */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-black rounded-xl flex items-center justify-center shadow-xl shadow-slate-100 hover:scale-105 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] rotate-2 hover:rotate-0">
|
||||
<a
|
||||
href="https://mintel.me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-14 h-14 bg-white rounded-2xl flex items-center justify-center shadow-lg shadow-black/[0.06] hover:scale-105 hover:shadow-black/10 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] border border-black/[0.06] hover:border-black/10"
|
||||
>
|
||||
<Image
|
||||
src="/gatekeeper/icon-white.svg"
|
||||
alt="Mintel"
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-8 h-8"
|
||||
style={{ filter: "invert(1)" }}
|
||||
width={28}
|
||||
height={28}
|
||||
className="w-7 h-7 opacity-80"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12 animate-slide-up">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-xs font-sans font-bold uppercase tracking-[0.4em] text-slate-900 border-b border-slate-50 pb-4 inline-block mx-auto min-w-[200px]">
|
||||
{projectName} <span className="text-slate-300">Gatekeeper</span>
|
||||
<div className="space-y-8 animate-slide-up">
|
||||
<div className="text-center space-y-3">
|
||||
<h1 className="text-[11px] font-sans font-bold uppercase tracking-[0.5em] text-black/80 pb-3 inline-block mx-auto min-w-[220px]">
|
||||
{projectName} <span className="text-black/30">Gatekeeper</span>
|
||||
</h1>
|
||||
<p className="text-[10px] text-slate-400 font-sans uppercase tracking-widest italic flex items-center justify-center gap-2">
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
Infrastructure Protection
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="h-px w-8 bg-gradient-to-r from-transparent to-black/10" />
|
||||
<p className="text-[8px] text-black/30 font-sans uppercase tracking-[0.35em] font-semibold">
|
||||
Infrastructure Protection
|
||||
</p>
|
||||
<div className="h-px w-8 bg-gradient-to-l from-transparent to-black/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-5 py-3 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-100 animate-shake">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<div className="bg-red-50 backdrop-blur-md text-red-600 px-5 py-4 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-200 animate-shake">
|
||||
<ShieldCheck className="w-4 h-4 text-red-500/70" />
|
||||
<span>Access Denied. Try Again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-4">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
{/* The Animated Framer Motion Form */}
|
||||
<AnimatedLoginForm
|
||||
redirectUrl={redirectUrl}
|
||||
loginAction={login}
|
||||
projectName={projectName}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="EMAIL (OPTIONAL)"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-[10px] font-sans font-bold tracking-[0.2em] uppercase placeholder:text-slate-300 shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="ACCESS CODE"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100 flex items-center justify-center"
|
||||
{/* Bottom Section */}
|
||||
<div className="pt-4 sm:pt-6 flex flex-col items-center gap-5">
|
||||
<div className="h-px w-16 bg-gradient-to-r from-transparent via-black/10 to-transparent" />
|
||||
<a
|
||||
href="https://mintel.me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="opacity-30 transition-opacity hover:opacity-60"
|
||||
>
|
||||
Unlock Access
|
||||
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Bottom Section - Full Branding Parity */}
|
||||
<div className="pt-12 sm:pt-20 flex flex-col items-center gap-6 sm:gap-8">
|
||||
<div className="h-px w-8 bg-slate-100" />
|
||||
<div className="opacity-80 transition-opacity hover:opacity-100">
|
||||
<Image
|
||||
src="/gatekeeper/logo-black.svg"
|
||||
src="/gatekeeper/logo-white.svg"
|
||||
alt={projectName}
|
||||
width={140}
|
||||
height={40}
|
||||
className="h-7 sm:h-auto grayscale contrast-125 w-auto"
|
||||
width={120}
|
||||
height={36}
|
||||
className="h-5 sm:h-6 w-auto"
|
||||
style={{ filter: "invert(1)" }}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[8px] font-sans font-bold text-slate-300 uppercase tracking-[0.4em] sm:tracking-[0.5em] text-center">
|
||||
© 2026 MINTEL
|
||||
</a>
|
||||
<p className="text-[7px] font-sans font-semibold text-black/25 uppercase tracking-[0.5em] text-center">
|
||||
© {new Date().getFullYear()} MINTEL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
126
packages/gatekeeper/src/app/opengraph-image.tsx
Normal file
126
packages/gatekeeper/src/app/opengraph-image.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
// Image metadata
|
||||
export const alt = "Gatekeeper Infrastructure Protection";
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
};
|
||||
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default async function Image() {
|
||||
const projectName = process.env.PROJECT_NAME || "MINTEL";
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, #020617, #0f172a)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* Subtle Background Pattern matching the industrial look */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 50% 50%, #334155 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
opacity: 0.1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Central Card Element */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(15, 23, 42, 0.6)",
|
||||
border: "1px solid rgba(51, 65, 85, 0.4)",
|
||||
borderRadius: "32px",
|
||||
padding: "80px",
|
||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
|
||||
}}
|
||||
>
|
||||
{/* Top Icon Box */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100px",
|
||||
height: "100px",
|
||||
background: "#000",
|
||||
borderRadius: "24px",
|
||||
border: "2px solid #334155",
|
||||
boxShadow: "0 20px 40px rgba(0,0,0,0.8)",
|
||||
marginBottom: "40px",
|
||||
transform: "rotate(2deg)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Project Name & Typography */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: "64px",
|
||||
fontWeight: 900,
|
||||
letterSpacing: "0.2em",
|
||||
color: "white",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
{projectName}{" "}
|
||||
<span style={{ color: "#64748b", marginLeft: "10px" }}>
|
||||
GATEKEEPER
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: "24px",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.4em",
|
||||
color: "#94a3b8",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Infrastructure Protection
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
...size,
|
||||
},
|
||||
);
|
||||
}
|
||||
283
packages/gatekeeper/src/components/animated-login-form.tsx
Normal file
283
packages/gatekeeper/src/components/animated-login-form.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, Lock, Shield, Fingerprint } from "lucide-react";
|
||||
|
||||
interface AnimatedLoginFormProps {
|
||||
redirectUrl: string;
|
||||
loginAction: (formData: FormData) => Promise<void>;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export function AnimatedLoginForm({
|
||||
redirectUrl,
|
||||
loginAction,
|
||||
}: AnimatedLoginFormProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const beamRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mouse tracking refs (no re-render)
|
||||
const mouse = useRef({ x: 0, y: 0 });
|
||||
const angle = useRef(0);
|
||||
const tilt = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Single rAF loop: iridescent border + perspective tilt
|
||||
useEffect(() => {
|
||||
let animId: number;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
mouse.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
|
||||
const animate = () => {
|
||||
if (!wrapperRef.current || !beamRef.current) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = wrapperRef.current.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const dx = mouse.current.x - cx;
|
||||
const dy = mouse.current.y - cy;
|
||||
|
||||
// Angle from form center to mouse → positions the bright highlight
|
||||
const targetAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
|
||||
// Lerp angle smoothly (shortest path)
|
||||
let diff = targetAngle - angle.current;
|
||||
while (diff > 180) diff -= 360;
|
||||
while (diff < -180) diff += 360;
|
||||
angle.current += diff * 0.06;
|
||||
|
||||
// Intensity: slightly stronger on focus
|
||||
const intensity = isFocused ? 1 : 0.7;
|
||||
|
||||
// Mouse-aligned iridescent conic gradient
|
||||
// The "hotspot" (brightest white) faces the mouse
|
||||
beamRef.current.style.background = `conic-gradient(from ${angle.current}deg at 50% 50%,
|
||||
rgba(255,255,255,${1.0 * intensity}) 0deg,
|
||||
rgba(200,210,255,${0.8 * intensity}) 20deg,
|
||||
rgba(255,200,230,${0.7 * intensity}) 45deg,
|
||||
rgba(150,160,180,${0.6 * intensity}) 80deg,
|
||||
rgba(40,40,50,${0.5 * intensity}) 160deg,
|
||||
rgba(20,20,30,${0.4 * intensity}) 200deg,
|
||||
rgba(140,150,170,${0.5 * intensity}) 280deg,
|
||||
rgba(210,225,255,${0.7 * intensity}) 320deg,
|
||||
rgba(255,255,255,${1.0 * intensity}) 360deg)`;
|
||||
|
||||
// Subtle perspective tilt — max ±4deg
|
||||
const maxTilt = 4;
|
||||
const normX = dx / (rect.width * 2);
|
||||
const normY = dy / (rect.height * 2);
|
||||
const targetTiltY = normX * maxTilt;
|
||||
const targetTiltX = -normY * maxTilt;
|
||||
tilt.current.x += (targetTiltX - tilt.current.x) * 0.08;
|
||||
tilt.current.y += (targetTiltY - tilt.current.y) * 0.08;
|
||||
|
||||
wrapperRef.current.style.transform = `perspective(800px) rotateX(${tilt.current.x}deg) rotateY(${tilt.current.y}deg)`;
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
cancelAnimationFrame(animId);
|
||||
};
|
||||
}, [isFocused]);
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await loginAction(formData);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.8, ease: [0.23, 1, 0.32, 1], delay: 0.3 }}
|
||||
className="relative"
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
{/* Outer wrapper for tilt (refs need non-motion div) */}
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={{ willChange: "transform", transformStyle: "preserve-3d" }}
|
||||
className="relative"
|
||||
>
|
||||
{/* ── Always-on iridescent beam border ── */}
|
||||
<div className="absolute -inset-[1.5px] rounded-[28px] z-0 overflow-hidden">
|
||||
{/* Sharp edge layer */}
|
||||
<div
|
||||
ref={beamRef}
|
||||
className="absolute inset-0 rounded-[28px]"
|
||||
style={{ filter: "blur(0.4px)" }}
|
||||
/>
|
||||
{/* Soft glow bloom */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-[28px]"
|
||||
style={{
|
||||
background: "inherit",
|
||||
filter: "blur(15px)",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Glassmorphism card — high-fidelity glossy light ── */}
|
||||
<div
|
||||
className="relative z-10 bg-white/70 backdrop-blur-3xl rounded-3xl p-7 sm:p-9"
|
||||
style={{
|
||||
boxShadow:
|
||||
/* Razor-sharp inner border highlight */ "inset 0 0 0 1px rgba(255,255,255,0.7), " +
|
||||
/* Top gloss edge */ "inset 0 1.5px 0.5px rgba(255,255,255,1), " +
|
||||
/* Secondary soft top gloss */ "inset 0 4px 10px rgba(255,255,255,0.4), " +
|
||||
/* Bottom inner shadow */ "inset 0 -1px 1px rgba(0,0,0,0.05), " +
|
||||
/* Outer drop shadows for depth */ "0 25px 50px -12px rgba(0,0,0,0.08), 0 4px 8px rgba(0,0,0,0.02)",
|
||||
}}
|
||||
>
|
||||
{/* Subtle surface "sheen" gradient */}
|
||||
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-white/40 via-transparent to-black/[0.02] pointer-events-none" />
|
||||
|
||||
{/* Shield icon header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.6 }}
|
||||
className="flex justify-center mb-6"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-black/[0.04] to-black/[0.08] border border-black/[0.06] flex items-center justify-center backdrop-blur-sm">
|
||||
<Shield className="w-5 h-5 text-black/40" />
|
||||
</div>
|
||||
{mounted && (
|
||||
<div className="absolute -inset-2 rounded-2xl border border-black/[0.04] animate-ping opacity-30" />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<form
|
||||
action={handleSubmit}
|
||||
className="space-y-5 relative z-10"
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Email Input */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6, duration: 0.5 }}
|
||||
className="relative group"
|
||||
>
|
||||
<Fingerprint className="absolute left-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-black/20 group-focus-within:text-black/50 transition-colors duration-300 pointer-events-none" />
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Identity (optional)"
|
||||
onFocus={() => setIsFocused(true)}
|
||||
className="w-full bg-black/[0.02] border border-black/[0.06] rounded-2xl pl-11 pr-4 py-3.5 focus:outline-none focus:border-black/15 focus:bg-white/80 transition-all duration-300 text-[11px] font-sans font-medium tracking-[0.08em] placeholder:text-black/25 placeholder:normal-case placeholder:tracking-normal text-black/70"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-4 right-4 h-px bg-gradient-to-r from-transparent via-black/0 to-transparent group-focus-within:via-black/10 transition-all duration-500" />
|
||||
</motion.div>
|
||||
|
||||
{/* Password Input */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.7, duration: 0.5 }}
|
||||
className="relative group"
|
||||
>
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-black/20 group-focus-within:text-black/50 transition-colors duration-300 pointer-events-none" />
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="Access code"
|
||||
onFocus={() => setIsFocused(true)}
|
||||
className="w-full bg-black/[0.02] border border-black/[0.06] rounded-2xl pl-11 pr-4 py-3.5 focus:outline-none focus:border-black/15 focus:bg-white/80 transition-all duration-300 text-[13px] font-sans font-medium tracking-[0.15em] placeholder:text-black/25 placeholder:tracking-normal placeholder:text-[11px] placeholder:font-normal text-black/80"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-4 right-4 h-px bg-gradient-to-r from-transparent via-black/0 to-transparent group-focus-within:via-black/10 transition-all duration-500" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.5 }}
|
||||
>
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`relative w-full py-4 rounded-2xl text-[10px] font-bold tracking-[0.3em] uppercase flex items-center justify-center overflow-hidden transition-all duration-300 ${
|
||||
isSubmitting
|
||||
? "bg-black/5 text-black/25 cursor-not-allowed"
|
||||
: "bg-black text-white hover:bg-black/85 shadow-lg shadow-black/10"
|
||||
}`}
|
||||
>
|
||||
{!isSubmitting && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full hover:translate-x-full transition-transform duration-700" />
|
||||
)}
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-3 relative z-10">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 1,
|
||||
ease: "linear",
|
||||
}}
|
||||
className="w-4 h-4 border-2 border-white/20 border-t-white/70 rounded-full"
|
||||
/>
|
||||
Authenticating...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-3 relative z-10">
|
||||
Unlock Access
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</form>
|
||||
|
||||
{/* Security badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.0, duration: 0.5 }}
|
||||
className="mt-5 flex items-center justify-center gap-2 text-[8px] font-sans font-semibold text-black/25 uppercase tracking-[0.3em]"
|
||||
>
|
||||
<div className="w-1 h-1 rounded-full bg-black/20 animate-pulse" />
|
||||
Encrypted Connection
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
127
packages/gatekeeper/src/components/gate-scene.tsx
Normal file
127
packages/gatekeeper/src/components/gate-scene.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function GateScene() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const mouse = useRef({ x: -1000, y: -1000 });
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const FONT_SIZE = 13;
|
||||
const COL_GAP = 18;
|
||||
const ROW_GAP = 20;
|
||||
|
||||
interface Cell {
|
||||
char: string;
|
||||
alpha: number;
|
||||
targetAlpha: number;
|
||||
speed: number;
|
||||
nextChange: number;
|
||||
}
|
||||
|
||||
let cells: Cell[][] = [];
|
||||
let cols = 0;
|
||||
let rows = 0;
|
||||
let animId: number;
|
||||
|
||||
const init = () => {
|
||||
cols = Math.ceil(canvas.width / COL_GAP);
|
||||
rows = Math.ceil(canvas.height / ROW_GAP);
|
||||
cells = Array.from({ length: cols }, () =>
|
||||
Array.from({ length: rows }, () => ({
|
||||
char: Math.random() > 0.5 ? "1" : "0",
|
||||
alpha: Math.random() * 0.08,
|
||||
targetAlpha: Math.random() * 0.15,
|
||||
speed: 0.008 + Math.random() * 0.02,
|
||||
nextChange: Math.floor(Math.random() * 80),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
mouse.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
init();
|
||||
};
|
||||
|
||||
let frame = 0;
|
||||
|
||||
const draw = () => {
|
||||
frame++;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
|
||||
|
||||
for (let c = 0; c < cols; c++) {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const cell = cells[c][r];
|
||||
const x = c * COL_GAP;
|
||||
const y = r * ROW_GAP + FONT_SIZE;
|
||||
|
||||
// Mouse proximity influence
|
||||
const dx = mouse.current.x - x;
|
||||
const dy = mouse.current.y - y;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
const proximity = Math.max(0, 1 - Math.sqrt(distSq) / 250);
|
||||
|
||||
// Nudge alpha toward target
|
||||
cell.alpha += (cell.targetAlpha - cell.alpha) * cell.speed;
|
||||
|
||||
// More aggressive random behavior
|
||||
if (frame >= cell.nextChange) {
|
||||
cell.targetAlpha = Math.random() * 0.25; // Higher max alpha
|
||||
cell.speed = 0.01 + Math.random() * 0.03; // Faster transitions
|
||||
cell.nextChange = frame + 20 + Math.floor(Math.random() * 80); // More frequent changes
|
||||
|
||||
// Higher flip probability near mouse
|
||||
const flipProb = 0.3 + proximity * 0.5;
|
||||
if (Math.random() < flipProb) {
|
||||
cell.char = cell.char === "0" ? "1" : "0";
|
||||
}
|
||||
}
|
||||
|
||||
const a = Math.min(0.4, cell.alpha + proximity * 0.35);
|
||||
if (a < 0.01) continue;
|
||||
|
||||
// Dark chars on light background
|
||||
ctx.fillStyle = `rgba(0, 0, 0, ${a})`;
|
||||
ctx.fillText(cell.char, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
animId = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener("resize", resize);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none z-0 bg-[#f5f5f7]">
|
||||
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 55% 65% at 50% 50%, rgba(245,245,247,0.7) 0%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
packages/gitea-mcp/Dockerfile
Normal file
19
packages/gitea-mcp/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable pnpm && pnpm install
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Use node to run the compiled index.js
|
||||
ENTRYPOINT ["node", "dist/index.js"]
|
||||
23
packages/gitea-mcp/package.json
Normal file
23
packages/gitea-mcp/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@mintel/gitea-mcp",
|
||||
"version": "1.9.16",
|
||||
"description": "Native Gitea MCP server for 100% Antigravity compatibility",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/start.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^20.14.10",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
871
packages/gitea-mcp/src/index.ts
Normal file
871
packages/gitea-mcp/src/index.ts
Normal file
@@ -0,0 +1,871 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import express from 'express';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
SubscribeRequestSchema,
|
||||
UnsubscribeRequestSchema,
|
||||
Tool,
|
||||
Resource,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod";
|
||||
import axios from "axios";
|
||||
|
||||
const GITEA_HOST = process.env.GITEA_HOST || "https://git.infra.mintel.me";
|
||||
const GITEA_ACCESS_TOKEN = process.env.GITEA_ACCESS_TOKEN || process.env.GITEA_TOKEN;
|
||||
|
||||
if (!GITEA_ACCESS_TOKEN) {
|
||||
console.error("Warning: Neither GITEA_ACCESS_TOKEN nor GITEA_TOKEN environment variable is set. Pipeline tools will return unauthorized errors.");
|
||||
}
|
||||
|
||||
const giteaClient = axios.create({
|
||||
baseURL: `${GITEA_HOST.replace(/\/$/, '')}/api/v1`,
|
||||
headers: {
|
||||
Authorization: `token ${GITEA_ACCESS_TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
const LIST_PIPELINES_TOOL: Tool = {
|
||||
name: "gitea_list_pipelines",
|
||||
description: "List recent action runs (pipelines) for a specific repository",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner (e.g., 'mmintel')" },
|
||||
repo: { type: "string", description: "Repository name (e.g., 'at-mintel')" },
|
||||
limit: { type: "number", description: "Number of runs to fetch (default: 5)" },
|
||||
branch: { type: "string", description: "Optional: Filter by branch name (e.g., 'main')" },
|
||||
event: { type: "string", description: "Optional: Filter by trigger event (e.g., 'push', 'pull_request')" },
|
||||
},
|
||||
required: ["owner", "repo"],
|
||||
},
|
||||
};
|
||||
|
||||
const GET_PIPELINE_LOGS_TOOL: Tool = {
|
||||
name: "gitea_get_pipeline_logs",
|
||||
description: "Get detailed logs for a specific pipeline run or job",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
run_id: { type: "number", description: "ID of the action run" },
|
||||
},
|
||||
required: ["owner", "repo", "run_id"],
|
||||
},
|
||||
};
|
||||
|
||||
const WAIT_PIPELINE_COMPLETION_TOOL: Tool = {
|
||||
name: "gitea_wait_pipeline_completion",
|
||||
description: "BLOCKS and waits until a pipeline run completes, fails, or is cancelled. Use this instead of polling manually to save tokens.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
run_id: { type: "number", description: "ID of the action run" },
|
||||
timeout_minutes: { type: "number", description: "Maximum time to wait before aborting (default: 10)" },
|
||||
},
|
||||
required: ["owner", "repo", "run_id"],
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_ISSUES_TOOL: Tool = {
|
||||
name: "gitea_list_issues",
|
||||
description: "List issues for a repository",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
state: { type: "string", description: "Filter by state: open, closed, or all (default: open)" },
|
||||
limit: { type: "number", description: "Number of issues to fetch (default: 10)" },
|
||||
},
|
||||
required: ["owner", "repo"],
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_ISSUE_TOOL: Tool = {
|
||||
name: "gitea_create_issue",
|
||||
description: "Create a new issue in a repository",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
title: { type: "string", description: "Issue title" },
|
||||
body: { type: "string", description: "Issue description/body" },
|
||||
},
|
||||
required: ["owner", "repo", "title"],
|
||||
},
|
||||
};
|
||||
|
||||
const GET_FILE_CONTENT_TOOL: Tool = {
|
||||
name: "gitea_get_file_content",
|
||||
description: "Get the raw content of a file from a repository",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
filepath: { type: "string", description: "Path to the file in the repository" },
|
||||
ref: { type: "string", description: "The name of the commit/branch/tag (default: main)" },
|
||||
},
|
||||
required: ["owner", "repo", "filepath"],
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE_ISSUE_TOOL: Tool = {
|
||||
name: "gitea_update_issue",
|
||||
description: "Update an existing issue (e.g. change state, title, or body)",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
index: { type: "number", description: "Issue index/number" },
|
||||
state: { type: "string", description: "Optional: 'open' or 'closed'" },
|
||||
title: { type: "string", description: "Optional: New title" },
|
||||
body: { type: "string", description: "Optional: New body text" },
|
||||
},
|
||||
required: ["owner", "repo", "index"],
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_ISSUE_COMMENT_TOOL: Tool = {
|
||||
name: "gitea_create_issue_comment",
|
||||
description: "Add a comment to an existing issue or pull request",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
index: { type: "number", description: "Issue or PR index/number" },
|
||||
body: { type: "string", description: "Comment body text" },
|
||||
},
|
||||
required: ["owner", "repo", "index", "body"],
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_PULL_REQUEST_TOOL: Tool = {
|
||||
name: "gitea_create_pull_request",
|
||||
description: "Create a new Pull Request",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
head: { type: "string", description: "The branch you want to merge (e.g., 'feature/my-changes')" },
|
||||
base: { type: "string", description: "The branch to merge into (e.g., 'main')" },
|
||||
title: { type: "string", description: "PR title" },
|
||||
body: { type: "string", description: "Optional: PR description" },
|
||||
},
|
||||
required: ["owner", "repo", "head", "base", "title"],
|
||||
},
|
||||
};
|
||||
|
||||
const SEARCH_REPOS_TOOL: Tool = {
|
||||
name: "gitea_search_repos",
|
||||
description: "Search for repositories accessible to the authenticated user",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search term" },
|
||||
limit: { type: "number", description: "Maximum number of results (default: 10)" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
};
|
||||
|
||||
// --- Wiki ---
|
||||
const LIST_WIKI_PAGES_TOOL: Tool = {
|
||||
name: "gitea_list_wiki_pages",
|
||||
description: "List all wiki pages of a repository",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
},
|
||||
required: ["owner", "repo"],
|
||||
},
|
||||
};
|
||||
|
||||
const GET_WIKI_PAGE_TOOL: Tool = {
|
||||
name: "gitea_get_wiki_page",
|
||||
description: "Get the content of a specific wiki page",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
page_name: { type: "string", description: "Name/slug of the wiki page (e.g., 'Home')" },
|
||||
},
|
||||
required: ["owner", "repo", "page_name"],
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_WIKI_PAGE_TOOL: Tool = {
|
||||
name: "gitea_create_wiki_page",
|
||||
description: "Create a new wiki page in a repository",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
title: { type: "string", description: "Page title" },
|
||||
content: { type: "string", description: "Page content in Markdown (base64 encoded internally)" },
|
||||
message: { type: "string", description: "Optional commit message" },
|
||||
},
|
||||
required: ["owner", "repo", "title", "content"],
|
||||
},
|
||||
};
|
||||
|
||||
const EDIT_WIKI_PAGE_TOOL: Tool = {
|
||||
name: "gitea_edit_wiki_page",
|
||||
description: "Edit an existing wiki page",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
page_name: { type: "string", description: "Current name/slug of the wiki page" },
|
||||
title: { type: "string", description: "Optional: new title" },
|
||||
content: { type: "string", description: "New content in Markdown" },
|
||||
message: { type: "string", description: "Optional commit message" },
|
||||
},
|
||||
required: ["owner", "repo", "page_name", "content"],
|
||||
},
|
||||
};
|
||||
|
||||
// --- Packages ---
|
||||
const LIST_PACKAGES_TOOL: Tool = {
|
||||
name: "gitea_list_packages",
|
||||
description: "List packages published to the Gitea package registry for a user or org",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "User or organization name" },
|
||||
type: { type: "string", description: "Optional: Package type filter (e.g., 'npm', 'docker', 'generic')" },
|
||||
limit: { type: "number", description: "Number of packages to return (default: 10)" },
|
||||
},
|
||||
required: ["owner"],
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_PACKAGE_VERSIONS_TOOL: Tool = {
|
||||
name: "gitea_list_package_versions",
|
||||
description: "List all published versions of a specific package",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "User or organization name" },
|
||||
type: { type: "string", description: "Package type (e.g., 'npm', 'docker')" },
|
||||
name: { type: "string", description: "Package name" },
|
||||
},
|
||||
required: ["owner", "type", "name"],
|
||||
},
|
||||
};
|
||||
|
||||
// --- Releases ---
|
||||
const LIST_RELEASES_TOOL: Tool = {
|
||||
name: "gitea_list_releases",
|
||||
description: "List releases for a repository",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
limit: { type: "number", description: "Number of releases to fetch (default: 10)" },
|
||||
},
|
||||
required: ["owner", "repo"],
|
||||
},
|
||||
};
|
||||
|
||||
const GET_LATEST_RELEASE_TOOL: Tool = {
|
||||
name: "gitea_get_latest_release",
|
||||
description: "Get the latest release for a repository",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
},
|
||||
required: ["owner", "repo"],
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_RELEASE_TOOL: Tool = {
|
||||
name: "gitea_create_release",
|
||||
description: "Create a new release for a repository",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "Repository owner" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
tag_name: { type: "string", description: "Git tag to build the release from (e.g., 'v1.2.3')" },
|
||||
name: { type: "string", description: "Release title" },
|
||||
body: { type: "string", description: "Optional: Release notes/description in Markdown" },
|
||||
draft: { type: "boolean", description: "Optional: Create as draft (default: false)" },
|
||||
prerelease: { type: "boolean", description: "Optional: Mark as prerelease (default: false)" },
|
||||
},
|
||||
required: ["owner", "repo", "tag_name", "name"],
|
||||
},
|
||||
};
|
||||
|
||||
// --- Projects ---
|
||||
const LIST_PROJECTS_TOOL: Tool = {
|
||||
name: "gitea_list_projects",
|
||||
description: "List projects (kanban boards) for a user, organization, or repository",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", description: "User or organization name" },
|
||||
repo: { type: "string", description: "Optional: Repository name (for repo-level projects)" },
|
||||
type: { type: "string", description: "Optional: 'individual' or 'repository' or 'organization'" },
|
||||
},
|
||||
required: ["owner"],
|
||||
},
|
||||
};
|
||||
|
||||
const GET_PROJECT_COLUMNS_TOOL: Tool = {
|
||||
name: "gitea_get_project_columns",
|
||||
description: "Get the columns (board columns) of a specific project",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "number", description: "Numeric project ID from gitea_list_projects" },
|
||||
},
|
||||
required: ["project_id"],
|
||||
},
|
||||
};
|
||||
|
||||
// Subscription State
|
||||
const subscriptions = new Set<string>();
|
||||
const runStatusCache = new Map<string, string>(); // uri -> status
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: "gitea-mcp-native",
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: { subscribe: true }, // Enable subscriptions
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// --- Tools ---
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
LIST_PIPELINES_TOOL,
|
||||
GET_PIPELINE_LOGS_TOOL,
|
||||
WAIT_PIPELINE_COMPLETION_TOOL,
|
||||
LIST_ISSUES_TOOL,
|
||||
CREATE_ISSUE_TOOL,
|
||||
GET_FILE_CONTENT_TOOL,
|
||||
UPDATE_ISSUE_TOOL,
|
||||
CREATE_ISSUE_COMMENT_TOOL,
|
||||
CREATE_PULL_REQUEST_TOOL,
|
||||
SEARCH_REPOS_TOOL,
|
||||
// Wiki
|
||||
LIST_WIKI_PAGES_TOOL,
|
||||
GET_WIKI_PAGE_TOOL,
|
||||
CREATE_WIKI_PAGE_TOOL,
|
||||
EDIT_WIKI_PAGE_TOOL,
|
||||
// Packages
|
||||
LIST_PACKAGES_TOOL,
|
||||
LIST_PACKAGE_VERSIONS_TOOL,
|
||||
// Releases
|
||||
LIST_RELEASES_TOOL,
|
||||
GET_LATEST_RELEASE_TOOL,
|
||||
CREATE_RELEASE_TOOL,
|
||||
// Projects
|
||||
LIST_PROJECTS_TOOL,
|
||||
GET_PROJECT_COLUMNS_TOOL,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name === "gitea_list_pipelines") {
|
||||
const { owner, repo, limit = 5, branch, event } = request.params.arguments as any;
|
||||
|
||||
try {
|
||||
const apiParams: Record<string, any> = { limit };
|
||||
if (branch) apiParams.branch = branch;
|
||||
if (event) apiParams.event = event;
|
||||
|
||||
const runsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs`, {
|
||||
params: apiParams,
|
||||
});
|
||||
|
||||
const runs = (runsResponse.data.workflow_runs || []) as any[];
|
||||
const enhancedRuns = await Promise.all(
|
||||
runs.map(async (run: any) => {
|
||||
try {
|
||||
const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run.id}/jobs`);
|
||||
const jobs = (jobsResponse.data.jobs || []) as any[];
|
||||
return {
|
||||
id: run.id,
|
||||
name: run.name,
|
||||
status: run.status,
|
||||
created_at: run.created_at,
|
||||
jobs: jobs.map((job: any) => ({
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
status: job.status,
|
||||
conclusion: job.conclusion
|
||||
}))
|
||||
};
|
||||
} catch (e) {
|
||||
return { id: run.id, name: run.name, status: run.status, created_at: run.created_at, jobs: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(enhancedRuns, null, 2) }],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error fetching pipelines: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_get_pipeline_logs") {
|
||||
const { owner, repo, run_id } = request.params.arguments as any;
|
||||
try {
|
||||
const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`);
|
||||
const jobs = (jobsResponse.data.jobs || []) as any[];
|
||||
const logs = jobs.map((job: any) => ({
|
||||
job_id: job.id,
|
||||
job_name: job.name,
|
||||
status: job.status,
|
||||
conclusion: job.conclusion,
|
||||
steps: (job.steps || []).map((step: any) => ({
|
||||
name: step.name,
|
||||
status: step.status,
|
||||
conclusion: step.conclusion
|
||||
}))
|
||||
}));
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify(logs, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_wait_pipeline_completion") {
|
||||
const { owner, repo, run_id, timeout_minutes = 10 } = request.params.arguments as any;
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = timeout_minutes * 60 * 1000;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
return { content: [{ type: "text", text: `Wait timed out after ${timeout_minutes} minutes.` }] };
|
||||
}
|
||||
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}`);
|
||||
const status = response.data.status;
|
||||
const conclusion = response.data.conclusion;
|
||||
|
||||
if (status !== "running" && status !== "waiting") {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Pipeline finished! Final Status: ${status}, Conclusion: ${conclusion}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Wait 5 seconds before polling again
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error checking pipeline status: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_list_issues") {
|
||||
const { owner, repo, state = "open", limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/issues`, {
|
||||
params: { state, limit }
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_create_issue") {
|
||||
const { owner, repo, title, body } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.post(`/repos/${owner}/${repo}/issues`, {
|
||||
title,
|
||||
body
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_get_file_content") {
|
||||
const { owner, repo, filepath, ref = "main" } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/contents/${filepath}`, {
|
||||
params: { ref }
|
||||
});
|
||||
// Gitea returns base64 encoded content for files
|
||||
if (response.data.type === 'file' && response.data.content) {
|
||||
const decodedContent = Buffer.from(response.data.content, 'base64').toString('utf-8');
|
||||
return { content: [{ type: "text", text: decodedContent }] };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_update_issue") {
|
||||
const { owner, repo, index, state, title, body } = request.params.arguments as any;
|
||||
try {
|
||||
const updateData: Record<string, any> = {};
|
||||
if (state) updateData.state = state;
|
||||
if (title) updateData.title = title;
|
||||
if (body) updateData.body = body;
|
||||
|
||||
// Send PATCH request to /repos/{owner}/{repo}/issues/{index}
|
||||
const response = await giteaClient.patch(`/repos/${owner}/${repo}/issues/${index}`, updateData);
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error updating issue: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_create_issue_comment") {
|
||||
const { owner, repo, index, body } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.post(`/repos/${owner}/${repo}/issues/${index}/comments`, {
|
||||
body
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error creating comment: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_create_pull_request") {
|
||||
const { owner, repo, head, base, title, body } = request.params.arguments as any;
|
||||
try {
|
||||
const prData: Record<string, any> = { head, base, title };
|
||||
if (body) prData.body = body;
|
||||
|
||||
const response = await giteaClient.post(`/repos/${owner}/${repo}/pulls`, prData);
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error creating Pull Request: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_search_repos") {
|
||||
const { query, limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/search`, {
|
||||
params: { q: query, limit }
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wiki Handlers ---
|
||||
if (request.params.name === "gitea_list_wiki_pages") {
|
||||
const { owner, repo } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/wiki/pages`);
|
||||
const pages = (response.data || []).map((p: any) => ({ title: p.title, last_commit: p.last_commit?.message }));
|
||||
return { content: [{ type: "text", text: JSON.stringify(pages, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error listing wiki pages: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_get_wiki_page") {
|
||||
const { owner, repo, page_name } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/wiki/page/${encodeURIComponent(page_name)}`);
|
||||
const content = Buffer.from(response.data.content_base64 || '', 'base64').toString('utf-8');
|
||||
return { content: [{ type: "text", text: `# ${response.data.title}\n\n${content}` }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error fetching wiki page: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_create_wiki_page") {
|
||||
const { owner, repo, title, content, message } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.post(`/repos/${owner}/${repo}/wiki/pages`, {
|
||||
title,
|
||||
content_base64: Buffer.from(content).toString('base64'),
|
||||
message: message || `Create wiki page: ${title}`,
|
||||
});
|
||||
return { content: [{ type: "text", text: `Wiki page '${response.data.title}' created.` }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error creating wiki page: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_edit_wiki_page") {
|
||||
const { owner, repo, page_name, title, content, message } = request.params.arguments as any;
|
||||
try {
|
||||
const updateData: Record<string, any> = {
|
||||
content_base64: Buffer.from(content).toString('base64'),
|
||||
message: message || `Update wiki page: ${page_name}`,
|
||||
};
|
||||
if (title) updateData.title = title;
|
||||
const response = await giteaClient.patch(`/repos/${owner}/${repo}/wiki/pages/${encodeURIComponent(page_name)}`, updateData);
|
||||
return { content: [{ type: "text", text: `Wiki page '${response.data.title}' updated.` }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error updating wiki page: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Package Handlers ---
|
||||
if (request.params.name === "gitea_list_packages") {
|
||||
const { owner, type, limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const params: Record<string, any> = { limit };
|
||||
if (type) params.type = type;
|
||||
const response = await giteaClient.get(`/packages/${owner}`, { params });
|
||||
const packages = (response.data || []).map((p: any) => ({
|
||||
name: p.name, type: p.type, version: p.version, created: p.created_at
|
||||
}));
|
||||
return { content: [{ type: "text", text: JSON.stringify(packages, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error listing packages: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_list_package_versions") {
|
||||
const { owner, type, name } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.get(`/packages/${owner}/${type}/${encodeURIComponent(name)}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error listing package versions: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Release Handlers ---
|
||||
if (request.params.name === "gitea_list_releases") {
|
||||
const { owner, repo, limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/releases`, { params: { limit } });
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error listing releases: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_get_latest_release") {
|
||||
const { owner, repo } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/releases/latest`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error fetching latest release: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_create_release") {
|
||||
const { owner, repo, tag_name, name, body, draft = false, prerelease = false } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.post(`/repos/${owner}/${repo}/releases`, {
|
||||
tag_name, name, body, draft, prerelease
|
||||
});
|
||||
return { content: [{ type: "text", text: `Release '${response.data.name}' created: ${response.data.html_url}` }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error creating release: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Project Handlers ---
|
||||
if (request.params.name === "gitea_list_projects") {
|
||||
const { owner, repo } = request.params.arguments as any;
|
||||
try {
|
||||
// Gitea API: repo-level projects or user projects
|
||||
const url = repo ? `/repos/${owner}/${repo}/projects` : `/users/${owner}/projects`;
|
||||
const response = await giteaClient.get(url);
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error listing projects: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "gitea_get_project_columns") {
|
||||
const { project_id } = request.params.arguments as any;
|
||||
try {
|
||||
const response = await giteaClient.get(`/projects/${project_id}/columns`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] };
|
||||
} catch (error: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error fetching project columns: ${error.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
});
|
||||
|
||||
// --- Resources & Subscriptions ---
|
||||
// We will expose a dynamic resource URI pattern: gitea://runs/{owner}/{repo}/{run_id}
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
return {
|
||||
resources: [
|
||||
{
|
||||
uri: "gitea://runs",
|
||||
name: "Gitea Pipeline Runs",
|
||||
description: "Dynamic resource for subscribing to pipeline runs. Format: gitea://runs/{owner}/{repo}/{run_id}",
|
||||
mimeType: "application/json",
|
||||
}
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
const uri = request.params.uri;
|
||||
const match = uri.match(/^gitea:\/\/runs\/([^\/]+)\/([^\/]+)\/(\d+)$/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Invalid resource URI. Must be gitea://runs/{owner}/{repo}/{run_id}`);
|
||||
}
|
||||
|
||||
const [, owner, repo, run_id] = match;
|
||||
|
||||
try {
|
||||
const runResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}`);
|
||||
const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`);
|
||||
|
||||
const resourceContent = {
|
||||
run: runResponse.data,
|
||||
jobs: jobsResponse.data
|
||||
};
|
||||
|
||||
// Update internal cache when read
|
||||
runStatusCache.set(uri, runResponse.data.status);
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(resourceContent, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to read Gitea resource: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
server.setRequestHandler(SubscribeRequestSchema, async (request) => {
|
||||
const uri = request.params.uri;
|
||||
if (!uri.startsWith("gitea://runs/")) {
|
||||
throw new Error("Only gitea://runs resources can be subscribed to");
|
||||
}
|
||||
subscriptions.add(uri);
|
||||
console.error(`Client subscribed to ${uri}`);
|
||||
return {};
|
||||
});
|
||||
|
||||
server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
|
||||
const uri = request.params.uri;
|
||||
subscriptions.delete(uri);
|
||||
console.error(`Client unsubscribed from ${uri}`);
|
||||
return {};
|
||||
});
|
||||
|
||||
// The server polling mechanism that pushes updates to subscribed clients
|
||||
async function pollSubscriptions() {
|
||||
for (const uri of subscriptions) {
|
||||
const match = uri.match(/^gitea:\/\/runs\/([^\/]+)\/([^\/]+)\/(\d+)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, owner, repo, run_id] = match;
|
||||
try {
|
||||
const runResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}`);
|
||||
const currentStatus = runResponse.data.status;
|
||||
const prevStatus = runStatusCache.get(uri);
|
||||
|
||||
// If status changed (e.g. running -> completed), notify client
|
||||
if (prevStatus !== currentStatus) {
|
||||
runStatusCache.set(uri, currentStatus);
|
||||
|
||||
server.notification({
|
||||
method: "notifications/resources/updated",
|
||||
params: { uri }
|
||||
});
|
||||
console.error(`Pushed update for ${uri}: ${prevStatus} -> ${currentStatus}`);
|
||||
|
||||
// Auto-unsubscribe if completed/failed so we don't poll forever?
|
||||
// Let the client decide, or we can handle it here if requested.
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error polling subscription ${uri}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll every 5 seconds
|
||||
setTimeout(pollSubscriptions, 5000);
|
||||
}
|
||||
|
||||
|
||||
async function run() {
|
||||
const isStdio = process.argv.includes('--stdio');
|
||||
|
||||
if (isStdio) {
|
||||
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('Gitea MCP server is running on stdio');
|
||||
} else {
|
||||
const app = express();
|
||||
let transport: SSEServerTransport | null = null;
|
||||
|
||||
app.get('/sse', async (req, res) => {
|
||||
console.error('New SSE connection established');
|
||||
transport = new SSEServerTransport('/message', res);
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
app.post('/message', async (req, res) => {
|
||||
if (!transport) {
|
||||
res.status(400).send('No active SSE connection');
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
const PORT = process.env.GITEA_MCP_PORT || 3001;
|
||||
app.listen(PORT, () => {
|
||||
console.error(`Gitea MCP server running on http://localhost:${PORT}/sse`);
|
||||
});
|
||||
|
||||
// Start the background poller only in SSE mode or if specifically desired
|
||||
pollSubscriptions();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
run().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
16
packages/gitea-mcp/src/start.ts
Normal file
16
packages/gitea-mcp/src/start.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
// Try to load .env.local first (contains credentials usually)
|
||||
config({ path: resolve(__dirname, '../../../.env.local') });
|
||||
// Fallback to .env (contains defaults)
|
||||
config({ path: resolve(__dirname, '../../../.env') });
|
||||
|
||||
// Now boot the compiled MCP index
|
||||
import('./index.js').catch(err => {
|
||||
console.error('Failed to start MCP Server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
packages/gitea-mcp/tsconfig.json
Normal file
16
packages/gitea-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
25
packages/glitchtip-mcp/package.json
Normal file
25
packages/glitchtip-mcp/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@mintel/glitchtip-mcp",
|
||||
"version": "1.9.16",
|
||||
"description": "GlitchTip Error Tracking MCP server for Mintel infrastructure",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/start.js",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^20.14.10",
|
||||
"typescript": "^5.5.3",
|
||||
"tsx": "^4.19.2"
|
||||
}
|
||||
}
|
||||
171
packages/glitchtip-mcp/src/index.ts
Normal file
171
packages/glitchtip-mcp/src/index.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import express from 'express';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import axios from "axios";
|
||||
import https from "https";
|
||||
|
||||
const GLITCHTIP_BASE_URL = process.env.GLITCHTIP_BASE_URL || "https://glitchtip.infra.mintel.me";
|
||||
const GLITCHTIP_API_KEY = process.env.GLITCHTIP_API_KEY;
|
||||
|
||||
if (!GLITCHTIP_API_KEY) {
|
||||
console.error("Warning: GLITCHTIP_API_KEY is not set. API calls will fail.");
|
||||
}
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false, // For internal infra
|
||||
});
|
||||
|
||||
const glitchtipClient = axios.create({
|
||||
baseURL: `${GLITCHTIP_BASE_URL}/api/0`,
|
||||
headers: { Authorization: `Bearer ${GLITCHTIP_API_KEY}` },
|
||||
httpsAgent
|
||||
});
|
||||
|
||||
const LIST_PROJECTS_TOOL: Tool = {
|
||||
name: "glitchtip_list_projects",
|
||||
description: "List all projects and organizations in GlitchTip",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
};
|
||||
|
||||
const LIST_ISSUES_TOOL: Tool = {
|
||||
name: "glitchtip_list_issues",
|
||||
description: "List issues (errors) for a specific project",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
organization_slug: { type: "string", description: "The organization slug" },
|
||||
project_slug: { type: "string", description: "The project slug" },
|
||||
query: { type: "string", description: "Optional query filter (e.g., 'is:unresolved')" },
|
||||
limit: { type: "number", description: "Maximum number of issues to return (default: 20)" },
|
||||
},
|
||||
required: ["organization_slug", "project_slug"],
|
||||
},
|
||||
};
|
||||
|
||||
const GET_ISSUE_DETAILS_TOOL: Tool = {
|
||||
name: "glitchtip_get_issue_details",
|
||||
description: "Get detailed information about a specific issue, including stack trace",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_id: { type: "string", description: "The ID of the issue" },
|
||||
},
|
||||
required: ["issue_id"],
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE_ISSUE_TOOL: Tool = {
|
||||
name: "glitchtip_update_issue",
|
||||
description: "Update the status of an issue (e.g., resolve it)",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_id: { type: "string", description: "The ID of the issue" },
|
||||
status: { type: "string", enum: ["resolved", "unresolved", "ignored"], description: "The new status" },
|
||||
},
|
||||
required: ["issue_id", "status"],
|
||||
},
|
||||
};
|
||||
|
||||
const server = new Server(
|
||||
{ name: "glitchtip-mcp", version: "1.0.0" },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
LIST_PROJECTS_TOOL,
|
||||
LIST_ISSUES_TOOL,
|
||||
GET_ISSUE_DETAILS_TOOL,
|
||||
UPDATE_ISSUE_TOOL,
|
||||
],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name === "glitchtip_list_projects") {
|
||||
try {
|
||||
const res = await glitchtipClient.get('/projects/');
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "glitchtip_list_issues") {
|
||||
const { organization_slug, project_slug, query, limit = 20 } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await glitchtipClient.get(`/projects/${organization_slug}/${project_slug}/issues/`, {
|
||||
params: { query, limit }
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "glitchtip_get_issue_details") {
|
||||
const { issue_id } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await glitchtipClient.get(`/issues/${issue_id}/`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "glitchtip_update_issue") {
|
||||
const { issue_id, status } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await glitchtipClient.put(`/issues/${issue_id}/`, { status });
|
||||
return { content: [{ type: "text", text: `Issue ${issue_id} status updated to ${status}.` }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const isStdio = process.argv.includes('--stdio');
|
||||
|
||||
if (isStdio) {
|
||||
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('GlitchTip MCP server is running on stdio');
|
||||
} else {
|
||||
const app = express();
|
||||
let transport: SSEServerTransport | null = null;
|
||||
|
||||
app.get('/sse', async (req, res) => {
|
||||
console.error('New SSE connection established');
|
||||
transport = new SSEServerTransport('/message', res);
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
app.post('/message', async (req, res) => {
|
||||
if (!transport) {
|
||||
res.status(400).send('No active SSE connection');
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
const PORT = process.env.GLITCHTIP_MCP_PORT || 3005;
|
||||
app.listen(PORT, () => {
|
||||
console.error(`GlitchTip MCP server running on http://localhost:${PORT}/sse`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
run().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
13
packages/glitchtip-mcp/src/start.ts
Normal file
13
packages/glitchtip-mcp/src/start.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
config({ path: resolve(__dirname, '../../../.env.local') });
|
||||
config({ path: resolve(__dirname, '../../../.env') });
|
||||
|
||||
import('./index.js').catch(err => {
|
||||
console.error('Failed to start GlitchTip MCP Server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
packages/glitchtip-mcp/tsconfig.json
Normal file
16
packages/glitchtip-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/husky-config",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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
|
||||
|
||||
@@ -20,7 +20,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
RUN pnpm --filter ${APP_NAME:-app} build
|
||||
|
||||
# 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
|
||||
|
||||
# Copy standalone output and static files
|
||||
|
||||
@@ -13,7 +13,7 @@ COPY packages/eslint-config/package.json ./packages/eslint-config/package.json
|
||||
COPY packages/next-config/package.json ./packages/next-config/package.json
|
||||
COPY packages/tsconfig/package.json ./packages/tsconfig/package.json
|
||||
COPY packages/infra/package.json ./packages/infra/package.json
|
||||
COPY packages/cms-infra/package.json ./packages/cms-infra/package.json
|
||||
|
||||
COPY packages/mail/package.json ./packages/mail/package.json
|
||||
COPY packages/cli/package.json ./packages/cli/package.json
|
||||
COPY packages/observability/package.json ./packages/observability/package.json
|
||||
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-app}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
|
||||
gatekeeper:
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:${IMAGE_TAG:-latest}
|
||||
image: git.infra.mintel.me/mmintel/gatekeeper:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
||||
|
||||
directus:
|
||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||
image: git.infra.mintel.me/mmintel/directus:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
|
||||
@@ -177,12 +177,31 @@ jobs:
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
- name: 🔐 Discover Valid Registry Token
|
||||
id: discover_token
|
||||
run: |
|
||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||
TOKENS="${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
||||
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
||||
|
||||
for TOKEN in $TOKENS; do
|
||||
if [ -n "$TOKEN" ]; then
|
||||
for U in $USERS; do
|
||||
if [ -n "$U" ]; then
|
||||
echo "Attempting docker login for a token with user $U..."
|
||||
if echo "$TOKEN" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
||||
echo "✅ Successfully authenticated with a token."
|
||||
echo "::add-mask::$TOKEN"
|
||||
echo "token=$TOKEN" >> $GITHUB_OUTPUT
|
||||
echo "user=$U" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
echo "❌ All available tokens failed to authenticate!"
|
||||
exit 1
|
||||
|
||||
- name: 🏗️ Docker Build & Push
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -197,8 +216,8 @@ jobs:
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
|
||||
NPM_TOKEN=${{ steps.discover_token.outputs.token }}
|
||||
tags: git.infra.mintel.me/mmintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy
|
||||
@@ -262,7 +281,7 @@ jobs:
|
||||
set -e
|
||||
cd "/home/deploy/sites/${{ github.event.repository.name }}"
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
echo "${{ steps.discover_token.outputs.token }}" | docker login git.infra.mintel.me -u "${{ steps.discover_token.outputs.user }}" --password-stdin
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||
docker system prune -f --filter "until=24h"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/infra",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -2,12 +2,24 @@
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY_DATA="/mnt/HC_Volume_104575103/registry-data/docker/registry/v2"
|
||||
REGISTRY_DATA="/mnt/HC_Volume_104796416/registry-data/docker/registry/v2"
|
||||
KEEP_TAGS=3
|
||||
|
||||
echo "🏥 Starting Aggressive Mintel Infrastructure Optimization..."
|
||||
|
||||
# 1. Prune Registry Tags (Filesystem level)
|
||||
# 1. Gitea Maintenance
|
||||
echo "🍵 Running Gitea Maintenance..."
|
||||
GITEA_CONTAINER=$(docker ps --format "{{.Names}}" | grep gitea | head -1 || true)
|
||||
if [ -n "$GITEA_CONTAINER" ]; then
|
||||
# Run common Gitea cleanup tasks
|
||||
docker exec -u git "$GITEA_CONTAINER" gitea admin cron run cleanup_old_repository_archives || true
|
||||
docker exec -u git "$GITEA_CONTAINER" gitea admin cron run cleanup_upload_directory || true
|
||||
docker exec -u git "$GITEA_CONTAINER" gitea admin cron run cleanup_packages || true
|
||||
docker exec -u git "$GITEA_CONTAINER" gitea admin cron run garbage_collect_attachment || true
|
||||
docker exec -u git "$GITEA_CONTAINER" gitea admin cron run garbage_collect_lfs || true
|
||||
fi
|
||||
|
||||
# 2. Prune Registry Tags (Filesystem level)
|
||||
if [ -d "$REGISTRY_DATA" ]; then
|
||||
echo "🔍 Processing Registry tags..."
|
||||
for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
|
||||
@@ -47,4 +59,4 @@ docker system prune -af --filter "until=24h"
|
||||
docker volume prune -f
|
||||
|
||||
echo "✅ Optimization complete!"
|
||||
df -h /mnt/HC_Volume_104575103
|
||||
df -h /mnt/HC_Volume_104796416
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/journaling",
|
||||
"version": "1.9.3",
|
||||
"private": true,
|
||||
"version": "1.9.16",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
25
packages/klz-payload-mcp/package.json
Normal file
25
packages/klz-payload-mcp/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@mintel/klz-payload-mcp",
|
||||
"version": "1.9.16",
|
||||
"description": "KLZ PayloadCMS MCP server for technical product data and leads",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/start.js",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^20.14.10",
|
||||
"typescript": "^5.5.3",
|
||||
"tsx": "^4.19.2"
|
||||
}
|
||||
}
|
||||
617
packages/klz-payload-mcp/src/index.ts
Normal file
617
packages/klz-payload-mcp/src/index.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import express, { Request, Response } from 'express';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import axios from "axios";
|
||||
import https from "https";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://klz-cables.com";
|
||||
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY;
|
||||
const PAYLOAD_EMAIL = process.env.PAYLOAD_EMAIL || "agent@mintel.me";
|
||||
const PAYLOAD_PASSWORD = process.env.PAYLOAD_PASSWORD || "agentpassword123";
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false, // For internal infra
|
||||
});
|
||||
|
||||
let jwtToken: string | null = null;
|
||||
|
||||
const payloadClient = axios.create({
|
||||
baseURL: `${PAYLOAD_URL}/api`,
|
||||
headers: PAYLOAD_API_KEY ? { Authorization: `users API-Key ${PAYLOAD_API_KEY}` } : {},
|
||||
httpsAgent
|
||||
});
|
||||
|
||||
payloadClient.interceptors.request.use(async (config) => {
|
||||
if (!PAYLOAD_API_KEY && !jwtToken && PAYLOAD_EMAIL && PAYLOAD_PASSWORD) {
|
||||
try {
|
||||
const loginRes = await axios.post(`${PAYLOAD_URL}/api/users/login`, {
|
||||
email: PAYLOAD_EMAIL,
|
||||
password: PAYLOAD_PASSWORD
|
||||
}, { httpsAgent });
|
||||
if (loginRes.data && loginRes.data.token) {
|
||||
jwtToken = loginRes.data.token;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to authenticate with Payload CMS using email/password.");
|
||||
}
|
||||
}
|
||||
|
||||
if (jwtToken && !PAYLOAD_API_KEY) {
|
||||
config.headers.Authorization = `JWT ${jwtToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
payloadClient.interceptors.response.use(res => res, async (error) => {
|
||||
const originalRequest = error.config;
|
||||
// If token expired, clear it and retry
|
||||
if (error.response?.status === 401 && !originalRequest._retry && !PAYLOAD_API_KEY) {
|
||||
originalRequest._retry = true;
|
||||
jwtToken = null; // Forces re-authentication on next interceptor run
|
||||
return payloadClient(originalRequest);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
|
||||
const SEARCH_PRODUCTS_TOOL: Tool = {
|
||||
name: "payload_search_products",
|
||||
description: "Search for technical product specifications (cables, cross-sections) in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query or part number" },
|
||||
limit: { type: "number", description: "Maximum number of results" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GET_PRODUCT_TOOL: Tool = {
|
||||
name: "payload_get_product",
|
||||
description: "Get a specific product by its slug or ID",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: { type: "string", description: "Product slug" },
|
||||
id: { type: "string", description: "Product ID (if slug is not used)" }
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_PRODUCT_TOOL: Tool = {
|
||||
name: "payload_create_product",
|
||||
description: "Create a new product in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Product title" },
|
||||
slug: { type: "string", description: "Product slug" },
|
||||
data: { type: "object", description: "Additional product data (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["title"]
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE_PRODUCT_TOOL: Tool = {
|
||||
name: "payload_update_product",
|
||||
description: "Update an existing product in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Product ID to update" },
|
||||
data: { type: "object", description: "Product data to update (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["id", "data"]
|
||||
},
|
||||
};
|
||||
|
||||
const DELETE_PRODUCT_TOOL: Tool = {
|
||||
name: "payload_delete_product",
|
||||
description: "Delete a product from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Product ID to delete" }
|
||||
},
|
||||
required: ["id"]
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_LEADS_TOOL: Tool = {
|
||||
name: "payload_list_leads",
|
||||
description: "List recent lead inquiries and contact requests",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: { type: "number", description: "Maximum number of leads" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GET_LEAD_TOOL: Tool = {
|
||||
name: "payload_get_lead",
|
||||
description: "Get a specific lead by ID",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Lead ID" }
|
||||
},
|
||||
required: ["id"]
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_LEAD_TOOL: Tool = {
|
||||
name: "payload_create_lead",
|
||||
description: "Create a new lead in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
email: { type: "string", description: "Lead email address" },
|
||||
data: { type: "object", description: "Additional lead data (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["email"]
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE_LEAD_TOOL: Tool = {
|
||||
name: "payload_update_lead",
|
||||
description: "Update an existing lead in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Lead ID to update" },
|
||||
data: { type: "object", description: "Lead data to update (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["id", "data"]
|
||||
},
|
||||
};
|
||||
|
||||
const DELETE_LEAD_TOOL: Tool = {
|
||||
name: "payload_delete_lead",
|
||||
description: "Delete a lead from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Lead ID to delete" }
|
||||
},
|
||||
required: ["id"]
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_PAGES_TOOL: Tool = {
|
||||
name: "payload_list_pages",
|
||||
description: "List pages from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: { type: "number", description: "Maximum number of pages" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GET_PAGE_TOOL: Tool = {
|
||||
name: "payload_get_page",
|
||||
description: "Get a specific page by its slug or ID",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: { type: "string", description: "Page slug" },
|
||||
id: { type: "string", description: "Page ID (if slug is not used)" }
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_POSTS_TOOL: Tool = {
|
||||
name: "payload_list_posts",
|
||||
description: "List posts/articles from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: { type: "number", description: "Maximum number of posts" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GET_POST_TOOL: Tool = {
|
||||
name: "payload_get_post",
|
||||
description: "Get a specific post by its slug or ID",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: { type: "string", description: "Post slug" },
|
||||
id: { type: "string", description: "Post ID (if slug is not used)" }
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_PAGE_TOOL: Tool = {
|
||||
name: "payload_create_page",
|
||||
description: "Create a new page in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Page title" },
|
||||
slug: { type: "string", description: "Page slug" },
|
||||
data: { type: "object", description: "Additional page data (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["title"]
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE_PAGE_TOOL: Tool = {
|
||||
name: "payload_update_page",
|
||||
description: "Update an existing page in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Page ID to update" },
|
||||
data: { type: "object", description: "Page data to update (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["id", "data"]
|
||||
},
|
||||
};
|
||||
|
||||
const DELETE_PAGE_TOOL: Tool = {
|
||||
name: "payload_delete_page",
|
||||
description: "Delete a page from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Page ID to delete" }
|
||||
},
|
||||
required: ["id"]
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_POST_TOOL: Tool = {
|
||||
name: "payload_create_post",
|
||||
description: "Create a new post in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Post title" },
|
||||
slug: { type: "string", description: "Post slug" },
|
||||
data: { type: "object", description: "Additional post data (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["title"]
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE_POST_TOOL: Tool = {
|
||||
name: "payload_update_post",
|
||||
description: "Update an existing post in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Post ID to update" },
|
||||
data: { type: "object", description: "Post data to update (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["id", "data"]
|
||||
},
|
||||
};
|
||||
|
||||
const DELETE_POST_TOOL: Tool = {
|
||||
name: "payload_delete_post",
|
||||
description: "Delete a post from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Post ID to delete" }
|
||||
},
|
||||
required: ["id"]
|
||||
},
|
||||
};
|
||||
|
||||
const server = new Server(
|
||||
{ name: "klz-payload-mcp", version: "1.0.0" },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
SEARCH_PRODUCTS_TOOL,
|
||||
GET_PRODUCT_TOOL,
|
||||
CREATE_PRODUCT_TOOL,
|
||||
UPDATE_PRODUCT_TOOL,
|
||||
DELETE_PRODUCT_TOOL,
|
||||
LIST_LEADS_TOOL,
|
||||
GET_LEAD_TOOL,
|
||||
CREATE_LEAD_TOOL,
|
||||
UPDATE_LEAD_TOOL,
|
||||
DELETE_LEAD_TOOL,
|
||||
LIST_PAGES_TOOL,
|
||||
GET_PAGE_TOOL,
|
||||
CREATE_PAGE_TOOL,
|
||||
UPDATE_PAGE_TOOL,
|
||||
DELETE_PAGE_TOOL,
|
||||
LIST_POSTS_TOOL,
|
||||
GET_POST_TOOL,
|
||||
CREATE_POST_TOOL,
|
||||
UPDATE_POST_TOOL,
|
||||
DELETE_POST_TOOL
|
||||
],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name === "payload_search_products") {
|
||||
const { query, limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.get('/products', {
|
||||
params: {
|
||||
where: query ? {
|
||||
or: [
|
||||
{ title: { contains: query } },
|
||||
{ slug: { contains: query } },
|
||||
{ description: { contains: query } }
|
||||
]
|
||||
} : {},
|
||||
limit
|
||||
}
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_get_product") {
|
||||
const { slug, id } = request.params.arguments as any;
|
||||
try {
|
||||
if (id) {
|
||||
const res = await payloadClient.get(`/products/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} else if (slug) {
|
||||
const res = await payloadClient.get('/products', { params: { where: { slug: { equals: slug } }, limit: 1 } });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs[0] || {}, null, 2) }] };
|
||||
}
|
||||
return { isError: true, content: [{ type: "text", text: "Error: must provide slug or id" }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_create_product") {
|
||||
const { title, slug, data = {} } = request.params.arguments as any;
|
||||
try {
|
||||
const payload = { title, slug, _status: 'draft', ...data };
|
||||
const res = await payloadClient.post('/products', payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_update_product") {
|
||||
const { id, data } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.patch(`/products/${id}`, data);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_delete_product") {
|
||||
const { id } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.delete(`/products/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_list_leads") {
|
||||
const { limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.get('/leads', {
|
||||
params: { limit, sort: '-createdAt' }
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_get_lead") {
|
||||
const { id } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.get(`/leads/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_create_lead") {
|
||||
const { email, data = {} } = request.params.arguments as any;
|
||||
try {
|
||||
const payload = { email, ...data };
|
||||
const res = await payloadClient.post('/leads', payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_update_lead") {
|
||||
const { id, data } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.patch(`/leads/${id}`, data);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_delete_lead") {
|
||||
const { id } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.delete(`/leads/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (request.params.name === "payload_list_pages") {
|
||||
const { limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.get('/pages', { params: { limit } });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_get_page") {
|
||||
const { slug, id } = request.params.arguments as any;
|
||||
try {
|
||||
if (id) {
|
||||
const res = await payloadClient.get(`/pages/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} else if (slug) {
|
||||
const res = await payloadClient.get('/pages', { params: { where: { slug: { equals: slug } }, limit: 1 } });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs[0] || {}, null, 2) }] };
|
||||
}
|
||||
return { isError: true, content: [{ type: "text", text: "Error: must provide slug or id" }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_create_page") {
|
||||
const { title, slug, data = {} } = request.params.arguments as any;
|
||||
try {
|
||||
const payload = { title, slug, _status: 'draft', ...data };
|
||||
const res = await payloadClient.post('/pages', payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_update_page") {
|
||||
const { id, data } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.patch(`/pages/${id}`, data);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_delete_page") {
|
||||
const { id } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.delete(`/pages/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_list_posts") {
|
||||
const { limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.get('/posts', { params: { limit, sort: '-createdAt' } });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_get_post") {
|
||||
const { slug, id } = request.params.arguments as any;
|
||||
try {
|
||||
if (id) {
|
||||
const res = await payloadClient.get(`/posts/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} else if (slug) {
|
||||
const res = await payloadClient.get('/posts', { params: { where: { slug: { equals: slug } }, limit: 1 } });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs[0] || {}, null, 2) }] };
|
||||
}
|
||||
return { isError: true, content: [{ type: "text", text: "Error: must provide slug or id" }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_create_post") {
|
||||
const { title, slug, data = {} } = request.params.arguments as any;
|
||||
try {
|
||||
const payload = { title, slug, _status: 'draft', ...data };
|
||||
const res = await payloadClient.post('/posts', payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_update_post") {
|
||||
const { id, data } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.patch(`/posts/${id}`, data);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_delete_post") {
|
||||
const { id } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.delete(`/posts/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const isStdio = process.argv.includes('--stdio');
|
||||
|
||||
if (isStdio) {
|
||||
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('KLZ Payload MCP server is running on stdio');
|
||||
} else {
|
||||
const app = express();
|
||||
let transport: SSEServerTransport | null = null;
|
||||
|
||||
app.get('/sse', async (req: Request, res: Response) => {
|
||||
console.error('New SSE connection established');
|
||||
transport = new SSEServerTransport('/message', res);
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
app.post('/message', async (req: Request, res: Response) => {
|
||||
if (!transport) {
|
||||
res.status(400).send('No active SSE connection');
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
const PORT = process.env.KLZ_PAYLOAD_MCP_PORT || 3006;
|
||||
app.listen(PORT, () => {
|
||||
console.error(`KLZ Payload MCP server running on http://localhost:${PORT}/sse`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
13
packages/klz-payload-mcp/src/start.ts
Normal file
13
packages/klz-payload-mcp/src/start.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
config({ path: resolve(__dirname, '../../../.env.local') });
|
||||
config({ path: resolve(__dirname, '../../../.env') });
|
||||
|
||||
import('./index.js').catch(err => {
|
||||
console.error('Failed to start KLZ Payload MCP Server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
packages/klz-payload-mcp/tsconfig.json
Normal file
16
packages/klz-payload-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/mail",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/meme-generator",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
28
packages/memory-mcp/package.json
Normal file
28
packages/memory-mcp/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@mintel/memory-mcp",
|
||||
"version": "1.9.16",
|
||||
"description": "Local Qdrant-based Memory MCP server",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/start.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",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^2.1.3"
|
||||
}
|
||||
}
|
||||
112
packages/memory-mcp/src/index.ts
Normal file
112
packages/memory-mcp/src/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import express from 'express';
|
||||
import { z } from 'zod';
|
||||
import { QdrantMemoryService } from './qdrant.js';
|
||||
|
||||
async function main() {
|
||||
const server = new McpServer({
|
||||
name: '@mintel/memory-mcp',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const qdrantService = new QdrantMemoryService(process.env.QDRANT_URL || 'http://localhost:6333');
|
||||
|
||||
server.tool(
|
||||
'store_memory',
|
||||
'Store a new piece of knowledge/memory into the vector database. Use this to remember architectural decisions, preferences, aliases, etc.',
|
||||
{
|
||||
label: z.string().describe('A short, descriptive label or title for the memory (e.g., "Architektur-Entscheidungen")'),
|
||||
content: z.string().describe('The actual content to remember (e.g., "In diesem Projekt nutzen wir lieber Composition over Inheritance.")'),
|
||||
},
|
||||
async (args) => {
|
||||
const success = await qdrantService.storeMemory(args.label, args.content);
|
||||
if (success) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Successfully stored memory: [${args.label}]` }],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to store memory: [${args.label}]` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'retrieve_memory',
|
||||
'Retrieve relevant memories from the vector database based on a semantic search query.',
|
||||
{
|
||||
query: z.string().describe('The search query to find relevant memories.'),
|
||||
limit: z.number().optional().describe('Maximum number of results to return (default: 5)'),
|
||||
},
|
||||
async (args) => {
|
||||
const results = await qdrantService.retrieveMemory(args.query, args.limit || 5);
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'No relevant memories found.' }],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedResults = results
|
||||
.map(r => `- [${r.label}] (Score: ${r.score.toFixed(3)}): ${r.content}`)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${results.length} memories:\n\n${formattedResults}` }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const isStdio = process.argv.includes('--stdio');
|
||||
|
||||
if (isStdio) {
|
||||
// Connect Stdio FIRST to avoid handshake timeouts while loading model
|
||||
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('Memory MCP server is running on stdio');
|
||||
|
||||
// Initialize dependency after connection
|
||||
try {
|
||||
await qdrantService.initialize();
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize local dependencies:', e);
|
||||
}
|
||||
} else {
|
||||
const app = express();
|
||||
let transport: SSEServerTransport | null = null;
|
||||
|
||||
app.get('/sse', async (req, res) => {
|
||||
console.error('New SSE connection established');
|
||||
transport = new SSEServerTransport('/message', res);
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
app.post('/message', async (req, res) => {
|
||||
if (!transport) {
|
||||
res.status(400).send('No active SSE connection');
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
const PORT = process.env.MEMORY_MCP_PORT || 3002;
|
||||
app.listen(PORT, async () => {
|
||||
console.error(`Memory MCP server running on http://localhost:${PORT}/sse`);
|
||||
// Initialize dependencies in SSE mode on startup
|
||||
try {
|
||||
await qdrantService.initialize();
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize local dependencies:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
89
packages/memory-mcp/src/qdrant.test.ts
Normal file
89
packages/memory-mcp/src/qdrant.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { QdrantMemoryService } from './qdrant.js';
|
||||
|
||||
vi.mock('@xenova/transformers', () => {
|
||||
return {
|
||||
env: { allowRemoteModels: false, localModelPath: './models' },
|
||||
pipeline: vi.fn().mockResolvedValue(async (text: string) => {
|
||||
// Mock embedding generation: returns an array of 384 numbers
|
||||
return { data: new Float32Array(384).fill(0.1) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockCreateCollection = vi.fn();
|
||||
const mockGetCollections = vi.fn().mockResolvedValue({ collections: [] });
|
||||
const mockUpsert = vi.fn();
|
||||
const mockSearch = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'test-id',
|
||||
version: 1,
|
||||
score: 0.9,
|
||||
payload: { label: 'Test Label', content: 'Test Content' }
|
||||
}
|
||||
]);
|
||||
|
||||
vi.mock('@qdrant/js-client-rest', () => {
|
||||
return {
|
||||
QdrantClient: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
getCollections: mockGetCollections,
|
||||
createCollection: mockCreateCollection,
|
||||
upsert: mockUpsert,
|
||||
search: mockSearch
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('QdrantMemoryService', () => {
|
||||
let service: QdrantMemoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new QdrantMemoryService('http://localhost:6333');
|
||||
});
|
||||
|
||||
it('should initialize and create collection if missing', async () => {
|
||||
mockGetCollections.mockResolvedValueOnce({ collections: [] });
|
||||
await service.initialize();
|
||||
|
||||
expect(mockGetCollections).toHaveBeenCalled();
|
||||
expect(mockCreateCollection).toHaveBeenCalledWith('mcp_memory', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should not create collection if it already exists', async () => {
|
||||
mockGetCollections.mockResolvedValueOnce({ collections: [{ name: 'mcp_memory' }] });
|
||||
await service.initialize();
|
||||
|
||||
expect(mockCreateCollection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should store memory', async () => {
|
||||
await service.initialize();
|
||||
const result = await service.storeMemory('Design', 'Composition over Inheritance');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockUpsert).toHaveBeenCalledWith('mcp_memory', expect.objectContaining({
|
||||
wait: true,
|
||||
points: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
label: 'Design',
|
||||
content: 'Composition over Inheritance'
|
||||
})
|
||||
})
|
||||
])
|
||||
}));
|
||||
});
|
||||
|
||||
it('should retrieve memory', async () => {
|
||||
await service.initialize();
|
||||
const results = await service.retrieveMemory('Design');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].label).toBe('Test Label');
|
||||
expect(results[0].content).toBe('Test Content');
|
||||
expect(results[0].score).toBe(0.9);
|
||||
});
|
||||
});
|
||||
110
packages/memory-mcp/src/qdrant.ts
Normal file
110
packages/memory-mcp/src/qdrant.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { pipeline, env } from '@xenova/transformers';
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
|
||||
// Be sure to set local caching options for transformers
|
||||
env.allowRemoteModels = true;
|
||||
env.localModelPath = './models';
|
||||
|
||||
export class QdrantMemoryService {
|
||||
private client: QdrantClient;
|
||||
private collectionName = 'mcp_memory';
|
||||
private embedder: any = null;
|
||||
|
||||
constructor(url: string = 'http://localhost:6333') {
|
||||
this.client = new QdrantClient({ url });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the embedding model and the Qdrant collection
|
||||
*/
|
||||
async initialize() {
|
||||
// 1. Load the embedding model (using a lightweight model suitable for semantic search)
|
||||
console.error('Loading embedding model...');
|
||||
this.embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
|
||||
|
||||
// 2. Ensure collection exists
|
||||
console.error(`Checking for collection: ${this.collectionName}`);
|
||||
try {
|
||||
const collections = await this.client.getCollections();
|
||||
const exists = collections.collections.some(c => c.name === this.collectionName);
|
||||
|
||||
if (!exists) {
|
||||
console.error(`Creating collection: ${this.collectionName}`);
|
||||
await this.client.createCollection(this.collectionName, {
|
||||
vectors: {
|
||||
size: 384, // size for all-MiniLM-L6-v2
|
||||
distance: 'Cosine'
|
||||
}
|
||||
});
|
||||
console.error('Collection created successfully.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize Qdrant collection:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a vector embedding for the given text
|
||||
*/
|
||||
private async getEmbedding(text: string): Promise<number[]> {
|
||||
if (!this.embedder) {
|
||||
throw new Error('Embedder not initialized. Call initialize() first.');
|
||||
}
|
||||
const output = await this.embedder(text, { pooling: 'mean', normalize: true });
|
||||
return Array.from(output.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a memory entry into Qdrant
|
||||
*/
|
||||
async storeMemory(label: string, content: string): Promise<boolean> {
|
||||
try {
|
||||
const fullText = `${label}: ${content}`;
|
||||
const vector = await this.getEmbedding(fullText);
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
await this.client.upsert(this.collectionName, {
|
||||
wait: true,
|
||||
points: [
|
||||
{
|
||||
id,
|
||||
vector,
|
||||
payload: {
|
||||
label,
|
||||
content,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to store memory:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves memory entries relevant to the query
|
||||
*/
|
||||
async retrieveMemory(query: string, limit: number = 5): Promise<Array<{ label: string, content: string, score: number }>> {
|
||||
try {
|
||||
const vector = await this.getEmbedding(query);
|
||||
const searchResults = await this.client.search(this.collectionName, {
|
||||
vector,
|
||||
limit,
|
||||
with_payload: true
|
||||
});
|
||||
|
||||
return searchResults.map(result => ({
|
||||
label: String(result.payload?.label || ''),
|
||||
content: String(result.payload?.content || ''),
|
||||
score: result.score
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to retrieve memory:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/memory-mcp/src/start.ts
Normal file
16
packages/memory-mcp/src/start.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
// Try to load .env.local first (contains credentials usually)
|
||||
config({ path: resolve(__dirname, '../../../.env.local') });
|
||||
// Fallback to .env (contains defaults)
|
||||
config({ path: resolve(__dirname, '../../../.env') });
|
||||
|
||||
// Now boot the compiled MCP index
|
||||
import('./index.js').catch(err => {
|
||||
console.error('Failed to start MCP Server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
packages/memory-mcp/tsconfig.json
Normal file
16
packages/memory-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-config",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-feedback",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-observability",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-utils",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/observability",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/page-audit",
|
||||
"version": "1.9.3",
|
||||
"private": true,
|
||||
"version": "1.9.16",
|
||||
"description": "AI-powered website IST-analysis using DataForSEO and Gemini",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
2
packages/payload-ai/.npmrc
Normal file
2
packages/payload-ai/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||
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
|
||||
51
packages/payload-ai/package.json
Normal file
51
packages/payload-ai/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@mintel/payload-ai",
|
||||
"version": "1.9.16",
|
||||
"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": {
|
||||
"@ai-sdk/openai": "^3.0.39",
|
||||
"@ai-sdk/react": "^3.0.110",
|
||||
"@mintel/content-engine": "workspace:*",
|
||||
"@mintel/thumbnail-generator": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@qdrant/js-client-rest": "^1.17.0",
|
||||
"ai": "^6.0.108",
|
||||
"replicate": "^1.4.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"
|
||||
}
|
||||
}
|
||||
90
packages/payload-ai/src/chatPlugin.ts
Normal file
90
packages/payload-ai/src/chatPlugin.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Config, Plugin } from 'payload'
|
||||
import { AIChatPermissionsCollection } from './collections/AIChatPermissions.js'
|
||||
import type { PayloadChatPluginConfig } from './types.js'
|
||||
import { optimizePostEndpoint } from './endpoints/optimizeEndpoint.js'
|
||||
import { generateSlugEndpoint, generateThumbnailEndpoint, generateSingleFieldEndpoint } from './endpoints/generateEndpoints.js'
|
||||
|
||||
export const payloadChatPlugin =
|
||||
(pluginOptions: PayloadChatPluginConfig): Plugin =>
|
||||
(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" })
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/api/mintel-ai/optimize',
|
||||
method: 'post',
|
||||
handler: optimizePostEndpoint,
|
||||
},
|
||||
{
|
||||
path: '/api/mintel-ai/generate-slug',
|
||||
method: 'post',
|
||||
handler: generateSlugEndpoint,
|
||||
},
|
||||
{
|
||||
path: '/api/mintel-ai/generate-thumbnail',
|
||||
method: 'post',
|
||||
handler: generateThumbnailEndpoint,
|
||||
},
|
||||
{
|
||||
path: '/api/mintel-ai/generate-single-field',
|
||||
method: 'post',
|
||||
handler: generateSingleFieldEndpoint,
|
||||
},
|
||||
]
|
||||
|
||||
// 3. Inject Chat React Component into Admin UI
|
||||
if (pluginOptions.renderChatBubble !== false) {
|
||||
config.admin = {
|
||||
...(config.admin || {}),
|
||||
components: {
|
||||
...(config.admin?.components || {}),
|
||||
providers: [
|
||||
...(config.admin?.components?.providers || []),
|
||||
'@mintel/payload-ai/components/ChatWindow#ChatWindowProvider',
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
69
packages/payload-ai/src/collections/AIChatPermissions.ts
Normal file
69
packages/payload-ai/src/collections/AIChatPermissions.ts
Normal file
@@ -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?',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
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>
|
||||
);
|
||||
};
|
||||
136
packages/payload-ai/src/components/ChatWindow/index.tsx
Normal file
136
packages/payload-ai/src/components/ChatWindow/index.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useChat } from '@ai-sdk/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 [pageContext, setPageContext] = useState<any>({ url: '' })
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const path = window.location.pathname;
|
||||
let collectionSlug = null;
|
||||
let id = null;
|
||||
// Payload admin URLs are usually /admin/collections/:slug/:id
|
||||
const match = path.match(/\/collections\/([^/]+)(?:\/([^/]+))?/);
|
||||
if (match) {
|
||||
collectionSlug = match[1];
|
||||
if (match[2] && match[2] !== 'create') {
|
||||
id = match[2];
|
||||
}
|
||||
}
|
||||
|
||||
setPageContext({
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
collectionSlug,
|
||||
id
|
||||
})
|
||||
}
|
||||
}, [isOpen]) // Refresh context when chat is opened
|
||||
|
||||
// @ts-ignore - AI hook version mismatch between core and react packages
|
||||
const { messages, input, handleInputChange, handleSubmit, setMessages } = useChat({
|
||||
api: '/api/mcp-chat',
|
||||
initialMessages: [],
|
||||
body: {
|
||||
pageContext
|
||||
}
|
||||
} as any)
|
||||
|
||||
// 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: '450px',
|
||||
height: '650px',
|
||||
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: any) => (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useField, useDocumentInfo, useForm } from "@payloadcms/ui";
|
||||
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 resData = await fetch("/api/api/mintel-ai/generate-single-field", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentTitle: (title as string) || "",
|
||||
documentContent: draftContent,
|
||||
fieldName,
|
||||
fieldDescription,
|
||||
instructions,
|
||||
}),
|
||||
});
|
||||
const res = await resData.json();
|
||||
|
||||
if (res.success && res.text) {
|
||||
setValue(res.text);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(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,111 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useField } from "@payloadcms/ui";
|
||||
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 resData = await fetch("/api/api/mintel-ai/generate-slug", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
draftContent,
|
||||
oldSlug: initialValue as string,
|
||||
instructions,
|
||||
}),
|
||||
});
|
||||
const res = await resData.json();
|
||||
|
||||
if (res.success && res.slug) {
|
||||
setValue(res.slug);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
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,112 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useField } from "@payloadcms/ui";
|
||||
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 resData = await fetch("/api/api/mintel-ai/generate-thumbnail", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
draftContent,
|
||||
title,
|
||||
instructions,
|
||||
}),
|
||||
});
|
||||
const res = await resData.json();
|
||||
|
||||
if (res.success && res.mediaId) {
|
||||
setValue(res.mediaId);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
140
packages/payload-ai/src/components/OptimizeButton.tsx
Normal file
140
packages/payload-ai/src/components/OptimizeButton.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useDocumentInfo } from "@payloadcms/ui";
|
||||
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 res = await fetch("/api/api/mintel-ai/optimize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ draftContent: payloadText, instructions }),
|
||||
});
|
||||
const response = await res.json();
|
||||
|
||||
if (response.success && response.lexicalAST) {
|
||||
// 3. Inject the new Lexical AST directly into the field form state
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
115
packages/payload-ai/src/endpoints/chatEndpoint.ts
Normal file
115
packages/payload-ai/src/endpoints/chatEndpoint.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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, pageContext } = (await req.json?.() || { messages: [] }) as { messages: any[], pageContext?: any }
|
||||
|
||||
// 1. Check AI Permissions for req.user
|
||||
// Look up the collection for permissions
|
||||
const permissionsQuery = await req.payload.find({
|
||||
collection: 'ai-chat-permissions' as any,
|
||||
where: {
|
||||
or: [
|
||||
{ targetUser: { equals: req.user.id } },
|
||||
{ targetRole: { equals: req.user.role || 'admin' } }
|
||||
]
|
||||
},
|
||||
limit: 10
|
||||
})
|
||||
|
||||
const allowedCollections = new Set<string>()
|
||||
const allowedMcpServers = new Set<string>()
|
||||
|
||||
for (const perm of permissionsQuery.docs) {
|
||||
if (perm.allowedCollections) {
|
||||
perm.allowedCollections.forEach((c: string) => allowedCollections.add(c))
|
||||
}
|
||||
if (perm.allowedMcpServers) {
|
||||
perm.allowedMcpServers.forEach((s: string) => allowedMcpServers.add(s))
|
||||
}
|
||||
}
|
||||
|
||||
let accessCollections = Array.from(allowedCollections)
|
||||
if (accessCollections.length === 0) {
|
||||
// Fallback or demo config if not configured yet
|
||||
accessCollections = ['users', 'pages', 'posts', 'products', 'leads', 'media']
|
||||
}
|
||||
|
||||
let activeTools: Record<string, any> = {}
|
||||
|
||||
// 2. Generate Payload Local Tools
|
||||
if (accessCollections.length > 0) {
|
||||
const payloadTools = generatePayloadLocalTools(req.payload, req, accessCollections)
|
||||
activeTools = { ...activeTools, ...payloadTools }
|
||||
}
|
||||
|
||||
// 3. Connect External MCPs
|
||||
if (Array.from(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.
|
||||
`
|
||||
|
||||
const contextContextStr = pageContext ? `
|
||||
Current User Context:
|
||||
URL: ${pageContext.url || 'Unknown'}
|
||||
Title: ${pageContext.title || 'Unknown'}
|
||||
Collection: ${pageContext.collectionSlug || 'None'}
|
||||
Document ID: ${pageContext.id || 'None'}
|
||||
You can use this to understand what the user is currently looking at.
|
||||
` : ''
|
||||
|
||||
try {
|
||||
const result = streamText({
|
||||
// @ts-ignore - AI SDK type mismatch
|
||||
model: openrouter('google/gemini-3.0-flash'),
|
||||
messages,
|
||||
tools: activeTools,
|
||||
// @ts-ignore - AI SDK type mismatch with maxSteps
|
||||
maxSteps: 10,
|
||||
system: `You are a helpful Payload CMS Agent orchestrating the local Mintel ecosystem.
|
||||
You only have access to tools explicitly granted by the Admin.
|
||||
You can completely control Payload CMS (read, create, update, delete documents).
|
||||
If you need more details to fulfill a request (e.g. creating a blog post), you can ask the user.
|
||||
${contextContextStr}
|
||||
${memorySystemPrompt}`
|
||||
})
|
||||
|
||||
return result.toTextStreamResponse()
|
||||
} catch (error) {
|
||||
console.error("AI Error:", error)
|
||||
return Response.json({ error: 'Failed to process AI request' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
158
packages/payload-ai/src/endpoints/generateEndpoints.ts
Normal file
158
packages/payload-ai/src/endpoints/generateEndpoints.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { PayloadRequest } from "payload";
|
||||
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 const generateSlugEndpoint = async (req: PayloadRequest) => {
|
||||
try {
|
||||
const { title, draftContent, oldSlug, instructions } = (await req.json?.() || {}) as any;
|
||||
const orchestrator = await getOrchestrator();
|
||||
const newSlug = await orchestrator.generateSlug(
|
||||
draftContent,
|
||||
title,
|
||||
instructions,
|
||||
);
|
||||
|
||||
if (oldSlug && oldSlug !== newSlug) {
|
||||
await req.payload.create({
|
||||
collection: "redirects" as any,
|
||||
data: {
|
||||
from: oldSlug,
|
||||
to: newSlug,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ success: true, slug: newSlug });
|
||||
} catch (e: any) {
|
||||
return Response.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const generateThumbnailEndpoint = async (req: PayloadRequest) => {
|
||||
try {
|
||||
const { draftContent, title, instructions } = (await req.json?.() || {}) as any;
|
||||
const OPENROUTER_KEY =
|
||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
|
||||
|
||||
if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY in .env");
|
||||
if (!REPLICATE_KEY) throw new Error("Missing REPLICATE_API_KEY in .env");
|
||||
|
||||
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 req.payload.create({
|
||||
collection: "media" as any,
|
||||
data: {
|
||||
alt: title ? `Thumbnail for ${title}` : "AI Generated Thumbnail",
|
||||
},
|
||||
file: {
|
||||
data: fileData,
|
||||
name: fileName,
|
||||
mimetype: "image/png",
|
||||
size: stat.size,
|
||||
},
|
||||
});
|
||||
|
||||
await fs.unlink(tmpPath).catch(() => { });
|
||||
|
||||
return Response.json({ success: true, mediaId: newMedia.id });
|
||||
} catch (e: any) {
|
||||
return Response.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const generateSingleFieldEndpoint = async (req: PayloadRequest) => {
|
||||
try {
|
||||
const { documentTitle, documentContent, fieldName, fieldDescription, instructions } = (await req.json?.() || {}) as any;
|
||||
|
||||
const OPENROUTER_KEY =
|
||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||
if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY");
|
||||
|
||||
const contextDocsData = await req.payload.find({
|
||||
collection: "context-files" as any,
|
||||
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 Response.json({ success: true, text });
|
||||
} catch (e: any) {
|
||||
return Response.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
75
packages/payload-ai/src/endpoints/optimizeEndpoint.ts
Normal file
75
packages/payload-ai/src/endpoints/optimizeEndpoint.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { PayloadRequest } from 'payload'
|
||||
import { parseMarkdownToLexical } from "../utils/lexicalParser.js";
|
||||
|
||||
export const optimizePostEndpoint = async (req: PayloadRequest) => {
|
||||
try {
|
||||
const { draftContent, instructions } = (await req.json?.() || {}) as { draftContent: string; instructions?: string };
|
||||
|
||||
if (!draftContent) {
|
||||
return Response.json({ error: 'Missing draftContent' }, { status: 400 })
|
||||
}
|
||||
|
||||
const globalAiSettings = (await req.payload.findGlobal({ slug: "ai-settings" })) as any;
|
||||
const customSources =
|
||||
globalAiSettings?.customSources?.map((s: any) => s.sourceName) || [];
|
||||
|
||||
const OPENROUTER_KEY =
|
||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
|
||||
|
||||
if (!OPENROUTER_KEY) {
|
||||
return Response.json({ error: "OPENROUTER_KEY not found in environment." }, { status: 500 })
|
||||
}
|
||||
|
||||
// Dynamically import to avoid bundling it into client components that might accidentally import this file
|
||||
const importDynamic = new Function("modulePath", "return import(modulePath)");
|
||||
const { AiBlogPostOrchestrator } = await importDynamic("@mintel/content-engine");
|
||||
|
||||
const orchestrator = new AiBlogPostOrchestrator({
|
||||
apiKey: OPENROUTER_KEY,
|
||||
replicateApiKey: REPLICATE_KEY,
|
||||
model: "google/gemini-3-flash-preview",
|
||||
});
|
||||
|
||||
const contextDocsData = await req.payload.find({
|
||||
collection: "context-files" as any,
|
||||
limit: 100,
|
||||
});
|
||||
const projectContext = contextDocsData.docs.map((doc: any) => doc.content);
|
||||
|
||||
const optimizedMarkdown = await orchestrator.optimizeDocument({
|
||||
content: draftContent,
|
||||
projectContext,
|
||||
availableComponents: [],
|
||||
instructions,
|
||||
internalLinks: [],
|
||||
customSources,
|
||||
});
|
||||
|
||||
if (!optimizedMarkdown || typeof optimizedMarkdown !== "string") {
|
||||
return Response.json({ error: "AI returned invalid markup." }, { status: 500 })
|
||||
}
|
||||
|
||||
const blocks = parseMarkdownToLexical(optimizedMarkdown);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
lexicalAST: {
|
||||
root: {
|
||||
type: "root",
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: blocks,
|
||||
direction: "ltr",
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error("Failed to optimize post in endpoint:", error);
|
||||
return Response.json({
|
||||
success: false,
|
||||
error: error.message || "An unknown error occurred during optimization.",
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
19
packages/payload-ai/src/index.ts
Normal file
19
packages/payload-ai/src/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @mintel/payload-ai
|
||||
* Primary entry point for reusing Mintel AI extensions in Payload CMS.
|
||||
*/
|
||||
|
||||
export * from './globals/AiSettings.js';
|
||||
export * from './components/FieldGenerators/AiFieldButton.js';
|
||||
export * from './components/AiMediaButtons.js';
|
||||
export * from './components/OptimizeButton.js';
|
||||
export * from './components/FieldGenerators/GenerateThumbnailButton.js';
|
||||
export * from './components/FieldGenerators/GenerateSlugButton.js';
|
||||
export * from './utils/lexicalParser.js';
|
||||
export * from './endpoints/replicateMediaEndpoint.js';
|
||||
export * from './chatPlugin.js';
|
||||
export * from './types.js';
|
||||
export * from './endpoints/chatEndpoint.js';
|
||||
export * from './tools/mcpAdapter.js';
|
||||
export * from './tools/memoryDb.js';
|
||||
export * from './tools/payloadLocal.js';
|
||||
65
packages/payload-ai/src/tools/mcpAdapter.ts
Normal file
65
packages/payload-ai/src/tools/mcpAdapter.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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
|
||||
// @ts-ignore - AI strict mode overload bug with implicit zod inferences
|
||||
execute: async (args: any) => {
|
||||
const result = await client.callTool({
|
||||
name: extTool.name,
|
||||
arguments: args
|
||||
})
|
||||
return result
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { tools: aiSdkTools, client }
|
||||
}
|
||||
115
packages/payload-ai/src/tools/memoryDb.ts
Normal file
115
packages/payload-ai/src/tools/memoryDb.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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: any) => 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".'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode bug
|
||||
execute: async ({ fact, category }: { fact: string; category?: string }) => {
|
||||
// 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.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode bug
|
||||
execute: async ({ query }: { query: string }) => {
|
||||
// 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: any) => r.payload?.fact || '')
|
||||
} catch (error) {
|
||||
console.error("Qdrant search error:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
107
packages/payload-ai/src/tools/payloadLocal.ts
Normal file
107
packages/payload-ai/src/tools/payloadLocal.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode type inference bug
|
||||
execute: async ({ limit = 10, page = 1, query }: { limit?: number; page?: number; query?: string }) => {
|
||||
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.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode type inference bug
|
||||
execute: async ({ id }: { id: string | number }) => {
|
||||
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.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode type inference bug
|
||||
execute: async ({ data }: { data: Record<string, any> }) => {
|
||||
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.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode type inference bug
|
||||
execute: async ({ id, data }: { id: string | number; data: Record<string, any> }) => {
|
||||
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.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode type inference bug
|
||||
execute: async ({ id }: { id: string | number }) => {
|
||||
return await payload.delete({
|
||||
collection: collectionSlug as any,
|
||||
id,
|
||||
req, // Enforce access control
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
8
packages/payload-ai/src/types.d.ts
vendored
Normal file
8
packages/payload-ai/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export type PayloadChatPluginConfig = {
|
||||
enabled?: boolean
|
||||
/** Render the chat bubble on the bottom right? Defaults to true */
|
||||
renderChatBubble?: boolean
|
||||
allowedCollections?: string[]
|
||||
mcpServers?: any[]
|
||||
}
|
||||
|
||||
18
packages/payload-ai/src/types.ts
Normal file
18
packages/payload-ai/src/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Plugin } from 'payload'
|
||||
|
||||
export interface PayloadChatPluginConfig {
|
||||
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
|
||||
}[]
|
||||
}
|
||||
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;
|
||||
}
|
||||
39
packages/payload-ai/tsconfig.json
Normal file
39
packages/payload-ai/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"paths": {
|
||||
"@payload-config": [
|
||||
"../../apps/mintel.me/payload.config.ts",
|
||||
"../../apps/web/payload.config.ts",
|
||||
"./node_modules/@payloadcms/next/dist/index.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"src/types.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/pdf",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.16",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
4
packages/seo-engine/.gitignore
vendored
Normal file
4
packages/seo-engine/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.seo-output
|
||||
.env
|
||||
123
packages/seo-engine/README.md
Normal file
123
packages/seo-engine/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# @mintel/seo-engine
|
||||
|
||||
AI-powered SEO keyword discovery, topic clustering, competitor analysis, and content gap identification — grounded in real search data, zero hallucinations.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ProjectContext + SeoConfig
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ SEO Engine Orchestrator │
|
||||
│ │
|
||||
│ 1. Seed Query Expansion │
|
||||
│ (company + industry + seedKeywords) │
|
||||
│ │
|
||||
│ 2. Data Collection (parallel) │
|
||||
│ ├── Serper Search Agent │
|
||||
│ │ (related searches, PAA, │
|
||||
│ │ organic snippets, volume proxy) │
|
||||
│ ├── Serper Autocomplete Agent │
|
||||
│ │ (long-tail suggestions) │
|
||||
│ └── Serper Competitor Agent │
|
||||
│ (top-10 SERP positions) │
|
||||
│ │
|
||||
│ 3. LLM Evaluation (Gemini/Claude) │
|
||||
│ → Strict context filtering │
|
||||
│ → Topic Clustering + Intent Mapping │
|
||||
│ │
|
||||
│ 4. Content Gap Analysis (LLM) │
|
||||
│ → Compare clusters vs existing pages │
|
||||
│ → Identify missing content │
|
||||
└──────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
SeoEngineOutput
|
||||
(clusters, gaps, competitors, discarded)
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { runSeoEngine } from "@mintel/seo-engine";
|
||||
|
||||
const result = await runSeoEngine(
|
||||
{
|
||||
companyName: "KLZ Cables",
|
||||
industry: "Mittelspannungskabel, Kabeltiefbau",
|
||||
briefing: "B2B provider of specialized medium-voltage cables.",
|
||||
targetAudience: "Bauleiter, Netzbetreiber",
|
||||
competitors: ["nkt.de", "faberkabel.de"],
|
||||
seedKeywords: ["NA2XS2Y", "VPE-isoliert"],
|
||||
existingPages: [
|
||||
{ url: "/produkte", title: "Produkte" },
|
||||
{ url: "/kontakt", title: "Kontakt" },
|
||||
],
|
||||
locale: { gl: "de", hl: "de" },
|
||||
},
|
||||
{
|
||||
serperApiKey: process.env.SERPER_API_KEY!,
|
||||
openRouterApiKey: process.env.OPENROUTER_API_KEY!,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### `ProjectContext`
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------ | ----------------------------- | ------------------------------------------- |
|
||||
| `companyName` | `string?` | Client company name |
|
||||
| `industry` | `string?` | Industry / main focus keywords |
|
||||
| `briefing` | `string?` | Project briefing text |
|
||||
| `targetAudience` | `string?` | Who the content targets |
|
||||
| `competitors` | `string[]?` | Competitor domains to analyze |
|
||||
| `seedKeywords` | `string[]?` | Explicit seed keywords beyond auto-derived |
|
||||
| `existingPages` | `{ url, title }[]?` | Current site pages for content gap analysis |
|
||||
| `customGuidelines` | `string?` | Extra strict filtering rules for the LLM |
|
||||
| `locale` | `{ gl: string, hl: string }?` | Google locale (default: `de`) |
|
||||
|
||||
### `SeoConfig`
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------ | --------- | -------------------------------------------- |
|
||||
| `serperApiKey` | `string` | **Required.** Serper API key |
|
||||
| `openRouterApiKey` | `string` | **Required.** OpenRouter API key |
|
||||
| `model` | `string?` | LLM model (default: `google/gemini-2.5-pro`) |
|
||||
| `maxKeywords` | `number?` | Cap total keywords returned |
|
||||
|
||||
## Output
|
||||
|
||||
```typescript
|
||||
interface SeoEngineOutput {
|
||||
topicClusters: TopicCluster[]; // Grouped keywords with intent + scores
|
||||
competitorRankings: CompetitorRanking[]; // Who ranks for your terms
|
||||
contentGaps: ContentGap[]; // Missing pages you should create
|
||||
discardedTerms: string[]; // Terms filtered out (with reasons)
|
||||
}
|
||||
```
|
||||
|
||||
## Agents
|
||||
|
||||
| Agent | Source | Data |
|
||||
| --------------------- | ---------------------- | ----------------------------------- |
|
||||
| `serper-agent` | Serper `/search` | Related searches, PAA, snippets |
|
||||
| `serper-autocomplete` | Serper `/autocomplete` | Google Autocomplete long-tail terms |
|
||||
| `serper-competitors` | Serper `/search` | Competitor SERP positions |
|
||||
|
||||
## API Keys
|
||||
|
||||
- **Serper** — [serper.dev](https://serper.dev) (pay-per-search, ~$0.001/query)
|
||||
- **OpenRouter** — [openrouter.ai](https://openrouter.ai) (pay-per-token)
|
||||
|
||||
No monthly subscriptions. Pure pay-on-demand.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install # from monorepo root
|
||||
pnpm --filter @mintel/seo-engine build
|
||||
npx tsx src/test-run.ts # smoke test (needs API keys in .env)
|
||||
```
|
||||
36
packages/seo-engine/package.json
Normal file
36
packages/seo-engine/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@mintel/seo-engine",
|
||||
"version": "1.9.16",
|
||||
"description": "AI-powered SEO keyword and topic cluster evaluation engine",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"dotenv": "^16.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.17.17",
|
||||
"tsup": "^8.3.6",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
132
packages/seo-engine/src/agents/scraper.ts
Normal file
132
packages/seo-engine/src/agents/scraper.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import axios from "axios";
|
||||
import * as cheerio from "cheerio";
|
||||
import { llmJsonRequest } from "../llm-client.js";
|
||||
|
||||
export interface ScrapedContext {
|
||||
url: string;
|
||||
wordCount: number;
|
||||
text: string;
|
||||
headings: { level: number; text: string }[];
|
||||
}
|
||||
|
||||
export interface ReverseEngineeredBriefing {
|
||||
recommendedWordCount: number;
|
||||
coreTopicsToCover: string[];
|
||||
suggestedHeadings: string[];
|
||||
entitiesToInclude: string[];
|
||||
contentFormat: string; // e.g. "Lange Liste mit Fakten", "Kaufberater", "Lexikon-Eintrag"
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the HTML of a URL and extracts the main readable text and headings.
|
||||
*/
|
||||
export async function scrapeCompetitorUrl(
|
||||
url: string,
|
||||
): Promise<ScrapedContext | null> {
|
||||
try {
|
||||
console.log(`[Scraper] Fetching source: ${url}`);
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// Remove junk elements before extracting text
|
||||
$(
|
||||
"script, style, nav, footer, header, aside, .cookie, .banner, iframe",
|
||||
).remove();
|
||||
|
||||
const headings: { level: number; text: string }[] = [];
|
||||
$(":header").each((_, el) => {
|
||||
const level = parseInt(el.tagName.replace(/h/i, ""), 10);
|
||||
const text = $(el).text().trim().replace(/\s+/g, " ");
|
||||
if (text) headings.push({ level, text });
|
||||
});
|
||||
|
||||
// Extract body text, removing excessive whitespace
|
||||
const text = $("body").text().replace(/\s+/g, " ").trim();
|
||||
const wordCount = text.split(" ").length;
|
||||
|
||||
return {
|
||||
url,
|
||||
text: text.slice(0, 15000), // Cap length to prevent blowing up the LLM token limit
|
||||
wordCount,
|
||||
headings,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[Scraper] Failed to scrape ${url}: ${(err as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const BRIEFING_SYSTEM_PROMPT = `
|
||||
You are a Senior Technical SEO Strategist.
|
||||
I will give you the scraped text and headings of a competitor's article that currently ranks #1 on Google for our target keyword.
|
||||
|
||||
### OBJECTIVE:
|
||||
Reverse engineer the content. Tell me EXACTLY what topics, entities, and headings we must include
|
||||
in our own article to beat this competitor.
|
||||
Do not just copy their headings. Distill the *core intent* and *required knowledge depth*.
|
||||
|
||||
### RULES:
|
||||
- If the text is very short (e.g., an e-commerce category page), mention that the format is "Category Page" and recommend a word count +50% higher than theirs.
|
||||
- Extract hyper-specific entities (e.g. DIN norms, specific materials, specific processes) that prove topic authority.
|
||||
- LANGUAGE: Match the language of the provided text.
|
||||
|
||||
### OUTPUT FORMAT:
|
||||
{
|
||||
"recommendedWordCount": number,
|
||||
"coreTopicsToCover": ["string"],
|
||||
"suggestedHeadings": ["string"],
|
||||
"entitiesToInclude": ["string"],
|
||||
"contentFormat": "string"
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Analyzes the scraped context using an LLM to generate a blueprint to beat the competitor.
|
||||
*/
|
||||
export async function analyzeCompetitorContent(
|
||||
context: ScrapedContext,
|
||||
targetKeyword: string,
|
||||
config: { openRouterApiKey: string; model?: string },
|
||||
): Promise<ReverseEngineeredBriefing | null> {
|
||||
const userPrompt = `
|
||||
TARGET KEYWORD TO BEAT: "${targetKeyword}"
|
||||
COMPETITOR URL: ${context.url}
|
||||
COMPETITOR WORD COUNT: ${context.wordCount}
|
||||
|
||||
COMPETITOR HEADINGS:
|
||||
${context.headings.map((h) => `H${h.level}: ${h.text}`).join("\n")}
|
||||
|
||||
COMPETITOR TEXT (Truncated):
|
||||
${context.text}
|
||||
`;
|
||||
|
||||
try {
|
||||
const { data } = await llmJsonRequest<ReverseEngineeredBriefing>({
|
||||
model: config.model || "google/gemini-2.5-pro",
|
||||
apiKey: config.openRouterApiKey,
|
||||
systemPrompt: BRIEFING_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
});
|
||||
|
||||
// Ensure numbers are numbers
|
||||
data.recommendedWordCount =
|
||||
Number(data.recommendedWordCount) || context.wordCount + 300;
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[Scraper] NLP Analysis failed for ${context.url}:`,
|
||||
(err as Error).message,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
64
packages/seo-engine/src/agents/serper-agent.ts
Normal file
64
packages/seo-engine/src/agents/serper-agent.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface SerperResult {
|
||||
relatedSearches: string[];
|
||||
peopleAlsoAsk: string[];
|
||||
organicSnippets: string[];
|
||||
estimatedTotalResults: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Google search data via Serper's /search endpoint.
|
||||
* Extracts related searches, People Also Ask, organic snippets,
|
||||
* and totalResults as a search volume proxy.
|
||||
*/
|
||||
export async function fetchSerperData(
|
||||
query: string,
|
||||
apiKey: string,
|
||||
locale: { gl: string; hl: string } = { gl: "de", hl: "de" },
|
||||
): Promise<SerperResult> {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
"https://google.serper.dev/search",
|
||||
{
|
||||
q: query,
|
||||
gl: locale.gl,
|
||||
hl: locale.hl,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"X-API-KEY": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
|
||||
const relatedSearches =
|
||||
data.relatedSearches?.map((r: any) => r.query) || [];
|
||||
const peopleAlsoAsk = data.peopleAlsoAsk?.map((p: any) => p.question) || [];
|
||||
const organicSnippets = data.organic?.map((o: any) => o.snippet) || [];
|
||||
const estimatedTotalResults = data.searchInformation?.totalResults
|
||||
? parseInt(data.searchInformation.totalResults, 10)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
relatedSearches,
|
||||
peopleAlsoAsk,
|
||||
organicSnippets,
|
||||
estimatedTotalResults,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Serper API error for query "${query}":`,
|
||||
(error as Error).message,
|
||||
);
|
||||
return {
|
||||
relatedSearches: [],
|
||||
peopleAlsoAsk: [],
|
||||
organicSnippets: [],
|
||||
estimatedTotalResults: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
43
packages/seo-engine/src/agents/serper-autocomplete.ts
Normal file
43
packages/seo-engine/src/agents/serper-autocomplete.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface AutocompleteResult {
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Google Autocomplete suggestions via Serper's /autocomplete endpoint.
|
||||
* These represent real user typing behavior — extremely high-signal for long-tail keywords.
|
||||
*/
|
||||
export async function fetchAutocompleteSuggestions(
|
||||
query: string,
|
||||
apiKey: string,
|
||||
locale: { gl: string; hl: string } = { gl: "de", hl: "de" },
|
||||
): Promise<AutocompleteResult> {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
"https://google.serper.dev/autocomplete",
|
||||
{
|
||||
q: query,
|
||||
gl: locale.gl,
|
||||
hl: locale.hl,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"X-API-KEY": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const suggestions =
|
||||
response.data.suggestions?.map((s: any) => s.value || s) || [];
|
||||
|
||||
return { suggestions };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Serper Autocomplete error for query "${query}":`,
|
||||
(error as Error).message,
|
||||
);
|
||||
return { suggestions: [] };
|
||||
}
|
||||
}
|
||||
75
packages/seo-engine/src/agents/serper-competitors.ts
Normal file
75
packages/seo-engine/src/agents/serper-competitors.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface CompetitorRanking {
|
||||
keyword: string;
|
||||
domain: string;
|
||||
position: number;
|
||||
title: string;
|
||||
snippet: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given keyword, check which competitor domains appear in the top organic results.
|
||||
* Filters results to only include domains in the `competitorDomains` list.
|
||||
*/
|
||||
export async function fetchCompetitorRankings(
|
||||
keyword: string,
|
||||
competitorDomains: string[],
|
||||
apiKey: string,
|
||||
locale: { gl: string; hl: string } = { gl: "de", hl: "de" },
|
||||
): Promise<CompetitorRanking[]> {
|
||||
if (competitorDomains.length === 0) return [];
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
"https://google.serper.dev/search",
|
||||
{
|
||||
q: keyword,
|
||||
gl: locale.gl,
|
||||
hl: locale.hl,
|
||||
num: 20,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"X-API-KEY": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const organic: any[] = response.data.organic || [];
|
||||
|
||||
// Normalize competitor domains for matching
|
||||
const normalizedCompetitors = competitorDomains.map((d) =>
|
||||
d
|
||||
.replace(/^(https?:\/\/)?(www\.)?/, "")
|
||||
.replace(/\/$/, "")
|
||||
.toLowerCase(),
|
||||
);
|
||||
|
||||
return organic
|
||||
.filter((result: any) => {
|
||||
const resultDomain = new URL(result.link).hostname
|
||||
.replace(/^www\./, "")
|
||||
.toLowerCase();
|
||||
return normalizedCompetitors.some(
|
||||
(cd) => resultDomain === cd || resultDomain.endsWith(`.${cd}`),
|
||||
);
|
||||
})
|
||||
.map((result: any) => ({
|
||||
keyword,
|
||||
domain: new URL(result.link).hostname.replace(/^www\./, ""),
|
||||
position: result.position,
|
||||
title: result.title || "",
|
||||
snippet: result.snippet || "",
|
||||
link: result.link,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Serper Competitor check error for keyword "${keyword}":`,
|
||||
(error as Error).message,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
148
packages/seo-engine/src/editor.ts
Normal file
148
packages/seo-engine/src/editor.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { ContentGap } from "./types.js";
|
||||
import type { ReverseEngineeredBriefing } from "./agents/scraper.js";
|
||||
|
||||
export interface FileEditorConfig {
|
||||
outputDir: string;
|
||||
authorName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an SEO-friendly URL slug from a title.
|
||||
*/
|
||||
function createSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically creates local .mdx draft files for identified high-priority content gaps.
|
||||
* Each file is self-explanatory: it tells the writer exactly WHY this page needs to exist,
|
||||
* WHAT to write, and HOW to structure the content — all based on real competitor data.
|
||||
*/
|
||||
export async function createGapDrafts(
|
||||
gaps: ContentGap[],
|
||||
briefings: Map<string, ReverseEngineeredBriefing>,
|
||||
config: FileEditorConfig,
|
||||
): Promise<string[]> {
|
||||
const createdFiles: string[] = [];
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.resolve(process.cwd(), config.outputDir), {
|
||||
recursive: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[File Editor] Could not create directory ${config.outputDir}:`,
|
||||
e,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const dateStr = new Date().toISOString().split("T")[0];
|
||||
|
||||
for (const gap of gaps) {
|
||||
if (gap.priority === "low") continue;
|
||||
|
||||
const slug = createSlug(gap.recommendedTitle);
|
||||
const filePath = path.join(
|
||||
path.resolve(process.cwd(), config.outputDir),
|
||||
`${slug}.mdx`,
|
||||
);
|
||||
const briefing = briefings.get(gap.targetKeyword);
|
||||
|
||||
const priorityEmoji = gap.priority === "high" ? "🔴" : "🟡";
|
||||
|
||||
let body = "";
|
||||
|
||||
// ── Intro: Explain WHY this file exists ──
|
||||
body += `{/* ═══════════════════════════════════════════════════════════════════\n`;
|
||||
body += ` 📋 SEO CONTENT BRIEFING — Auto-generated by @mintel/seo-engine\n`;
|
||||
body += ` ═══════════════════════════════════════════════════════════════════\n\n`;
|
||||
body += ` Dieses Dokument wurde automatisch erstellt.\n`;
|
||||
body += ` Es basiert auf einer Analyse der aktuellen Google-Suchergebnisse\n`;
|
||||
body += ` und der Webseiten deiner Konkurrenz.\n\n`;
|
||||
body += ` ▸ Du kannst dieses File direkt als MDX-Seite verwenden.\n`;
|
||||
body += ` ▸ Ersetze den Briefing-Block unten durch deinen eigenen Text.\n`;
|
||||
body += ` ▸ Setze isDraft auf false, wenn der Text fertig ist.\n`;
|
||||
body += ` ═══════════════════════════════════════════════════════════════════ */}\n\n`;
|
||||
|
||||
// ── Section 1: Warum diese Seite? ──
|
||||
body += `## ${priorityEmoji} Warum diese Seite erstellt werden sollte\n\n`;
|
||||
body += `**Priorität:** ${gap.priority === "high" ? "Hoch — Direkt umsatzrelevant" : "Mittel — Stärkt die thematische Autorität"}\n\n`;
|
||||
body += `${gap.rationale}\n\n`;
|
||||
body += `| Feld | Wert |\n`;
|
||||
body += `|------|------|\n`;
|
||||
body += `| **Focus Keyword** | \`${gap.targetKeyword}\` |\n`;
|
||||
body += `| **Topic Cluster** | ${gap.relatedCluster} |\n`;
|
||||
body += `| **Priorität** | ${gap.priority} |\n\n`;
|
||||
|
||||
// ── Section 2: Competitor Briefing ──
|
||||
if (briefing) {
|
||||
body += `## 🔍 Konkurrenz-Analyse (Reverse Engineered)\n\n`;
|
||||
body += `> Die folgenden Daten stammen aus einer automatischen Analyse der Webseite,\n`;
|
||||
body += `> die aktuell auf **Platz 1 bei Google** für das Keyword \`${gap.targetKeyword}\` rankt.\n`;
|
||||
body += `> Nutze diese Informationen, um **besseren Content** zu schreiben.\n\n`;
|
||||
|
||||
body += `### Content-Format des Konkurrenten\n\n`;
|
||||
body += `**${briefing.contentFormat}** — Empfohlene Mindestlänge: **~${briefing.recommendedWordCount} Wörter**\n\n`;
|
||||
|
||||
body += `### Diese Themen MUSS dein Artikel abdecken\n\n`;
|
||||
body += `Die folgenden Punkte werden vom aktuellen Platz-1-Ranker behandelt. Wenn dein Artikel diese Themen nicht abdeckt, wird es schwer, ihn zu überholen:\n\n`;
|
||||
briefing.coreTopicsToCover.forEach(
|
||||
(t, i) => (body += `${i + 1}. ${t}\n`),
|
||||
);
|
||||
|
||||
body += `\n### Fachbegriffe & Entitäten die im Text vorkommen müssen\n\n`;
|
||||
body += `Diese Begriffe signalisieren Google, dass dein Text fachlich tiefgreifend ist. Versuche, möglichst viele davon natürlich in deinen Text einzubauen:\n\n`;
|
||||
briefing.entitiesToInclude.forEach((e) => (body += `- \`${e}\`\n`));
|
||||
|
||||
body += `\n### Empfohlene Gliederung\n\n`;
|
||||
body += `Orientiere dich an dieser Struktur (du kannst sie anpassen):\n\n`;
|
||||
briefing.suggestedHeadings.forEach(
|
||||
(h, i) => (body += `${i + 1}. **${h}**\n`),
|
||||
);
|
||||
} else {
|
||||
body += `## 🔍 Konkurrenz-Analyse\n\n`;
|
||||
body += `> Für dieses Keyword konnte kein Konkurrent gescraped werden.\n`;
|
||||
body += `> Schreibe den Artikel trotzdem — du hast weniger Wettbewerb!\n`;
|
||||
}
|
||||
|
||||
body += `\n---\n\n`;
|
||||
body += `## ✍️ Dein Content (hier schreiben)\n\n`;
|
||||
body += `{/* Lösche alles oberhalb dieser Zeile, wenn dein Text fertig ist. */}\n\n`;
|
||||
body += `Hier beginnt dein eigentlicher Artikel...\n`;
|
||||
|
||||
const file = `---
|
||||
title: "${gap.recommendedTitle}"
|
||||
description: "TODO: Meta-Description mit dem Keyword '${gap.targetKeyword}' schreiben."
|
||||
date: "${dateStr}"
|
||||
author: "${config.authorName || "Mintel SEO Engine"}"
|
||||
tags: ["${gap.relatedCluster}"]
|
||||
isDraft: true
|
||||
focus_keyword: "${gap.targetKeyword}"
|
||||
---
|
||||
|
||||
${body}`;
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, file, "utf8");
|
||||
console.log(`[File Editor] Created draft: ${filePath}`);
|
||||
createdFiles.push(filePath);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[File Editor] Failed to write ${filePath}:`,
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return createdFiles;
|
||||
}
|
||||
237
packages/seo-engine/src/engine.ts
Normal file
237
packages/seo-engine/src/engine.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { llmJsonRequest } from "./llm-client.js";
|
||||
import { fetchSerperData } from "./agents/serper-agent.js";
|
||||
import { fetchAutocompleteSuggestions } from "./agents/serper-autocomplete.js";
|
||||
import {
|
||||
fetchCompetitorRankings,
|
||||
type CompetitorRanking,
|
||||
} from "./agents/serper-competitors.js";
|
||||
import {
|
||||
scrapeCompetitorUrl,
|
||||
analyzeCompetitorContent,
|
||||
type ReverseEngineeredBriefing,
|
||||
} from "./agents/scraper.js";
|
||||
import { analyzeContentGaps, type ContentGap } from "./steps/content-gap.js";
|
||||
import { SEO_SYSTEM_PROMPT } from "./prompts.js";
|
||||
import type {
|
||||
ProjectContext,
|
||||
SeoConfig,
|
||||
SeoEngineOutput,
|
||||
TopicCluster,
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-2.5-pro";
|
||||
|
||||
export async function runSeoEngine(
|
||||
context: ProjectContext,
|
||||
config: SeoConfig,
|
||||
): Promise<SeoEngineOutput> {
|
||||
if (!config.serperApiKey)
|
||||
throw new Error("Missing Serper API Key in SeoConfig.");
|
||||
if (!config.openRouterApiKey)
|
||||
throw new Error("Missing OpenRouter API Key in SeoConfig.");
|
||||
|
||||
const locale = context.locale || { gl: "de", hl: "de" };
|
||||
const seedQueries: string[] = [];
|
||||
|
||||
// Derive seed queries from context
|
||||
if (context.companyName) seedQueries.push(context.companyName);
|
||||
if (context.industry) seedQueries.push(context.industry);
|
||||
if (context.competitors && context.competitors.length > 0) {
|
||||
seedQueries.push(...context.competitors.slice(0, 2));
|
||||
}
|
||||
if (context.seedKeywords && context.seedKeywords.length > 0) {
|
||||
seedQueries.push(...context.seedKeywords);
|
||||
}
|
||||
|
||||
if (seedQueries.length === 0) {
|
||||
throw new Error(
|
||||
"ProjectContext must provide at least an industry, company name, or seedKeywords.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SEO Engine] Sourcing raw data for ${seedQueries.length} seeds: ${seedQueries.join(", ")}`,
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Step 1: Google Search Data + Autocomplete (parallel per seed)
|
||||
// ──────────────────────────────────────────────
|
||||
const rawSearchData = new Set<string>();
|
||||
const allAutocompleteSuggestions = new Set<string>();
|
||||
const volumeMap = new Map<string, number>(); // keyword → totalResults
|
||||
|
||||
const searchPromises = seedQueries.map(async (query) => {
|
||||
const [searchResult, autocompleteResult] = await Promise.all([
|
||||
fetchSerperData(query, config.serperApiKey!, locale),
|
||||
fetchAutocompleteSuggestions(query, config.serperApiKey!, locale),
|
||||
]);
|
||||
|
||||
searchResult.relatedSearches.forEach((r) => rawSearchData.add(r));
|
||||
searchResult.peopleAlsoAsk.forEach((p) => rawSearchData.add(p));
|
||||
searchResult.organicSnippets.forEach((o) => rawSearchData.add(o));
|
||||
autocompleteResult.suggestions.forEach((s) => {
|
||||
rawSearchData.add(s);
|
||||
allAutocompleteSuggestions.add(s);
|
||||
});
|
||||
|
||||
if (searchResult.estimatedTotalResults > 0) {
|
||||
volumeMap.set(query, searchResult.estimatedTotalResults);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(searchPromises);
|
||||
const rawTerms = Array.from(rawSearchData);
|
||||
|
||||
console.log(
|
||||
`[SEO Engine] Sourced ${rawTerms.length} raw terms (incl. ${allAutocompleteSuggestions.size} autocomplete). Evaluating with LLM...`,
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Step 2: LLM Evaluation + Topic Clustering
|
||||
// ──────────────────────────────────────────────
|
||||
const userPrompt = `
|
||||
PROJECT CONTEXT:
|
||||
CompanyName: ${context.companyName || "N/A"}
|
||||
Industry / Main Focus: ${context.industry || "N/A"}
|
||||
Briefing Summary: ${context.briefing || "N/A"}
|
||||
Target Audience: ${context.targetAudience || "N/A"}
|
||||
Known Competitors: ${context.competitors?.join(", ") || "N/A"}
|
||||
|
||||
EXTRA STRICT GUIDELINES:
|
||||
${context.customGuidelines || "None. Apply standard Mintel strict adherence."}
|
||||
|
||||
RAW SEARCH TERMS SOURCED FROM GOOGLE (incl. autocomplete, PAA, related, snippets):
|
||||
${rawTerms.map((t, i) => `${i + 1}. ${t}`).join("\n")}
|
||||
|
||||
EVALUATE AND CLUSTER STRICTLY ACCORDING TO SYSTEM INSTRUCTIONS.
|
||||
`;
|
||||
|
||||
const { data: clusterData } = await llmJsonRequest<{
|
||||
topicClusters: TopicCluster[];
|
||||
discardedTerms: string[];
|
||||
}>({
|
||||
model: config.model || DEFAULT_MODEL,
|
||||
apiKey: config.openRouterApiKey,
|
||||
systemPrompt: SEO_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
});
|
||||
|
||||
const topicClusters = clusterData.topicClusters || [];
|
||||
const discardedTerms = clusterData.discardedTerms || [];
|
||||
|
||||
// Attach volume estimates based on totalResults proxy
|
||||
for (const cluster of topicClusters) {
|
||||
for (const kw of cluster.secondaryKeywords) {
|
||||
const vol = volumeMap.get(kw.term);
|
||||
if (vol !== undefined) {
|
||||
kw.estimatedVolume =
|
||||
vol > 1_000_000 ? "high" : vol > 100_000 ? "medium" : "low";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SEO Engine] LLM clustered ${topicClusters.reduce((a, c) => a + c.secondaryKeywords.length + 1, 0)} keywords into ${topicClusters.length} clusters. Discarded ${discardedTerms.length}.`,
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Step 3 & 4: Competitor SERP Analysis & Content Scraping
|
||||
// ──────────────────────────────────────────────
|
||||
let competitorRankings: CompetitorRanking[] = [];
|
||||
const competitorBriefings: Record<string, ReverseEngineeredBriefing> = {};
|
||||
|
||||
if (context.competitors && context.competitors.length > 0) {
|
||||
const primaryKeywords = topicClusters
|
||||
.map((c) => c.primaryKeyword)
|
||||
.slice(0, 5);
|
||||
console.log(
|
||||
`[SEO Engine] Checking competitor rankings for: ${primaryKeywords.join(", ")}`,
|
||||
);
|
||||
|
||||
const competitorPromises = primaryKeywords.map((kw) =>
|
||||
fetchCompetitorRankings(
|
||||
kw,
|
||||
context.competitors!,
|
||||
config.serperApiKey!,
|
||||
locale,
|
||||
),
|
||||
);
|
||||
const results = await Promise.all(competitorPromises);
|
||||
competitorRankings = results.flat();
|
||||
|
||||
console.log(
|
||||
`[SEO Engine] Found ${competitorRankings.length} competitor rankings.`,
|
||||
);
|
||||
|
||||
// Pick top ranking competitor for each primary keyword to reverse engineer
|
||||
console.log(`[SEO Engine] Reverse engineering top competitor content...`);
|
||||
const scrapePromises = primaryKeywords.map(async (kw) => {
|
||||
const topRanking = competitorRankings.find((r) => r.keyword === kw);
|
||||
if (!topRanking) return null;
|
||||
|
||||
const scraped = await scrapeCompetitorUrl(topRanking.link);
|
||||
if (!scraped) return null;
|
||||
|
||||
const briefing = await analyzeCompetitorContent(scraped, kw, {
|
||||
openRouterApiKey: config.openRouterApiKey!,
|
||||
model: config.model,
|
||||
});
|
||||
|
||||
if (briefing) {
|
||||
competitorBriefings[kw] = briefing;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(scrapePromises);
|
||||
console.log(
|
||||
`[SEO Engine] Generated ${Object.keys(competitorBriefings).length} competitor briefings.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Step 5: Content Gap Analysis
|
||||
// ──────────────────────────────────────────────
|
||||
let contentGaps: ContentGap[] = [];
|
||||
|
||||
if (context.existingPages && context.existingPages.length > 0) {
|
||||
console.log(
|
||||
`[SEO Engine] Analyzing content gaps against ${context.existingPages.length} existing pages...`,
|
||||
);
|
||||
contentGaps = await analyzeContentGaps(
|
||||
topicClusters,
|
||||
context.existingPages,
|
||||
{
|
||||
openRouterApiKey: config.openRouterApiKey,
|
||||
model: config.model,
|
||||
},
|
||||
);
|
||||
console.log(`[SEO Engine] Found ${contentGaps.length} content gaps.`);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Optional Keyword Cap
|
||||
// ──────────────────────────────────────────────
|
||||
if (config.maxKeywords) {
|
||||
let count = 0;
|
||||
for (const cluster of topicClusters) {
|
||||
cluster.secondaryKeywords = cluster.secondaryKeywords.filter(() => {
|
||||
if (count < config.maxKeywords!) {
|
||||
count++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SEO Engine] ✅ Complete.`);
|
||||
|
||||
return {
|
||||
topicClusters,
|
||||
competitorRankings,
|
||||
competitorBriefings,
|
||||
contentGaps,
|
||||
autocompleteSuggestions: Array.from(allAutocompleteSuggestions),
|
||||
discardedTerms,
|
||||
};
|
||||
}
|
||||
12
packages/seo-engine/src/index.ts
Normal file
12
packages/seo-engine/src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from "./types.js";
|
||||
export * from "./engine.js";
|
||||
export * from "./editor.js";
|
||||
export { generateSeoReport } from "./report.js";
|
||||
export { fetchSerperData } from "./agents/serper-agent.js";
|
||||
export { fetchAutocompleteSuggestions } from "./agents/serper-autocomplete.js";
|
||||
export { fetchCompetitorRankings } from "./agents/serper-competitors.js";
|
||||
export {
|
||||
scrapeCompetitorUrl,
|
||||
analyzeCompetitorContent,
|
||||
} from "./agents/scraper.js";
|
||||
export { analyzeContentGaps } from "./steps/content-gap.js";
|
||||
153
packages/seo-engine/src/llm-client.ts
Normal file
153
packages/seo-engine/src/llm-client.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// ============================================================================
|
||||
// LLM Client — Unified interface with model routing via OpenRouter
|
||||
// ============================================================================
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
export interface LLMRequestOptions {
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
jsonMode?: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface LLMResponse {
|
||||
content: string;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
cost: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean raw LLM output to parseable JSON.
|
||||
* Handles markdown fences, control chars, trailing commas.
|
||||
*/
|
||||
export function cleanJson(str: string): string {
|
||||
let cleaned = str.replace(/```json\n?|```/g, "").trim();
|
||||
// eslint-disable-next-line no-control-regex
|
||||
cleaned = cleaned.replace(/[\x00-\x1f\x7f-\x9f]/gi, " ");
|
||||
|
||||
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to an LLM via OpenRouter.
|
||||
*/
|
||||
export async function llmRequest(
|
||||
options: LLMRequestOptions,
|
||||
): Promise<LLMResponse> {
|
||||
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
|
||||
|
||||
const resp = await axios
|
||||
.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 120000,
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
if (err.response) {
|
||||
console.error(
|
||||
"OpenRouter API Error:",
|
||||
JSON.stringify(err.response.data, null, 2),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
const content = resp.data.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error(`LLM returned no content. Model: ${model}`);
|
||||
}
|
||||
|
||||
let cost = 0;
|
||||
const usage = resp.data.usage || {};
|
||||
if (usage.cost !== undefined) {
|
||||
cost = usage.cost;
|
||||
} else {
|
||||
// Fallback estimation
|
||||
cost =
|
||||
(usage.prompt_tokens || 0) * (0.1 / 1_000_000) +
|
||||
(usage.completion_tokens || 0) * (0.4 / 1_000_000);
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
usage: {
|
||||
promptTokens: usage.prompt_tokens || 0,
|
||||
completionTokens: usage.completion_tokens || 0,
|
||||
cost,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and parse the response as JSON.
|
||||
*/
|
||||
export async function llmJsonRequest<T = any>(
|
||||
options: LLMRequestOptions,
|
||||
): Promise<{ data: T; usage: LLMResponse["usage"] }> {
|
||||
let response;
|
||||
try {
|
||||
response = await llmRequest({ ...options, jsonMode: true });
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"Retrying LLM request without forced JSON mode...",
|
||||
(err as Error).message,
|
||||
);
|
||||
response = await llmRequest({ ...options, jsonMode: false });
|
||||
}
|
||||
|
||||
const cleaned = cleanJson(response.content);
|
||||
|
||||
let parsed: T;
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to parse LLM JSON response: ${(e as Error).message}\nRaw: ${cleaned.substring(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unwrap common LLM artifacts: {"0": {...}}, {"state": {...}}, etc.
|
||||
const unwrapped = unwrapResponse(parsed);
|
||||
|
||||
return { data: unwrapped as T, usage: response.usage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively unwrap common LLM wrapping patterns.
|
||||
*/
|
||||
function unwrapResponse(obj: any): any {
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 1) {
|
||||
const key = keys[0];
|
||||
if (
|
||||
key === "0" ||
|
||||
key === "state" ||
|
||||
key === "facts" ||
|
||||
key === "result" ||
|
||||
key === "data"
|
||||
) {
|
||||
return unwrapResponse(obj[key]);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
35
packages/seo-engine/src/prompts.ts
Normal file
35
packages/seo-engine/src/prompts.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export const SEO_SYSTEM_PROMPT = `
|
||||
You are a high-end Digital Architect and Expert SEO Analyst for the Mintel ecosystem.
|
||||
Your exact job is to process RAW SEARCH DATA from Google (via Serper API) and evaluate it against our STRICT PROJECT CONTEXT.
|
||||
|
||||
### OBJECTIVE:
|
||||
Given a project briefing, industry, and raw search queries (related searches, user questions), you must evaluate each term.
|
||||
Filter out ANY hallucinations, generic irrelevant fluff, or terms that do not strictly match the client's high-end context.
|
||||
Then, group the surviving relevant terms into logical "Topic Clusters" with search intents.
|
||||
|
||||
### RULES:
|
||||
- NO Hallucinations. Do not invent keywords that were not provided in the raw data or strongly implied by the context.
|
||||
- ABOSLUTE STRICTNESS: If a raw search term is irrelevant to the provided industry/briefing, DISCARD IT. Add it to the "discardedTerms" list.
|
||||
- HIGH-END QUALITY: The Mintel standard requires precision. Exclude generic garbage like "was ist ein unternehmen" if the client does B2B HDD-Bohrverfahren.
|
||||
|
||||
### OUTPUT FORMAT:
|
||||
You MUST respond with valid JSON matching this schema:
|
||||
{
|
||||
"topicClusters": [
|
||||
{
|
||||
"clusterName": "string",
|
||||
"primaryKeyword": "string",
|
||||
"secondaryKeywords": [
|
||||
{
|
||||
"term": "string",
|
||||
"intent": "informational" | "navigational" | "commercial" | "transactional",
|
||||
"relevanceScore": number, // 1-10
|
||||
"rationale": "string" // Short explanation why this fits the context
|
||||
}
|
||||
],
|
||||
"userIntent": "string" // Broad intent for the cluster
|
||||
}
|
||||
],
|
||||
"discardedTerms": ["string"] // Words you threw out and why
|
||||
}
|
||||
`;
|
||||
237
packages/seo-engine/src/report.ts
Normal file
237
packages/seo-engine/src/report.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type {
|
||||
SeoEngineOutput,
|
||||
TopicCluster,
|
||||
ContentGap,
|
||||
CompetitorRanking,
|
||||
} from "./types.js";
|
||||
import type { ReverseEngineeredBriefing } from "./agents/scraper.js";
|
||||
|
||||
export interface ReportConfig {
|
||||
projectName: string;
|
||||
outputDir: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a comprehensive, human-readable SEO Strategy Report in Markdown.
|
||||
* This is the "big picture" document that summarizes everything the SEO Engine found
|
||||
* and gives the team a clear action plan.
|
||||
*/
|
||||
export async function generateSeoReport(
|
||||
output: SeoEngineOutput,
|
||||
config: ReportConfig,
|
||||
): Promise<string> {
|
||||
const dateStr = new Date().toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const allKeywords = output.topicClusters.flatMap((c) => [
|
||||
c.primaryKeyword,
|
||||
...c.secondaryKeywords.map((k) => k.term),
|
||||
]);
|
||||
|
||||
let md = "";
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Header
|
||||
// ══════════════════════════════════════════════
|
||||
md += `# 📊 SEO Strategie-Report: ${config.projectName}\n\n`;
|
||||
md += `> Erstellt am **${dateStr}** von der **@mintel/seo-engine**\n\n`;
|
||||
|
||||
md += `## Zusammenfassung auf einen Blick\n\n`;
|
||||
md += `| Metrik | Wert |\n`;
|
||||
md += `|--------|------|\n`;
|
||||
md += `| Keywords gefunden | **${allKeywords.length}** |\n`;
|
||||
md += `| Topic Clusters | **${output.topicClusters.length}** |\n`;
|
||||
md += `| Konkurrenz-Rankings analysiert | **${output.competitorRankings.length}** |\n`;
|
||||
md += `| Konkurrenz-Briefings erstellt | **${Object.keys(output.competitorBriefings).length}** |\n`;
|
||||
md += `| Content Gaps identifiziert | **${output.contentGaps.length}** |\n`;
|
||||
md += `| Autocomplete-Vorschläge | **${output.autocompleteSuggestions.length}** |\n`;
|
||||
md += `| Verworfene Begriffe | **${output.discardedTerms.length}** |\n\n`;
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Section 1: Keywords zum Tracken
|
||||
// ══════════════════════════════════════════════
|
||||
md += `---\n\n`;
|
||||
md += `## 🎯 Keywords zum Tracken\n\n`;
|
||||
md += `Diese Keywords sind relevant für das Projekt und sollten in einem Ranking-Tracker (z.B. Serpbear) beobachtet werden:\n\n`;
|
||||
md += `| # | Keyword | Intent | Relevanz | Cluster |\n`;
|
||||
md += `|---|---------|--------|----------|--------|\n`;
|
||||
|
||||
let kwIndex = 1;
|
||||
for (const cluster of output.topicClusters) {
|
||||
md += `| ${kwIndex++} | **${cluster.primaryKeyword}** | — | 🏆 Primary | ${cluster.clusterName} |\n`;
|
||||
for (const kw of cluster.secondaryKeywords) {
|
||||
const intentEmoji =
|
||||
kw.intent === "transactional"
|
||||
? "💰"
|
||||
: kw.intent === "commercial"
|
||||
? "🛒"
|
||||
: kw.intent === "navigational"
|
||||
? "🧭"
|
||||
: "📖";
|
||||
md += `| ${kwIndex++} | ${kw.term} | ${intentEmoji} ${kw.intent} | ${kw.relevanceScore}/10 | ${cluster.clusterName} |\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Section 2: Topic Clusters
|
||||
// ══════════════════════════════════════════════
|
||||
md += `\n---\n\n`;
|
||||
md += `## 🗂️ Topic Clusters\n\n`;
|
||||
md += `Die SEO Engine hat die Keywords automatisch in thematische Cluster gruppiert. Jeder Cluster sollte idealerweise durch eine **Pillar Page** und mehrere **Sub-Pages** abgedeckt werden.\n\n`;
|
||||
|
||||
for (const cluster of output.topicClusters) {
|
||||
md += `### ${cluster.clusterName}\n\n`;
|
||||
md += `- **Pillar Keyword:** \`${cluster.primaryKeyword}\`\n`;
|
||||
md += `- **User Intent:** ${cluster.userIntent}\n`;
|
||||
md += `- **Sub-Keywords:** ${cluster.secondaryKeywords.map((k) => `\`${k.term}\``).join(", ")}\n\n`;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Section 3: Konkurrenz-Landscape
|
||||
// ══════════════════════════════════════════════
|
||||
if (output.competitorRankings.length > 0) {
|
||||
md += `---\n\n`;
|
||||
md += `## 🏁 Konkurrenz-Landscape\n\n`;
|
||||
md += `Für die wichtigsten Keywords wurde geprüft, welche Konkurrenten aktuell bei Google ranken:\n\n`;
|
||||
md += `| Keyword | Konkurrent | Position | Titel |\n`;
|
||||
md += `|---------|-----------|----------|-------|\n`;
|
||||
|
||||
for (const r of output.competitorRankings) {
|
||||
md += `| ${r.keyword} | **${r.domain}** | #${r.position} | ${r.title.slice(0, 60)}${r.title.length > 60 ? "…" : ""} |\n`;
|
||||
}
|
||||
md += `\n`;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Section 4: Competitor Briefings
|
||||
// ══════════════════════════════════════════════
|
||||
if (Object.keys(output.competitorBriefings).length > 0) {
|
||||
md += `---\n\n`;
|
||||
md += `## 🔬 Konkurrenz-Briefings (Reverse Engineered)\n\n`;
|
||||
md += `Für die folgenden Keywords wurde der aktuelle **Platz-1-Ranker** automatisch gescraped und analysiert. Diese Briefings zeigen exakt, was ein Artikel abdecken muss, um die Konkurrenz zu schlagen:\n\n`;
|
||||
|
||||
for (const [keyword, briefing] of Object.entries(
|
||||
output.competitorBriefings,
|
||||
)) {
|
||||
const b = briefing as ReverseEngineeredBriefing;
|
||||
md += `### Keyword: \`${keyword}\`\n\n`;
|
||||
md += `- **Format:** ${b.contentFormat}\n`;
|
||||
md += `- **Ziel-Wortanzahl:** ~${b.recommendedWordCount}\n`;
|
||||
md += `- **Kernthemen:** ${b.coreTopicsToCover.join("; ")}\n`;
|
||||
md += `- **Wichtige Entitäten:** ${b.entitiesToInclude.map((e) => `\`${e}\``).join(", ")}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Section 5: Content Gaps — Action Plan
|
||||
// ══════════════════════════════════════════════
|
||||
if (output.contentGaps.length > 0) {
|
||||
md += `---\n\n`;
|
||||
md += `## 🚧 Content Gaps — Fehlende Seiten\n\n`;
|
||||
md += `Die folgenden Seiten existieren auf der Website noch **nicht**, werden aber von der Zielgruppe aktiv gesucht. Sie sind nach Priorität sortiert:\n\n`;
|
||||
|
||||
const highGaps = output.contentGaps.filter((g) => g.priority === "high");
|
||||
const medGaps = output.contentGaps.filter((g) => g.priority === "medium");
|
||||
const lowGaps = output.contentGaps.filter((g) => g.priority === "low");
|
||||
|
||||
if (highGaps.length > 0) {
|
||||
md += `### 🔴 Hohe Priorität (direkt umsatzrelevant)\n\n`;
|
||||
for (const g of highGaps) {
|
||||
md += `- **${g.recommendedTitle}**\n`;
|
||||
md += ` - Keyword: \`${g.targetKeyword}\` · Cluster: ${g.relatedCluster}\n`;
|
||||
md += ` - ${g.rationale}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (medGaps.length > 0) {
|
||||
md += `### 🟡 Mittlere Priorität (stärkt Autorität)\n\n`;
|
||||
for (const g of medGaps) {
|
||||
md += `- **${g.recommendedTitle}**\n`;
|
||||
md += ` - Keyword: \`${g.targetKeyword}\` · Cluster: ${g.relatedCluster}\n`;
|
||||
md += ` - ${g.rationale}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (lowGaps.length > 0) {
|
||||
md += `### 🟢 Niedrige Priorität (Top-of-Funnel)\n\n`;
|
||||
for (const g of lowGaps) {
|
||||
md += `- **${g.recommendedTitle}**\n`;
|
||||
md += ` - Keyword: \`${g.targetKeyword}\` · Cluster: ${g.relatedCluster}\n`;
|
||||
md += ` - ${g.rationale}\n\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Section 6: Autocomplete Insights
|
||||
// ══════════════════════════════════════════════
|
||||
if (output.autocompleteSuggestions.length > 0) {
|
||||
md += `---\n\n`;
|
||||
md += `## 💡 Google Autocomplete — Long-Tail Insights\n\n`;
|
||||
md += `Diese Begriffe stammen direkt aus der Google-Suchleiste und spiegeln echtes Nutzerverhalten wider. Sie eignen sich besonders für **FAQ-Sektionen**, **H2/H3-Überschriften** und **Long-Tail Content**:\n\n`;
|
||||
|
||||
for (const s of output.autocompleteSuggestions) {
|
||||
md += `- ${s}\n`;
|
||||
}
|
||||
md += `\n`;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Section 7: Verworfene Begriffe
|
||||
// ══════════════════════════════════════════════
|
||||
if (output.discardedTerms.length > 0) {
|
||||
md += `---\n\n`;
|
||||
md += `## 🗑️ Verworfene Begriffe\n\n`;
|
||||
md += `Die folgenden Begriffe wurden von der KI als **nicht relevant** eingestuft:\n\n`;
|
||||
md += `<details>\n<summary>Alle ${output.discardedTerms.length} verworfenen Begriffe anzeigen</summary>\n\n`;
|
||||
for (const t of output.discardedTerms) {
|
||||
md += `- ${t}\n`;
|
||||
}
|
||||
md += `\n</details>\n\n`;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Section 8: Copy-Paste Snippets
|
||||
// ══════════════════════════════════════════════
|
||||
md += `---\n\n`;
|
||||
md += `## 📋 Copy-Paste Snippets\n\n`;
|
||||
md += `Diese Listen sind optimiert für das schnelle Kopieren in SEO-Tools oder Tabellen.\n\n`;
|
||||
|
||||
md += `### Rank-Tracker (z.B. Serpbear) — Ein Keyword pro Zeile\n`;
|
||||
md += `\`\`\`text\n`;
|
||||
md += allKeywords.join("\n");
|
||||
md += `\n\`\`\`\n\n`;
|
||||
|
||||
md += `### Excel / Google Sheets — Kommagetrennt\n`;
|
||||
md += `\`\`\`text\n`;
|
||||
md += allKeywords.join(", ");
|
||||
md += `\n\`\`\`\n\n`;
|
||||
|
||||
md += `### Pillar-Keywords (Nur Primary Keywords)\n`;
|
||||
md += `\`\`\`text\n`;
|
||||
md += output.topicClusters.map((c) => c.primaryKeyword).join("\n");
|
||||
md += `\n\`\`\`\n\n`;
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Footer
|
||||
// ══════════════════════════════════════════════
|
||||
md += `---\n\n`;
|
||||
md += `*Dieser Report wurde automatisch von der @mintel/seo-engine generiert. Alle Daten basieren auf echten Google-Suchergebnissen (via Serper API) und wurden durch ein LLM ausgewertet.*\n`;
|
||||
|
||||
// Write to disk
|
||||
const outDir = path.resolve(process.cwd(), config.outputDir);
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
const filename =
|
||||
config.filename ||
|
||||
`seo-report-${config.projectName.toLowerCase().replace(/\s+/g, "-")}.md`;
|
||||
const filePath = path.join(outDir, filename);
|
||||
await fs.writeFile(filePath, md, "utf8");
|
||||
console.log(`[Report] Written SEO Strategy Report to: ${filePath}`);
|
||||
return filePath;
|
||||
}
|
||||
84
packages/seo-engine/src/steps/content-gap.ts
Normal file
84
packages/seo-engine/src/steps/content-gap.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { llmJsonRequest } from "../llm-client.js";
|
||||
import type { TopicCluster } from "../types.js";
|
||||
|
||||
export interface ExistingPage {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ContentGap {
|
||||
recommendedTitle: string;
|
||||
targetKeyword: string;
|
||||
relatedCluster: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
const CONTENT_GAP_SYSTEM_PROMPT = `
|
||||
You are a senior SEO Content Strategist. Your job is to compare a set of TOPIC CLUSTERS
|
||||
(keywords the company should rank for) against the EXISTING PAGES on their website.
|
||||
|
||||
### OBJECTIVE:
|
||||
Identify content gaps — topics/keywords that have NO corresponding page yet.
|
||||
For each gap, recommend a page title, the primary target keyword, which cluster it belongs to,
|
||||
and a priority (high/medium/low) based on commercial intent and relevance.
|
||||
|
||||
### RULES:
|
||||
- Only recommend gaps for topics that are genuinely MISSING from the existing pages.
|
||||
- Do NOT recommend pages that already exist (even if the title is slightly different — use semantic matching).
|
||||
- Priority "high" = commercial/transactional intent, directly drives revenue.
|
||||
- Priority "medium" = informational with strong industry relevance.
|
||||
- Priority "low" = broad, top-of-funnel topics.
|
||||
- LANGUAGE: Match the language of the project context (if German context, recommend German titles).
|
||||
|
||||
### OUTPUT FORMAT:
|
||||
{
|
||||
"contentGaps": [
|
||||
{
|
||||
"recommendedTitle": "string",
|
||||
"targetKeyword": "string",
|
||||
"relatedCluster": "string",
|
||||
"priority": "high" | "medium" | "low",
|
||||
"rationale": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
`;
|
||||
|
||||
export async function analyzeContentGaps(
|
||||
topicClusters: TopicCluster[],
|
||||
existingPages: ExistingPage[],
|
||||
config: { openRouterApiKey: string; model?: string },
|
||||
): Promise<ContentGap[]> {
|
||||
if (topicClusters.length === 0) return [];
|
||||
if (existingPages.length === 0) {
|
||||
console.log(
|
||||
"[Content Gap] No existing pages provided, skipping gap analysis.",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const userPrompt = `
|
||||
TOPIC CLUSTERS (what the company SHOULD rank for):
|
||||
${JSON.stringify(topicClusters, null, 2)}
|
||||
|
||||
EXISTING PAGES ON THE WEBSITE:
|
||||
${existingPages.map((p, i) => `${i + 1}. "${p.title}" — ${p.url}`).join("\n")}
|
||||
|
||||
Identify ALL content gaps. Be thorough but precise.
|
||||
`;
|
||||
|
||||
try {
|
||||
const { data } = await llmJsonRequest<{ contentGaps: ContentGap[] }>({
|
||||
model: config.model || "google/gemini-2.5-pro",
|
||||
apiKey: config.openRouterApiKey,
|
||||
systemPrompt: CONTENT_GAP_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
});
|
||||
|
||||
return data.contentGaps || [];
|
||||
} catch (err) {
|
||||
console.error("[Content Gap] Analysis failed:", (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user