Compare commits
86 Commits
gatekeeper
...
feat/analy
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a9dd06fc6 | |||
| 985e532c61 | |||
| 04462f21e1 | |||
| 11babf3347 | |||
| 3de92accc4 | |||
| c8961411ec | |||
| 42ea30c11f | |||
| 7b2cc3723a | |||
| 0fc4ddbc28 | |||
| 9f8eab2ad2 | |||
| 69c483ac46 | |||
| ac178ccbb0 | |||
| 3f16828bdf | |||
| 0f7cd94e14 | |||
| 2cc6512afa | |||
| 151c98c884 | |||
| ba17d7940e | |||
| 46120dbd56 | |||
| 571c4797fa | |||
| c7d107a578 | |||
| 5f50e98a0a | |||
| 45f831ca1c | |||
| 73356cca30 | |||
| b7e630c1d8 | |||
| 13c8bc29ae | |||
| 51a892fd76 | |||
| 9cc8e573ec | |||
| a80464abc0 | |||
| 0b929d00a1 | |||
| 8ec2ecb454 | |||
| 2a9c5780c3 | |||
| fbfc9feab0 | |||
| 8486261555 | |||
| 5e1f2669e6 | |||
| 541f1c17b7 | |||
| 048fafa3db | |||
| 77e2c4f9b6 | |||
| 4eb1aaf640 | |||
| 61f2f83e0c | |||
| 2dc61e4937 | |||
| a6ca876823 | |||
| f615565323 | |||
| 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 |
5
.changeset/next-config-server-actions.md
Normal file
5
.changeset/next-config-server-actions.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@mintel/next-config": patch
|
||||
---
|
||||
|
||||
fix: add serverActions.allowedOrigins to support branch deployments
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@mintel/monorepo": patch
|
||||
"acquisition-manager": patch
|
||||
"feedback-commander": patch
|
||||
---
|
||||
|
||||
fix: make directus extension build scripts more resilient
|
||||
23
.env
23
.env
@@ -3,6 +3,7 @@ IMAGE_TAG=v1.8.19
|
||||
PROJECT_NAME=at-mintel
|
||||
PROJECT_COLOR=#82ed20
|
||||
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
||||
GITEA_HOST=https://git.infra.mintel.me
|
||||
OPENROUTER_API_KEY=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
|
||||
ZYTE_API_KEY=1f0f74726f044f55aaafc7ead32cd489
|
||||
REPLICATE_API_KEY=r8_W3grtpXMRfi0u3AM9VdkKbuWdZMmhwU2Tn0yt
|
||||
@@ -11,6 +12,11 @@ DATA_FOR_SEO_API_KEY=bWFyY0BtaW50ZWwubWU6MjQ0YjBjZmIzOGY3NTIzZA==
|
||||
DATA_FOR_SEO_LOGIN=marc@mintel.me
|
||||
DATA_FOR_SEO_PASSWORD=244b0cfb38f7523d
|
||||
|
||||
# Kabelfachmann LLM Configuration
|
||||
KABELFACHMANN_LLM_PROVIDER=openrouter
|
||||
KABELFACHMANN_OLLAMA_MODEL=qwen3.5
|
||||
KABELFACHMANN_OLLAMA_HOST=http://host.docker.internal:11434
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=mintel
|
||||
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||
@@ -41,3 +47,20 @@ SENTRY_DSN=
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_USERNAME=mmintel
|
||||
UMAMI_PASSWORD=Tim300493.
|
||||
|
||||
# MCP Services
|
||||
QDRANT_URL=http://qdrant:6333
|
||||
SERPBEAR_API_KEY=Tim300493.
|
||||
GLITCHTIP_API_KEY=3d37db66fa1fc3a1cdcd1357728b056225aca950f0163b50c5e922344807ede9
|
||||
|
||||
# Corrected MCP Base URLs
|
||||
UMAMI_BASE_URL=https://analytics.infra.mintel.me
|
||||
GLITCHTIP_BASE_URL=https://errors.infra.mintel.me
|
||||
SERPBEAR_BASE_URL=https://seo.infra.mintel.me
|
||||
|
||||
# Vikunja MCP
|
||||
VIKUNJA_API_TOKEN=tk_468a6f7a59b329aa42796378e55075b79eb4d3d4
|
||||
VIKUNJA_BASE_URL=https://tasks.infra.mintel.me
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.9.5
|
||||
IMAGE_TAG=v1.9.17
|
||||
PROJECT_NAME=sample-website
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
|
||||
44
.gitea/workflows/analytics-mailer.yml
Normal file
44
.gitea/workflows/analytics-mailer.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Monthly Analytics Mailer
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 1 * *' # Run at 08:00 on the 1st of every month
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
send-reports:
|
||||
name: 📊 Send Monthly Reports
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
UMAMI_BASE_URL: ${{ secrets.UMAMI_BASE_URL }}
|
||||
UMAMI_USERNAME: ${{ secrets.UMAMI_USERNAME }}
|
||||
UMAMI_PASSWORD: ${{ secrets.UMAMI_PASSWORD }}
|
||||
MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }}
|
||||
MAILGUN_DOMAIN: ${{ secrets.MAILGUN_DOMAIN }}
|
||||
MAILGUN_SENDER: ${{ secrets.MAILGUN_SENDER }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node_version: 20
|
||||
|
||||
- name: Enable pnpm
|
||||
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
|
||||
|
||||
- name: Build mail templates
|
||||
run: pnpm --filter @mintel/mail build
|
||||
|
||||
- name: Build analytics mailer
|
||||
run: pnpm --filter @mintel/analytics-mailer build
|
||||
|
||||
- name: Run Analytics Mailer
|
||||
run: pnpm --filter @mintel/analytics-mailer run start
|
||||
@@ -199,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
|
||||
@@ -216,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,55 @@ 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
|
||||
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: 🔐 Registry Auth
|
||||
run: |
|
||||
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
||||
echo "//$REGISTRY/:_authToken=${{ secrets.NPM_TOKEN || secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}" >> .npmrc
|
||||
- name: 🔍 Diagnostics
|
||||
run: |
|
||||
echo "👤 User: $(id)"
|
||||
echo "📂 Directory: $(pwd)"
|
||||
echo "📂 Disk Space:"
|
||||
df -h
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
rm -rf .next .turbo node_modules || true
|
||||
pnpm install --no-frozen-lockfile --reporter=append-only
|
||||
- name: 📦 Archive dependencies
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: node_modules
|
||||
path: node_modules
|
||||
retention-days: 1
|
||||
|
||||
static:
|
||||
name: 🔍 Static Analysis
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -36,98 +81,126 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- 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
|
||||
- name: Install dependencies
|
||||
id: deps
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: 📦 Cache APT Packages
|
||||
uses: actions/cache@v4
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
|
||||
|
||||
- name: 💾 Cache Chromium
|
||||
id: cache-chromium
|
||||
uses: actions/cache@v4
|
||||
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'
|
||||
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@v3
|
||||
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@v3
|
||||
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@v3
|
||||
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 +208,7 @@ jobs:
|
||||
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [qa_suite]
|
||||
needs: [prepare, static, accessibility, analysis, performance]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -144,22 +217,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" \
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -46,4 +46,12 @@ 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/
|
||||
|
||||
# Kabelfachmann MCP
|
||||
packages/kabelfachmann-mcp/data/
|
||||
packages/kabelfachmann-mcp/models/
|
||||
@@ -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"
|
||||
|
||||
14
apps/analytics-mailer/config.json
Normal file
14
apps/analytics-mailer/config.json
Normal file
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"websiteId": "d773ea10-a3b3-4ccf-9024-987e14c4d669",
|
||||
"domain": "e-tib.com",
|
||||
"clientEmail": "marc@mintel.me",
|
||||
"clientName": "E-TIB GmbH"
|
||||
},
|
||||
{
|
||||
"websiteId": "59a7db94-0100-4c7e-98ef-99f45b17f9c3",
|
||||
"domain": "klz-cables.com",
|
||||
"clientEmail": "marc@mintel.me",
|
||||
"clientName": "KLZ"
|
||||
}
|
||||
]
|
||||
26
apps/analytics-mailer/package.json
Normal file
26
apps/analytics-mailer/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@mintel/analytics-mailer",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --clean",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/mail": "workspace:*",
|
||||
"axios": "^1.7.9",
|
||||
"dotenv": "^16.4.7",
|
||||
"form-data": "^4.0.1",
|
||||
"mailgun.js": "^10.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"tsup": "^8.3.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
143
apps/analytics-mailer/src/index.ts
Normal file
143
apps/analytics-mailer/src/index.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import { render } from "@mintel/mail";
|
||||
import { MonthlyAnalyticsTemplate } from "@mintel/mail";
|
||||
import Mailgun from "mailgun.js";
|
||||
import formData from "form-data";
|
||||
import * as dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const UMAMI_BASE_URL = process.env.UMAMI_BASE_URL || "https://umami.infra.mintel.me";
|
||||
const UMAMI_USERNAME = process.env.UMAMI_USERNAME;
|
||||
const UMAMI_PASSWORD = process.env.UMAMI_PASSWORD;
|
||||
const UMAMI_API_KEY = process.env.UMAMI_API_KEY;
|
||||
|
||||
const MAILGUN_API_KEY = process.env.MAILGUN_API_KEY;
|
||||
const MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN;
|
||||
const MAILGUN_SENDER = process.env.MAILGUN_SENDER || "reports@mintel.me";
|
||||
const MAILGUN_URL = process.env.MAILGUN_URL || "https://api.eu.mailgun.net";
|
||||
|
||||
interface ConfigClient {
|
||||
websiteId: string;
|
||||
domain: string;
|
||||
clientEmail: string;
|
||||
clientName: string;
|
||||
}
|
||||
|
||||
async function getUmamiToken(): Promise<string> {
|
||||
if (UMAMI_API_KEY) {
|
||||
return UMAMI_API_KEY;
|
||||
}
|
||||
if (!UMAMI_USERNAME || !UMAMI_PASSWORD) {
|
||||
throw new Error("Missing UMAMI_USERNAME and UMAMI_PASSWORD or UMAMI_API_KEY");
|
||||
}
|
||||
const res = await axios.post(`${UMAMI_BASE_URL}/api/auth/login`, {
|
||||
username: UMAMI_USERNAME,
|
||||
password: UMAMI_PASSWORD,
|
||||
});
|
||||
return res.data.token;
|
||||
}
|
||||
|
||||
async function fetchUmamiData(api: any, websiteId: string, startAt: number, endAt: number) {
|
||||
const [statsRes, pagesRes, refRes] = await Promise.all([
|
||||
api.get(`/websites/${websiteId}/stats`, { params: { startAt, endAt } }),
|
||||
api.get(`/websites/${websiteId}/metrics`, { params: { type: "url", limit: 5, startAt, endAt } }),
|
||||
api.get(`/websites/${websiteId}/metrics`, { params: { type: "referrer", limit: 3, startAt, endAt } }),
|
||||
]);
|
||||
|
||||
const stats = statsRes.data;
|
||||
const topPages = (pagesRes.data || []).map((p: any) => ({ url: p.x, views: p.y }));
|
||||
const topReferrers = (refRes.data || []).map((r: any) => ({ url: r.x, visitors: r.y }));
|
||||
|
||||
return {
|
||||
totalVisitors: stats.visitors?.value || 0,
|
||||
totalPageviews: stats.pageviews?.value || 0,
|
||||
topPages,
|
||||
topReferrers,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const isDryRun = process.argv.includes("--dry-run");
|
||||
|
||||
// Load config
|
||||
const configPath = path.resolve(process.cwd(), "config.json");
|
||||
const configRaw = await fs.readFile(configPath, "utf-8");
|
||||
const clients: ConfigClient[] = JSON.parse(configRaw);
|
||||
|
||||
// Setup Umami API
|
||||
const token = await getUmamiToken();
|
||||
const headers = UMAMI_API_KEY ? { "x-umami-api-key": token } : { Authorization: `Bearer ${token}` };
|
||||
const api = axios.create({ baseURL: `${UMAMI_BASE_URL}/api`, headers });
|
||||
|
||||
// Setup Mailgun
|
||||
const mailgun = new Mailgun(formData);
|
||||
const mg = mailgun.client({ username: "api", key: MAILGUN_API_KEY!, url: MAILGUN_URL });
|
||||
|
||||
// Time range calculation (previous month)
|
||||
const now = new Date();
|
||||
// Ensure we get the correct previous month relative to current local time, but use UTC boundary or local? Umami uses ms timestamps.
|
||||
// Standard way: First day of previous month, last day of previous month.
|
||||
const startOfPrevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const endOfPrevMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
||||
|
||||
const startAt = startOfPrevMonth.getTime();
|
||||
const endAt = endOfPrevMonth.getTime();
|
||||
|
||||
const monthName = startOfPrevMonth.toLocaleString("de-DE", { month: "long" });
|
||||
const year = startOfPrevMonth.getFullYear().toString();
|
||||
|
||||
console.log(`Generating reports for ${monthName} ${year}...`);
|
||||
|
||||
for (const client of clients) {
|
||||
if (client.websiteId === "replace-with-umami-website-id") continue;
|
||||
|
||||
console.log(`Processing ${client.domain} (${client.clientName})...`);
|
||||
|
||||
try {
|
||||
const data = await fetchUmamiData(api, client.websiteId, startAt, endAt);
|
||||
|
||||
const html = await render(MonthlyAnalyticsTemplate({
|
||||
clientName: client.clientName,
|
||||
domain: client.domain,
|
||||
month: monthName,
|
||||
year,
|
||||
totalVisitors: data.totalVisitors,
|
||||
totalPageviews: data.totalPageviews,
|
||||
topPages: data.topPages,
|
||||
topReferrers: data.topReferrers,
|
||||
}));
|
||||
|
||||
const subject = `Ihr Website-Report für ${monthName} ${year} - ${client.domain}`;
|
||||
|
||||
if (isDryRun) {
|
||||
console.log(`[DRY-RUN] Would send email to ${client.clientEmail} for ${client.domain}`);
|
||||
const debugPath = path.resolve(process.cwd(), `dry-run-${client.domain}.html`);
|
||||
await fs.writeFile(debugPath, html);
|
||||
console.log(`[DRY-RUN] Saved HTML preview to ${debugPath}`);
|
||||
} else {
|
||||
if (!MAILGUN_API_KEY) {
|
||||
throw new Error("MAILGUN_API_KEY is missing for live run.");
|
||||
}
|
||||
await mg.messages.create(MAILGUN_DOMAIN!, {
|
||||
from: MAILGUN_SENDER,
|
||||
to: [client.clientEmail],
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
console.log(`Successfully sent to ${client.clientEmail}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to process client ${client.domain}:`, e.response?.data || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Done.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
10
apps/analytics-mailer/tsconfig.json
Normal file
10
apps/analytics-mailer/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sample-website",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
177
blender-mv.sh
Executable file
177
blender-mv.sh
Executable file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# blender-mv: A smart file/directory mover that automatically updates Blender paths
|
||||
|
||||
set -e
|
||||
|
||||
print_usage() {
|
||||
echo "Usage: blender-mv [OPTIONS] SOURCE DEST"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --search-project <dir> Scan this specific project directory for .blend files"
|
||||
echo " to update (useful if you move an external asset folder,"
|
||||
echo " and old .blend files referencing it are elsewhere)."
|
||||
echo ""
|
||||
echo "Example: blender-mv ./old_folder ./new_folder"
|
||||
}
|
||||
|
||||
SEARCH_PROJECT=""
|
||||
|
||||
# Parse arguments
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case $1 in
|
||||
--search-project) SEARCH_PROJECT="$2"; shift 2 ;;
|
||||
-h|--help) print_usage; exit 0 ;;
|
||||
*)
|
||||
if [[ -z "$SOURCE" ]]; then
|
||||
SOURCE="$1"
|
||||
elif [[ -z "$DEST" ]]; then
|
||||
DEST="$1"
|
||||
else
|
||||
echo "Unknown parameter passed: $1"
|
||||
print_usage
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$SOURCE" || -z "$DEST" ]]; then
|
||||
echo "Error: Both SOURCE and DEST must be provided."
|
||||
print_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -e "$SOURCE" ]]; then
|
||||
echo "Error: Source path '$SOURCE' does not exist."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to get absolute normalized path (handles missing destination gracefully)
|
||||
get_abs_path() {
|
||||
python3 -c 'import os,sys; print(os.path.normpath(os.path.abspath(sys.argv[1])))' "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# Resolve source before moving
|
||||
ABS_SOURCE=$(get_abs_path "$SOURCE")
|
||||
|
||||
# Determine resulting target path
|
||||
if [[ -d "$DEST" ]]; then
|
||||
# If DEST is an existing dir, standard mv puts SOURCE inside it
|
||||
BASENAME_SOURCE=$(basename "$SOURCE")
|
||||
ABS_DEST=$(get_abs_path "$DEST/$BASENAME_SOURCE")
|
||||
else
|
||||
# Placed exactly at DEST (rename or target dir doesn't exist yet)
|
||||
ABS_DEST=$(get_abs_path "$DEST")
|
||||
fi
|
||||
|
||||
echo "Moving: $SOURCE -> $DEST"
|
||||
mv "$SOURCE" "$DEST"
|
||||
|
||||
echo "Replacing paths in Blender's recent-files lists..."
|
||||
# Find all recent-files.txt across Blender versions and replace the string
|
||||
find "$HOME/Library/Application Support/Blender" -type f -name "recent-files.txt" 2>/dev/null | while read -r RECENT_FILE; do
|
||||
# Using sed with alternative delimiter to avoid path interference
|
||||
sed -i '' "s|${ABS_SOURCE}|${ABS_DEST}|g" "$RECENT_FILE"
|
||||
done
|
||||
|
||||
# Prepare the embedded Python payload to update blend paths
|
||||
TMP_PY=$(mktemp /tmp/blender_update_paths.XXXXXX.py)
|
||||
cat << 'EOF' > "$TMP_PY"
|
||||
import bpy
|
||||
import sys
|
||||
|
||||
def replace_in_prop(prop, old_path, new_path):
|
||||
if prop and isinstance(prop, str) and old_path in prop:
|
||||
return prop.replace(old_path, new_path)
|
||||
return prop
|
||||
|
||||
def run():
|
||||
# Args are passed after `--`
|
||||
if '--' not in sys.argv:
|
||||
return
|
||||
idx = sys.argv.index('--')
|
||||
if len(sys.argv) <= idx + 2:
|
||||
return
|
||||
|
||||
old_path = sys.argv[idx + 1]
|
||||
new_path = sys.argv[idx + 2]
|
||||
|
||||
changed = False
|
||||
|
||||
# 1. Libraries (Linked objects/collections)
|
||||
for lib in bpy.data.libraries:
|
||||
new_fp = replace_in_prop(lib.filepath, old_path, new_path)
|
||||
if new_fp != lib.filepath:
|
||||
lib.filepath = new_fp
|
||||
changed = True
|
||||
|
||||
# 2. Images (Textures, Env Maps)
|
||||
for img in bpy.data.images:
|
||||
new_fp = replace_in_prop(img.filepath, old_path, new_path)
|
||||
if new_fp != img.filepath:
|
||||
img.filepath = new_fp
|
||||
changed = True
|
||||
|
||||
# 3. Cache files (Alembic/VDB)
|
||||
for cache in bpy.data.cache_files:
|
||||
new_fp = replace_in_prop(cache.filepath, old_path, new_path)
|
||||
if new_fp != cache.filepath:
|
||||
cache.filepath = new_fp
|
||||
changed = True
|
||||
|
||||
# 4. Fonts
|
||||
for font in bpy.data.fonts:
|
||||
new_fp = replace_in_prop(font.filepath, old_path, new_path)
|
||||
if new_fp != font.filepath:
|
||||
font.filepath = new_fp
|
||||
changed = True
|
||||
|
||||
# 5. Video strips
|
||||
for scene in bpy.data.scenes:
|
||||
if scene.sequence_editor:
|
||||
for strip in scene.sequence_editor.sequences_all:
|
||||
if strip.type in ('MOVIE', 'IMAGE'):
|
||||
new_fp = replace_in_prop(strip.filepath, old_path, new_path)
|
||||
if new_fp != strip.filepath:
|
||||
strip.filepath = new_fp
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
print(f"[*] Updated paths in {bpy.data.filepath}")
|
||||
bpy.ops.wm.save_mainfile()
|
||||
|
||||
try:
|
||||
run()
|
||||
except Exception as e:
|
||||
print(f"[!] Error updating blend file paths: {e}")
|
||||
EOF
|
||||
|
||||
# Determine what to search for blend files
|
||||
if [[ -n "$SEARCH_PROJECT" ]]; then
|
||||
SEARCH_ROOT=$(get_abs_path "$SEARCH_PROJECT")
|
||||
echo "Scanning project root '$SEARCH_ROOT' for .blend files..."
|
||||
elif [[ -d "$ABS_DEST" ]]; then
|
||||
SEARCH_ROOT="$ABS_DEST"
|
||||
echo "Scanning moved directory for .blend files..."
|
||||
elif [[ "$ABS_DEST" == *.blend ]]; then
|
||||
SEARCH_ROOT="$ABS_DEST"
|
||||
echo "Updating moved .blend file..."
|
||||
else
|
||||
echo "No project root defined and moved object is not a directory or .blend file."
|
||||
echo "Finished."
|
||||
rm -f "$TMP_PY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Execute headless blender on all found blend files
|
||||
if [[ -d "$SEARCH_ROOT" ]]; then
|
||||
find "$SEARCH_ROOT" -type f -name "*.blend" -print0 | while IFS= read -r -d '' BLEND_FILE; do
|
||||
blender -b "$BLEND_FILE" -P "$TMP_PY" -- "$ABS_SOURCE" "$ABS_DEST" >/dev/null 2>&1 || echo "Warning: Failed to process $BLEND_FILE"
|
||||
done
|
||||
elif [[ -f "$SEARCH_ROOT" && "$SEARCH_ROOT" == *.blend ]]; then
|
||||
blender -b "$SEARCH_ROOT" -P "$TMP_PY" -- "$ABS_SOURCE" "$ABS_DEST" >/dev/null 2>&1 || echo "Warning: Failed to process $SEARCH_ROOT"
|
||||
fi
|
||||
|
||||
rm -f "$TMP_PY"
|
||||
echo "Success: Move complete and paths updated."
|
||||
114
docker-compose.mcps.yml
Normal file
114
docker-compose.mcps.yml
Normal file
@@ -0,0 +1,114 @@
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: qdrant-mcp
|
||||
ports:
|
||||
- "6335:6333"
|
||||
- "6336:6334"
|
||||
volumes:
|
||||
- ./data/qdrant:/qdrant/storage
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
gitea-mcp:
|
||||
build:
|
||||
context: ./packages/gitea-mcp
|
||||
container_name: gitea-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3011:3001"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
memory-mcp:
|
||||
build:
|
||||
context: ./packages/memory-mcp
|
||||
container_name: memory-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3002:3002"
|
||||
depends_on:
|
||||
- qdrant
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
umami-mcp:
|
||||
build:
|
||||
context: ./packages/umami-mcp
|
||||
container_name: umami-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3003:3003"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
serpbear-mcp:
|
||||
build:
|
||||
context: ./packages/serpbear-mcp
|
||||
container_name: serpbear-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3004:3004"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
glitchtip-mcp:
|
||||
build:
|
||||
context: ./packages/glitchtip-mcp
|
||||
container_name: glitchtip-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3005:3005"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
klz-payload-mcp:
|
||||
build:
|
||||
context: ./packages/klz-payload-mcp
|
||||
container_name: klz-payload-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3006:3006"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
vikunja-mcp:
|
||||
build:
|
||||
context: ./packages/vikunja-mcp
|
||||
container_name: vikunja-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3007:3007"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
outline-mcp:
|
||||
build:
|
||||
context: ./packages/outline-mcp
|
||||
container_name: outline-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3008:3008"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
networks:
|
||||
mcp-network:
|
||||
driver: bridge
|
||||
69
eslint-errors-2.txt
Normal file
69
eslint-errors-2.txt
Normal file
@@ -0,0 +1,69 @@
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/gitea-mcp/src/index.ts[24m[0m
|
||||
[0m [2m11:0[22m [31merror[39m Parsing error: Identifier expected[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/glitchtip-mcp/src/index.ts[24m[0m
|
||||
[0m [2m124:19[22m [33mwarning[39m 'res' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/klz-payload-mcp/src/index.ts[24m[0m
|
||||
[0m [2m39:18[22m [33mwarning[39m 'e' is defined but never used. Allowed unused caught errors must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/memory-mcp/src/qdrant.test.ts[24m[0m
|
||||
[0m [2m7:52[22m [33mwarning[39m 'text' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/page-audit/src/report.ts[24m[0m
|
||||
[0m [2m7:47[22m [33mwarning[39m 'PageAuditData' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m7:62[22m [33mwarning[39m 'AuditIssue' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx[24m[0m
|
||||
[0m [2m11:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateSlugButton.tsx[24m[0m
|
||||
[0m [2m20:21[22m [33mwarning[39m 'replaceState' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateThumbnailButton.tsx[24m[0m
|
||||
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/OptimizeButton.tsx[24m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'Button' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/mcpAdapter.ts[24m[0m
|
||||
[0m [2m44:15[22m [33mwarning[39m 'toolSchema' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/memoryDb.ts[24m[0m
|
||||
[0m [2m89:31[22m [33mwarning[39m 'query' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/payloadLocal.ts[24m[0m
|
||||
[0m [2m3:40[22m [33mwarning[39m 'User' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/types.ts[24m[0m
|
||||
[0m [2m1:15[22m [33mwarning[39m 'Plugin' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/ConceptPDF.tsx[24m[0m
|
||||
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/EstimationPDF.tsx[24m[0m
|
||||
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m54:11[22m [33mwarning[39m 'getPageNum' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/InfoPDF.tsx[24m[0m
|
||||
[0m [2m5:13[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m12:5[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SharedUI.tsx[24m[0m
|
||||
[0m [2m528:5[22m [33mwarning[39m 'bankData' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SimpleLayout.tsx[24m[0m
|
||||
[0m [2m4:52[22m [33mwarning[39m 'PDFText' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:26[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/seo-engine/src/report.ts[24m[0m
|
||||
[0m [2m5:3[22m [33mwarning[39m 'TopicCluster' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m6:3[22m [33mwarning[39m 'ContentGap' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m7:3[22m [33mwarning[39m 'CompetitorRanking' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[31m[1m✖ 28 problems (1 error, 27 warnings)[22m[39m[0m
|
||||
[0m[31m[1m[22m[39m[0m
|
||||
97
eslint-errors.txt
Normal file
97
eslint-errors.txt
Normal file
@@ -0,0 +1,97 @@
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/gitea-mcp/src/index.ts[24m[0m
|
||||
[0m [2m12:5[22m [33mwarning[39m 'Resource' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m14:10[22m [33mwarning[39m 'z' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m427:30[22m [33mwarning[39m 'e' is defined but never used. Allowed unused caught errors must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m745:50[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||
[0m [2m745:60[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||
[0m [2m799:54[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||
[0m [2m799:64[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/glitchtip-mcp/src/index.ts[24m[0m
|
||||
[0m [2m124:19[22m [33mwarning[39m 'res' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/klz-payload-mcp/src/index.ts[24m[0m
|
||||
[0m [2m39:18[22m [33mwarning[39m 'e' is defined but never used. Allowed unused caught errors must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/memory-mcp/src/qdrant.test.ts[24m[0m
|
||||
[0m [2m7:52[22m [33mwarning[39m 'text' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/page-audit/src/report.ts[24m[0m
|
||||
[0m [2m7:47[22m [33mwarning[39m 'PageAuditData' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m7:62[22m [33mwarning[39m 'AuditIssue' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/chatPlugin.ts[24m[0m
|
||||
[0m [2m1:15[22m [33mwarning[39m 'Config' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m10:17[22m [31merror[39m 'config' is never reassigned. Use 'const' instead [2mprefer-const[22m[0m
|
||||
[0m [2m48:37[22m [33mwarning[39m 'req' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/ChatWindow/index.tsx[24m[0m
|
||||
[0m [2m43:5[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m44:63[22m [33mwarning[39m 'setMessages' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx[24m[0m
|
||||
[0m [2m11:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateSlugButton.tsx[24m[0m
|
||||
[0m [2m20:21[22m [33mwarning[39m 'replaceState' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateThumbnailButton.tsx[24m[0m
|
||||
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/OptimizeButton.tsx[24m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'Button' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/endpoints/chatEndpoint.ts[24m[0m
|
||||
[0m [2m96:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m100:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/mcpAdapter.ts[24m[0m
|
||||
[0m [2m44:15[22m [33mwarning[39m 'toolSchema' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m53:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/memoryDb.ts[24m[0m
|
||||
[0m [2m50:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m88:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m89:31[22m [33mwarning[39m 'query' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/payloadLocal.ts[24m[0m
|
||||
[0m [2m3:40[22m [33mwarning[39m 'User' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m25:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m45:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m61:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m78:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m95:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/types.ts[24m[0m
|
||||
[0m [2m1:15[22m [33mwarning[39m 'Plugin' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/ConceptPDF.tsx[24m[0m
|
||||
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/EstimationPDF.tsx[24m[0m
|
||||
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m54:11[22m [33mwarning[39m 'getPageNum' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/InfoPDF.tsx[24m[0m
|
||||
[0m [2m5:13[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m12:5[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SharedUI.tsx[24m[0m
|
||||
[0m [2m528:5[22m [33mwarning[39m 'bankData' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SimpleLayout.tsx[24m[0m
|
||||
[0m [2m4:52[22m [33mwarning[39m 'PDFText' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:26[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/seo-engine/src/report.ts[24m[0m
|
||||
[0m [2m5:3[22m [33mwarning[39m 'TopicCluster' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m6:3[22m [33mwarning[39m 'ContentGap' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m7:3[22m [33mwarning[39m 'CompetitorRanking' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[31m[1m✖ 49 problems (16 errors, 33 warnings)[22m[39m[0m
|
||||
[0m[31m[1m[22m[39m[31m[1m 1 error and 0 warnings potentially fixable with the `--fix` option.[22m[39m[0m
|
||||
[0m[31m[1m[22m[39m[0m
|
||||
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
@@ -6,6 +6,12 @@
|
||||
"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 --build --remove-orphans",
|
||||
"dev:mcps:down": "docker-compose -f docker-compose.mcps.yml down",
|
||||
"dev:mcps:watch": "pnpm -r --filter=\"./packages/*-mcp\" exec tsc -w",
|
||||
"dev:mcps": "npm run dev:mcps:up && npm run dev:mcps:watch",
|
||||
"start:mcps": "npm run dev:mcps:up",
|
||||
"start:mcps:force": "docker-compose -f docker-compose.mcps.yml up -d --build --force-recreate --remove-orphans",
|
||||
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
||||
"test": "pnpm -r test",
|
||||
"changeset": "changeset",
|
||||
@@ -36,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",
|
||||
@@ -49,7 +56,7 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"require-in-the-middle": "^8.0.1"
|
||||
},
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.17",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
@@ -68,4 +75,4 @@
|
||||
"@sentry/nextjs": "10.38.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cli",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cloner",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.17",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/concept-engine",
|
||||
"version": "1.9.5",
|
||||
"private": true,
|
||||
"version": "1.9.17",
|
||||
"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.5",
|
||||
"version": "1.9.17",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/eslint-config",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/estimation-engine",
|
||||
"version": "1.9.5",
|
||||
"private": true,
|
||||
"version": "1.9.17",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/gatekeeper",
|
||||
"version": "1.9.6",
|
||||
"private": true,
|
||||
"version": "1.9.17",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -12,14 +11,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "16.1.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"three": "^0.183.1"
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
@@ -29,7 +25,6 @@
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/three": "^0.183.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { GateScene } from "../../components/gate-scene";
|
||||
import { AnimatedLoginForm } from "../../components/animated-login-form";
|
||||
|
||||
@@ -165,14 +164,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
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
|
||||
<img
|
||||
src="/gatekeeper/icon-white.svg"
|
||||
alt="Mintel"
|
||||
style={{ filter: "invert(1)" }}
|
||||
width={28}
|
||||
height={28}
|
||||
className="w-7 h-7 opacity-80"
|
||||
unoptimized
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
@@ -214,14 +212,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
rel="noopener noreferrer"
|
||||
className="opacity-30 transition-opacity hover:opacity-60"
|
||||
>
|
||||
<Image
|
||||
<img
|
||||
src="/gatekeeper/logo-white.svg"
|
||||
alt={projectName}
|
||||
width={120}
|
||||
height={36}
|
||||
className="h-5 sm:h-6 w-auto"
|
||||
style={{ filter: "invert(1)" }}
|
||||
unoptimized
|
||||
/>
|
||||
</a>
|
||||
<p className="text-[7px] font-sans font-semibold text-black/25 uppercase tracking-[0.5em] text-center">
|
||||
|
||||
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 npm install -g pnpm@10.18.3 && 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 start.js
|
||||
ENTRYPOINT ["node", "dist/start.js"]
|
||||
22
packages/gitea-mcp/package.json
Normal file
22
packages/gitea-mcp/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@mintel/gitea-mcp",
|
||||
"version": "1.9.17",
|
||||
"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": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.10",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
1347
packages/gitea-mcp/src/index.ts
Normal file
1347
packages/gitea-mcp/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
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({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||
// Fallback to .env (contains defaults)
|
||||
config({ quiet: true, 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/**/*"
|
||||
]
|
||||
}
|
||||
15
packages/glitchtip-mcp/Dockerfile
Normal file
15
packages/glitchtip-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENTRYPOINT ["node", "dist/start.js"]
|
||||
24
packages/glitchtip-mcp/package.json
Normal file
24
packages/glitchtip-mcp/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@mintel/glitchtip-mcp",
|
||||
"version": "1.9.17",
|
||||
"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": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
219
packages/glitchtip-mcp/src/index.ts
Normal file
219
packages/glitchtip-mcp/src/index.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import express, { Request, Response } from 'express';
|
||||
import crypto from 'crypto';
|
||||
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,
|
||||
timeout: 10000 // Add timeout to prevent hangs
|
||||
});
|
||||
|
||||
// Logging middleware for GlitchTip client
|
||||
glitchtipClient.interceptors.request.use((config) => {
|
||||
console.error(`GlitchTip Request: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
}, (error) => {
|
||||
console.error(`GlitchTip Request Error: ${error.message}`);
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
glitchtipClient.interceptors.response.use((response) => {
|
||||
return response;
|
||||
}, (error) => {
|
||||
const msg = error.response?.data?.detail || error.response?.data?.message || error.message;
|
||||
console.error(`GlitchTip API Error (${error.config?.url}): ${msg}`);
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
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) => {
|
||||
try {
|
||||
if (request.params.name === "glitchtip_list_projects") {
|
||||
const res = await glitchtipClient.get('/projects/');
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
|
||||
if (request.params.name === "glitchtip_list_issues") {
|
||||
const { organization_slug, project_slug, query, limit = 20 } = request.params.arguments as any;
|
||||
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) }] };
|
||||
}
|
||||
|
||||
if (request.params.name === "glitchtip_get_issue_details") {
|
||||
const { issue_id } = request.params.arguments as any;
|
||||
const res = await glitchtipClient.get(`/issues/${issue_id}/`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
|
||||
if (request.params.name === "glitchtip_update_issue") {
|
||||
const { issue_id, status } = request.params.arguments as any;
|
||||
const res = await glitchtipClient.put(`/issues/${issue_id}/`, { status });
|
||||
return { content: [{ type: "text", text: `Issue ${issue_id} status updated to ${status}.` }] };
|
||||
}
|
||||
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
} catch (e: any) {
|
||||
const msg = e.response?.data?.detail || e.response?.data?.message || e.message;
|
||||
return { isError: true, content: [{ type: "text", text: `GlitchTip Service Error: ${msg}` }] };
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
// Manual CORS middleware
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
console.error(`${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/sse', async (req, res) => {
|
||||
const transport = new SSEServerTransport('/message', res as any);
|
||||
const sessionId = transport.sessionId;
|
||||
console.error(`New SSE connection: ${sessionId}`);
|
||||
transports.set(sessionId, transport);
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 15000);
|
||||
|
||||
req.on('close', () => {
|
||||
console.error(`SSE connection closed: ${sessionId}`);
|
||||
clearInterval(heartbeatInterval);
|
||||
transports.delete(sessionId);
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
req.on('close', resolve);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/message', express.json(), async (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = transports.get(sessionId);
|
||||
|
||||
console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`);
|
||||
|
||||
if (!transport) {
|
||||
console.error(`No transport found for session: ${sessionId}`);
|
||||
res.status(400).send('No active SSE connection for this session');
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.GLITCHTIP_MCP_PORT) || 3005;
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.error(`GlitchTip MCP server running on http://0.0.0.0:${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({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||
config({ quiet: true, 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.5",
|
||||
"version": "1.9.17",
|
||||
"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
|
||||
|
||||
2
packages/infra/docker/config.yml
Normal file
2
packages/infra/docker/config.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
service:
|
||||
allowiconchanges: false
|
||||
@@ -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
|
||||
|
||||
42
packages/infra/docker/vikunja-compose.yml
Normal file
42
packages/infra/docker/vikunja-compose.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_USER: vikunja
|
||||
POSTGRES_PASSWORD: mintel_vikunja_DB_2026!
|
||||
POSTGRES_DB: vikunja
|
||||
volumes:
|
||||
- ./data/db:/var/lib/postgresql/data
|
||||
restart: always
|
||||
|
||||
vikunja:
|
||||
image: vikunja/vikunja:latest
|
||||
environment:
|
||||
VIKUNJA_DATABASE_HOST: db
|
||||
VIKUNJA_DATABASE_PASSWORD: mintel_vikunja_DB_2026!
|
||||
VIKUNJA_DATABASE_TYPE: postgres
|
||||
VIKUNJA_DATABASE_USER: vikunja
|
||||
VIKUNJA_DATABASE_DATABASE: vikunja
|
||||
VIKUNJA_SERVICE_PUBLICURL: https://tasks.infra.mintel.me/
|
||||
VIKUNJA_SERVICE_ENABLEREGISTRATION: true
|
||||
VIKUNJA_SERVICE_ALLOWICONCHANGES: false
|
||||
VIKUNJA_SERVICE_JWTSECRET: d2FzaHViZXJzZWN1cmVqd3RzZWNyZXQyMDI2IQ==
|
||||
volumes:
|
||||
- ./data/files:/app/vikunja/files
|
||||
- ./config.yml:/etc/vikunja/config.yml:ro
|
||||
depends_on:
|
||||
- db
|
||||
restart: always
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.vikunja.rule=Host(`tasks.infra.mintel.me`)"
|
||||
- "traefik.http.routers.vikunja.entrypoints=websecure"
|
||||
- "traefik.http.routers.vikunja.tls.certresolver=le"
|
||||
- "traefik.http.services.vikunja.loadbalancer.server.port=3456"
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
|
||||
networks:
|
||||
infra:
|
||||
external: true
|
||||
@@ -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.5",
|
||||
"version": "1.9.17",
|
||||
"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
|
||||
@@ -44,7 +56,17 @@ fi
|
||||
# 3. Global Docker Pruning
|
||||
echo "🧹 Pruning Docker resources..."
|
||||
docker system prune -af --filter "until=24h"
|
||||
|
||||
echo "🧹 Pruning BuildKit builder containers and cache..."
|
||||
# Remove orphaned buildkit containers to release volume locks
|
||||
docker rm -f $(docker ps -aq --filter name=buildx_buildkit_builder) 2>/dev/null || true
|
||||
docker builder prune -af || true
|
||||
docker buildx prune -af || true
|
||||
|
||||
echo "🧹 Pruning Docker volumes..."
|
||||
docker volume prune -f
|
||||
# Force remove dangling buildx state volumes that might bypass standard prune
|
||||
docker volume ls -q | grep buildx | xargs -r docker volume rm 2>/dev/null || true
|
||||
|
||||
echo "✅ Optimization complete!"
|
||||
df -h /mnt/HC_Volume_104575103
|
||||
df -h /mnt/HC_Volume_104796416
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY_DATA="/mnt/HC_Volume_104575103/registry-data/docker/registry/v2"
|
||||
# Find registry data dynamically (volume ID might change)
|
||||
REGISTRY_DATA=$(find /mnt -maxdepth 5 -type d -path '*/registry-data/docker/registry/v2' | head -n 1)
|
||||
|
||||
if [ -z "$REGISTRY_DATA" ]; then
|
||||
echo "❌ Registry data directory not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KEEP_TAGS=3
|
||||
|
||||
echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/journaling",
|
||||
"version": "1.9.5",
|
||||
"private": true,
|
||||
"version": "1.9.17",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
15
packages/klz-payload-mcp/Dockerfile
Normal file
15
packages/klz-payload-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENTRYPOINT ["node", "dist/start.js"]
|
||||
24
packages/klz-payload-mcp/package.json
Normal file
24
packages/klz-payload-mcp/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@mintel/klz-payload-mcp",
|
||||
"version": "1.9.17",
|
||||
"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.5.0",
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
653
packages/klz-payload-mcp/src/index.ts
Normal file
653
packages/klz-payload-mcp/src/index.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
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 crypto from "crypto";
|
||||
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();
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
// Manual CORS middleware
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
console.error(`${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/sse', async (req, res) => {
|
||||
const transport = new SSEServerTransport('/message', res as any);
|
||||
const sessionId = transport.sessionId;
|
||||
console.error(`New SSE connection: ${sessionId}`);
|
||||
transports.set(sessionId, transport);
|
||||
|
||||
req.on('close', () => {
|
||||
console.error(`SSE connection closed: ${sessionId}`);
|
||||
transports.delete(sessionId);
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
req.on('close', resolve);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/message', express.json(), async (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = transports.get(sessionId);
|
||||
|
||||
console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`);
|
||||
|
||||
if (!transport) {
|
||||
console.error(`No transport found for session: ${sessionId}`);
|
||||
res.status(400).send('No active SSE connection for this session');
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.KLZ_PAYLOAD_MCP_PORT) || 3006;
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.error(`KLZ Payload MCP server running on http://0.0.0.0:${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({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||
config({ quiet: true, 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.5",
|
||||
"version": "1.9.17",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -25,3 +25,4 @@ export * from "./templates/ConfirmationMessage";
|
||||
export * from "./templates/FollowUpTemplate";
|
||||
export * from "./templates/ProjectEstimateTemplate";
|
||||
export * from "./templates/SiteAuditTemplate";
|
||||
export * from "./templates/MonthlyAnalyticsTemplate";
|
||||
|
||||
205
packages/mail/src/templates/MonthlyAnalyticsTemplate.tsx
Normal file
205
packages/mail/src/templates/MonthlyAnalyticsTemplate.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import * as React from "react";
|
||||
import { Heading, Section, Text, Row, Column } from "@react-email/components";
|
||||
import { MintelLayout } from "../layouts/MintelLayout";
|
||||
|
||||
export interface MonthlyAnalyticsTemplateProps {
|
||||
clientName: string;
|
||||
domain: string;
|
||||
month: string;
|
||||
year: string;
|
||||
totalVisitors: number;
|
||||
totalPageviews: number;
|
||||
topPages: { url: string; views: number }[];
|
||||
topReferrers: { url: string; visitors: number }[];
|
||||
}
|
||||
|
||||
export const MonthlyAnalyticsTemplate = ({
|
||||
clientName,
|
||||
domain,
|
||||
month,
|
||||
year,
|
||||
totalVisitors,
|
||||
totalPageviews,
|
||||
topPages,
|
||||
topReferrers,
|
||||
}: MonthlyAnalyticsTemplateProps) => {
|
||||
const preview = `Ihr Website-Report für ${month} ${year}: ${domain}`;
|
||||
|
||||
return (
|
||||
<MintelLayout preview={preview}>
|
||||
<Heading style={h1}>Monatsreport: {month} {year}</Heading>
|
||||
<Text style={intro}>
|
||||
Hallo {clientName},<br /><br />
|
||||
hier ist der monatliche Performance-Report für Ihre Website ({domain}).
|
||||
Die Daten zeigen, wie viele Menschen Ihre Seite besucht haben und welche Inhalte am beliebtesten waren.
|
||||
</Text>
|
||||
|
||||
<Section style={metricsContainer}>
|
||||
<Row>
|
||||
<Column style={metricColumn}>
|
||||
<Text style={metricLabel}>BESUCHER</Text>
|
||||
<Text style={metricValue}>{totalVisitors.toLocaleString('de-DE')}</Text>
|
||||
</Column>
|
||||
<Column style={metricColumn}>
|
||||
<Text style={metricLabel}>SEITENAUFRUFE</Text>
|
||||
<Text style={metricValue}>{totalPageviews.toLocaleString('de-DE')}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={auditContainer}>
|
||||
<Heading as="h2" style={h2}>Top Seiten</Heading>
|
||||
{topPages.map((page, i) => (
|
||||
<Row key={i} style={highlightRow}>
|
||||
<Column style={bulletCol}>
|
||||
<div style={bullet} />
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={highlightText}>{page.url}</Text>
|
||||
</Column>
|
||||
<Column align="right">
|
||||
<Text style={highlightNumber}>{page.views.toLocaleString('de-DE')} Aufrufe</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section style={auditContainer}>
|
||||
<Heading as="h2" style={h2}>Top Besucherquellen</Heading>
|
||||
{topReferrers.length > 0 ? topReferrers.map((ref, i) => (
|
||||
<Row key={i} style={highlightRow}>
|
||||
<Column style={bulletCol}>
|
||||
<div style={bullet} />
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={highlightText}>{ref.url || "Direktaufruf"}</Text>
|
||||
</Column>
|
||||
<Column align="right">
|
||||
<Text style={highlightNumber}>{ref.visitors.toLocaleString('de-DE')} Besucher</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
)) : (
|
||||
<Text style={highlightText}>Keine externen Quellen verzeichnet.</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Text style={bodyText}>
|
||||
Haben Sie Fragen zu diesen Zahlen oder möchten Sie die Reichweite Ihrer Seite weiter ausbauen?
|
||||
Antworten Sie einfach auf diese E-Mail.
|
||||
</Text>
|
||||
|
||||
<Text style={footerText}>
|
||||
Beste Grüße,<br />
|
||||
<strong>Marc Mintel</strong><br />
|
||||
Digitaler Architekt
|
||||
</Text>
|
||||
</MintelLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonthlyAnalyticsTemplate;
|
||||
|
||||
const h1 = {
|
||||
fontSize: "28px",
|
||||
fontWeight: "900",
|
||||
margin: "0 0 24px",
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const h2 = {
|
||||
fontSize: "14px",
|
||||
fontWeight: "900",
|
||||
textTransform: "uppercase" as const,
|
||||
color: "#444444",
|
||||
margin: "0 0 16px",
|
||||
letterSpacing: "0.1em",
|
||||
};
|
||||
|
||||
const intro = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#cccccc",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const metricsContainer = {
|
||||
backgroundColor: "#151515",
|
||||
padding: "32px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "32px",
|
||||
border: "1px solid #222222",
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const metricColumn = {
|
||||
width: "50%",
|
||||
};
|
||||
|
||||
const metricLabel = {
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
color: "#888888",
|
||||
letterSpacing: "0.1em",
|
||||
margin: "0 0 8px",
|
||||
};
|
||||
|
||||
const metricValue = {
|
||||
fontSize: "36px",
|
||||
fontWeight: "900",
|
||||
color: "#4CAF50",
|
||||
margin: "0",
|
||||
letterSpacing: "-0.02em",
|
||||
};
|
||||
|
||||
const auditContainer = {
|
||||
backgroundColor: "#151515",
|
||||
padding: "32px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "32px",
|
||||
border: "1px solid #222222",
|
||||
};
|
||||
|
||||
const highlightRow = {
|
||||
marginBottom: "12px",
|
||||
};
|
||||
|
||||
const bulletCol = {
|
||||
width: "24px",
|
||||
verticalAlign: "top" as const,
|
||||
};
|
||||
|
||||
const bullet = {
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
backgroundColor: "#4CAF50",
|
||||
marginTop: "8px",
|
||||
};
|
||||
|
||||
const highlightText = {
|
||||
fontSize: "15px",
|
||||
color: "#ffffff",
|
||||
margin: "0",
|
||||
lineHeight: "22px",
|
||||
wordBreak: "break-all" as const,
|
||||
};
|
||||
|
||||
const highlightNumber = {
|
||||
fontSize: "15px",
|
||||
color: "#888888",
|
||||
margin: "0",
|
||||
whiteSpace: "nowrap" as const,
|
||||
};
|
||||
|
||||
const bodyText = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#888888",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
lineHeight: "20px",
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/meme-generator",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.17",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
18
packages/memory-mcp/Dockerfile
Normal file
18
packages/memory-mcp/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||
RUN for dir in $(find /app/node_modules -type d -name "sharp" | grep "node_modules/sharp$"); do \
|
||||
echo "module.exports = {};" > "$dir/lib/index.js" || true; \
|
||||
done
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENTRYPOINT ["node", "dist/start.js"]
|
||||
29
packages/memory-mcp/package.json
Normal file
29
packages/memory-mcp/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@mintel/memory-mcp",
|
||||
"version": "1.9.17",
|
||||
"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",
|
||||
"onnxruntime-node": "^1.14.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.19.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^2.1.3"
|
||||
}
|
||||
}
|
||||
153
packages/memory-mcp/src/index.ts
Normal file
153
packages/memory-mcp/src/index.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import express from 'express';
|
||||
import crypto from 'crypto';
|
||||
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();
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
// Manual CORS middleware
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
console.error(`${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/sse', async (req, res) => {
|
||||
const transport = new SSEServerTransport('/message', res as any);
|
||||
const sessionId = transport.sessionId;
|
||||
console.error(`New SSE connection: ${sessionId}`);
|
||||
transports.set(sessionId, transport);
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 15000);
|
||||
|
||||
req.on('close', () => {
|
||||
console.error(`SSE connection closed: ${sessionId}`);
|
||||
clearInterval(heartbeatInterval);
|
||||
transports.delete(sessionId);
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
req.on('close', resolve);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/message', express.json(), async (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = transports.get(sessionId);
|
||||
|
||||
console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`);
|
||||
|
||||
if (!transport) {
|
||||
console.error(`No transport found for session: ${sessionId}`);
|
||||
res.status(400).send('No active SSE connection for this session');
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.MEMORY_MCP_PORT) || 3002;
|
||||
app.listen(PORT, '0.0.0.0', async () => {
|
||||
console.error(`Memory MCP server running on http://0.0.0.0:${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({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||
// Fallback to .env (contains defaults)
|
||||
config({ quiet: true, 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/**/*"
|
||||
]
|
||||
}
|
||||
@@ -53,6 +53,31 @@ const withMintelConfig = (config) => {
|
||||
|
||||
let nextConfig = { ...baseNextConfig, ...config };
|
||||
|
||||
// Explicitly merge rewrites
|
||||
nextConfig.rewrites = async () => {
|
||||
const defaultRewrites = await baseNextConfig.rewrites();
|
||||
let customRewrites = {};
|
||||
if (typeof config.rewrites === 'function') {
|
||||
customRewrites = await config.rewrites();
|
||||
} else if (Array.isArray(config.rewrites)) {
|
||||
customRewrites = config.rewrites;
|
||||
}
|
||||
|
||||
if (Array.isArray(customRewrites)) {
|
||||
return {
|
||||
beforeFiles: [...customRewrites, ...defaultRewrites],
|
||||
afterFiles: [],
|
||||
fallback: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
beforeFiles: [...(customRewrites.beforeFiles || []), ...defaultRewrites],
|
||||
afterFiles: customRewrites.afterFiles || [],
|
||||
fallback: customRewrites.fallback || [],
|
||||
};
|
||||
};
|
||||
|
||||
if (hasI18nConfig) {
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
nextConfig = withNextIntl(nextConfig);
|
||||
@@ -71,3 +96,4 @@ const withMintelConfig = (config) => {
|
||||
};
|
||||
|
||||
export default withMintelConfig;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-config",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-feedback",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-observability",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-utils",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/observability",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
15
packages/outline-mcp/Dockerfile
Normal file
15
packages/outline-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENTRYPOINT ["node", "dist/start.js"]
|
||||
26
packages/outline-mcp/package.json
Normal file
26
packages/outline-mcp/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@mintel/outline-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "Outline 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",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^2.1.3"
|
||||
}
|
||||
}
|
||||
91
packages/outline-mcp/src/index.test.ts
Normal file
91
packages/outline-mcp/src/index.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
|
||||
// Mock environment
|
||||
process.env.OUTLINE_API_TOKEN = 'test-token';
|
||||
process.env.OUTLINE_BASE_URL = 'https://outline.test';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios', () => {
|
||||
const mockPost = vi.fn();
|
||||
return {
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
post: mockPost
|
||||
}))
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('Outline MCP Server Tools', () => {
|
||||
let mockPost: any;
|
||||
let requestHandler: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Extract the mock
|
||||
const axiosInstance = axios.create();
|
||||
mockPost = axiosInstance.post;
|
||||
|
||||
// Mock Server setRequestHandler
|
||||
let capturedHandler: any;
|
||||
vi.spyOn(Server.prototype, 'setRequestHandler').mockImplementation((schema: any, handler: any) => {
|
||||
// The first call is usually ListToolsRequestSchema, second is CallToolRequestSchema
|
||||
// We just store the last one which is CallToolRequestSchema
|
||||
capturedHandler = handler;
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
// Re-import to trigger registration
|
||||
vi.resetModules();
|
||||
await import('./index.js');
|
||||
|
||||
requestHandler = capturedHandler;
|
||||
});
|
||||
|
||||
it('should search documents', async () => {
|
||||
mockPost.mockResolvedValueOnce({ data: { data: [{ id: '1', title: 'Test' }] } });
|
||||
|
||||
const result = await requestHandler({
|
||||
params: {
|
||||
name: 'outline_search_documents',
|
||||
arguments: { query: 'test' }
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/documents.search', { query: 'test', collectionId: undefined });
|
||||
expect(result.content[0].text).toContain('Test');
|
||||
});
|
||||
|
||||
it('should get a document', async () => {
|
||||
mockPost.mockResolvedValueOnce({ data: { data: { id: 'doc1', title: 'Doc 1' } } });
|
||||
|
||||
const result = await requestHandler({
|
||||
params: {
|
||||
name: 'outline_get_document',
|
||||
arguments: { id: 'doc1' }
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/documents.info', { id: 'doc1' });
|
||||
expect(result.content[0].text).toContain('Doc 1');
|
||||
});
|
||||
|
||||
it('should create a document', async () => {
|
||||
mockPost.mockResolvedValueOnce({ data: { data: { id: 'new-doc' } } });
|
||||
|
||||
const result = await requestHandler({
|
||||
params: {
|
||||
name: 'outline_create_document',
|
||||
arguments: { collectionId: 'col1', title: 'New Doc', text: 'Hello', publish: true }
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/documents.create', {
|
||||
collectionId: 'col1', title: 'New Doc', text: 'Hello', publish: true
|
||||
});
|
||||
expect(result.content[0].text).toContain('new-doc');
|
||||
});
|
||||
});
|
||||
184
packages/outline-mcp/src/index.ts
Normal file
184
packages/outline-mcp/src/index.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import axios from "axios";
|
||||
|
||||
// Environment variables
|
||||
const OUTLINE_API_TOKEN = process.env.OUTLINE_API_TOKEN;
|
||||
const OUTLINE_BASE_URL = process.env.OUTLINE_BASE_URL;
|
||||
|
||||
if (!OUTLINE_API_TOKEN || !OUTLINE_BASE_URL) {
|
||||
console.error("Missing required environment variables: OUTLINE_API_TOKEN or OUTLINE_BASE_URL");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: OUTLINE_BASE_URL,
|
||||
headers: {
|
||||
Authorization: `Bearer ${OUTLINE_API_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Define tools
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: "outline_search_documents",
|
||||
description: "Search for documents in Outline.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
collectionId: { type: "string", description: "Optional collection ID to scope search" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "outline_get_document",
|
||||
description: "Get a document by its ID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "The ID of the document" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "outline_create_document",
|
||||
description: "Create a new document.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
collectionId: { type: "string", description: "The ID of the collection" },
|
||||
title: { type: "string", description: "Document title" },
|
||||
text: { type: "string", description: "Document content in Markdown" },
|
||||
publish: { type: "boolean", description: "Whether to publish the document" },
|
||||
},
|
||||
required: ["collectionId", "title", "text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "outline_update_document",
|
||||
description: "Update an existing document.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "The ID of the document" },
|
||||
title: { type: "string", description: "New title" },
|
||||
text: { type: "string", description: "New content in Markdown" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "outline_create_collection",
|
||||
description: "Create a new collection.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Collection name" },
|
||||
description: { type: "string", description: "Collection description" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: "at-mintel-outline-mcp",
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools };
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
try {
|
||||
switch (request.params.name) {
|
||||
case "outline_search_documents": {
|
||||
const { query, collectionId } = request.params.arguments as any;
|
||||
const res = await axiosInstance.post("/api/documents.search", {
|
||||
query,
|
||||
collectionId,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
case "outline_get_document": {
|
||||
const { id } = request.params.arguments as any;
|
||||
const res = await axiosInstance.post("/api/documents.info", { id });
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
case "outline_create_document": {
|
||||
const { collectionId, title, text, publish } = request.params.arguments as any;
|
||||
const res = await axiosInstance.post("/api/documents.create", {
|
||||
collectionId,
|
||||
title,
|
||||
text,
|
||||
publish: publish ?? true,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
case "outline_update_document": {
|
||||
const { id, title, text } = request.params.arguments as any;
|
||||
const res = await axiosInstance.post("/api/documents.update", {
|
||||
id,
|
||||
title,
|
||||
text,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
case "outline_create_collection": {
|
||||
const { name, description } = request.params.arguments as any;
|
||||
const res = await axiosInstance.post("/api/collections.create", {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data ? JSON.stringify(error.response.data) : error.message;
|
||||
return {
|
||||
content: [{ type: "text", text: `API Error: ${errorMsg}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Outline MCP Server running on stdio");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
13
packages/outline-mcp/src/start.ts
Normal file
13
packages/outline-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({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||
|
||||
import('./index.js').catch(err => {
|
||||
console.error('Failed to start Outline MCP Server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
packages/outline-mcp/tsconfig.json
Normal file
16
packages/outline-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,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/page-audit",
|
||||
"version": "1.9.5",
|
||||
"private": true,
|
||||
"version": "1.9.17",
|
||||
"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
|
||||
52
packages/payload-ai/package.json
Normal file
52
packages/payload-ai/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@mintel/payload-ai",
|
||||
"version": "1.9.18",
|
||||
"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/FieldGenerators/*": "./dist/components/FieldGenerators/*.js",
|
||||
"./components/*": "./dist/components/*.js",
|
||||
"./actions/*": "./dist/actions/*.js",
|
||||
"./globals/*": "./dist/globals/*.js",
|
||||
"./endpoints/*": "./dist/endpoints/*.js",
|
||||
"./utils/*": "./dist/utils/*.js",
|
||||
"./tools/*": "./dist/tools/*.js"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
98
packages/payload-ai/src/chatPlugin.ts
Normal file
98
packages/payload-ai/src/chatPlugin.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { 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) => {
|
||||
const 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/index#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>
|
||||
);
|
||||
};
|
||||
156
packages/payload-ai/src/components/ChatWindow/index.tsx
Normal file
156
packages/payload-ai/src/components/ChatWindow/index.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
|
||||
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
|
||||
|
||||
const { messages, input, handleInputChange, handleSubmit } = useChat({
|
||||
api: "/api/mcp-chat",
|
||||
initialMessages: [],
|
||||
body: {
|
||||
pageContext,
|
||||
},
|
||||
} as any) 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>
|
||||
);
|
||||
}
|
||||
142
packages/payload-ai/src/endpoints/chatEndpoint.ts
Normal file
142
packages/payload-ai/src/endpoints/chatEndpoint.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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({
|
||||
model: openrouter("google/gemini-3.0-flash"),
|
||||
messages,
|
||||
tools: activeTools,
|
||||
// @ts-expect-error - 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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
189
packages/payload-ai/src/endpoints/generateEndpoints.ts
Normal file
189
packages/payload-ai/src/endpoints/generateEndpoints.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
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 {
|
||||
let body: any = {};
|
||||
try {
|
||||
if (req.body) body = (await req.json?.()) || {};
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const { title, draftContent, oldSlug, instructions } = body;
|
||||
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 {
|
||||
let body: any = {};
|
||||
try {
|
||||
if (req.body) body = (await req.json?.()) || {};
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const { draftContent, title, instructions } = body;
|
||||
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 {
|
||||
let body: any = {};
|
||||
try {
|
||||
if (req.body) body = (await req.json?.()) || {};
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const {
|
||||
documentTitle,
|
||||
documentContent,
|
||||
fieldName,
|
||||
fieldDescription,
|
||||
instructions,
|
||||
} = body;
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
108
packages/payload-ai/src/endpoints/optimizeEndpoint.ts
Normal file
108
packages/payload-ai/src/endpoints/optimizeEndpoint.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { PayloadRequest } from "payload";
|
||||
import { parseMarkdownToLexical } from "../utils/lexicalParser.js";
|
||||
|
||||
export const optimizePostEndpoint = async (req: PayloadRequest) => {
|
||||
try {
|
||||
let body: any = {};
|
||||
try {
|
||||
if (req.body) {
|
||||
// req.json() acts as a method in Next.js/Payload req wrapper
|
||||
body = (await req.json?.()) || {};
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore JSON parse error, body remains empty
|
||||
}
|
||||
|
||||
const { draftContent, instructions } = body as {
|
||||
draftContent?: string;
|
||||
instructions?: string;
|
||||
};
|
||||
|
||||
if (!draftContent) {
|
||||
return Response.json(
|
||||
{ success: false, 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';
|
||||
68
packages/payload-ai/src/tools/mcpAdapter.ts
Normal file
68
packages/payload-ai/src/tools/mcpAdapter.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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, jsonSchema } from "ai";
|
||||
/**
|
||||
* 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: jsonSchema(toolSchema as any),
|
||||
// @ts-expect-error - 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 };
|
||||
}
|
||||
140
packages/payload-ai/src/tools/memoryDb.ts
Normal file
140
packages/payload-ai/src/tools/memoryDb.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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-expect-error - 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-expect-error - 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 [];
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
137
packages/payload-ai/src/tools/payloadLocal.ts
Normal file
137
packages/payload-ai/src/tools/payloadLocal.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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-expect-error - 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-expect-error - 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-expect-error - 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-expect-error - 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-expect-error - 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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user