Compare commits

...

20 Commits

Author SHA1 Message Date
9ba1ddf2a6 chore: trigger deployment for payload-ai v1.9.18 MCP tools fix
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m10s
Build & Deploy / 🏗️ Build (push) Successful in 5m6s
Build & Deploy / 🚀 Deploy (push) Successful in 1m31s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-18 21:51:04 +01:00
a558b18cd8 fix: switch AI model to openai/gpt-4o-mini for reliable MCP tool calling
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m7s
Build & Deploy / 🏗️ Build (push) Successful in 5m28s
Build & Deploy / 🚀 Deploy (push) Successful in 1m29s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-18 21:39:35 +01:00
482460b336 fix: add missing MISTRAL_API_KEY to deploy env
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m9s
Build & Deploy / 🏗️ Build (push) Successful in 5m17s
Build & Deploy / 🚀 Deploy (push) Successful in 1m32s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-18 18:56:00 +01:00
943c6ed09d fix: ai model id and remove klz-kabelfachmann public port
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m6s
Build & Deploy / 🏗️ Build (push) Successful in 5m17s
Build & Deploy / 🚀 Deploy (push) Successful in 1m32s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-18 17:25:04 +01:00
c45a90082b chore: trigger deploy after pushing missing kabelfachmann-mcp image
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🏗️ Build (push) Successful in 5m21s
Build & Deploy / 🚀 Deploy (push) Successful in 1m20s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-18 16:13:27 +01:00
fb98c8237e feat(qdrant-sync): add gzip compression before transfer to reduce upload size
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m8s
Build & Deploy / 🏗️ Build (push) Successful in 5m32s
Build & Deploy / 🚀 Deploy (push) Failing after 9s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-18 10:32:54 +01:00
b84e0d782d fix(deploy): use :latest tag for kabelfachmann-mcp image (pre-built external image)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-03-18 10:32:42 +01:00
ef4f0e7ace fix(qdrant-sync): use rsync with SSH keepalive to prevent broken pipe on large transfers
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m11s
Build & Deploy / 🏗️ Build (push) Successful in 5m40s
Build & Deploy / 🚀 Deploy (push) Failing after 12s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-17 23:51:02 +01:00
d6bdd28b30 fix(ai-search): fix linting errors for deployment
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m30s
Build & Deploy / 🏗️ Build (push) Successful in 6m41s
Build & Deploy / 🚀 Deploy (push) Failing after 11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-17 23:03:48 +01:00
289e41a040 feat: integrate and deploy kabelfachmann mcp
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 1m10s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-17 22:09:13 +01:00
5144378c7e fix: routes 2026-03-10 11:34:01 +01:00
c804b051e0 fix: qdrant 2026-03-08 01:36:14 +01:00
1dc52da677 feat: Automate Qdrant PDF ingestion via Media hooks
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m1s
Build & Deploy / 🏗️ Build (push) Successful in 4m22s
Build & Deploy / 🚀 Deploy (push) Successful in 1m41s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-08 01:08:55 +01:00
7f1aeaee7e ci: add migrator target to Dockerfile for deployment pipeline
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m55s
Build & Deploy / 🏗️ Build (push) Successful in 8m40s
Build & Deploy / 🚀 Deploy (push) Successful in 1m23s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-07 23:41:42 +01:00
590c542b73 ci: fix Docker build missing patches directory
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 58s
Build & Deploy / 🏗️ Build (push) Failing after 4m38s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-03-07 23:34:26 +01:00
ab477e8d8e chore: commit Payload importMap to fix CI typecheck
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 59s
Build & Deploy / 🏗️ Build (push) Failing after 24s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-07 23:30:59 +01:00
d83ab182db ci: ignore dirty workspace files from eslint to fix QA
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 55s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-07 23:26:01 +01:00
159ee66f55 fix: patch @mintel/payload-ai to remove missing SCSS import
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 58s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-07 22:43:38 +01:00
45c385d62e ci: fix typecheck by clearing stale .next cache before QA
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 57s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-07 22:34:22 +01:00
8e99c9d121 feat: automated Qdrant sync with Mistral embeddings + Kabelhandbuch ingestion
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 55s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Switch embedding API from OpenRouter to Mistral mistral-embed (1024-dim, EU/DSGVO)
- Add afterChange/afterDelete hooks to Posts.ts and Pages.ts for live sync
- Integrate kabelhandbuch.txt parsing into /api/sync-qdrant boot route
- Add .gitignore entries for kabelhandbuch.txt
2026-03-07 15:39:10 +01:00
18 changed files with 674 additions and 133 deletions

View File

@@ -14,8 +14,8 @@ on:
default: 'false'
env:
PUPPETEER_SKIP_DOWNLOAD: "true"
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
PUPPETEER_SKIP_DOWNLOAD: 'true'
COREPACK_NPM_REGISTRY: 'https://registry.npmmirror.com'
concurrency:
group: deploy-pipeline
@@ -29,16 +29,16 @@ jobs:
name: 🔍 Prepare
runs-on: docker
outputs:
target: ${{ steps.determine.outputs.target }}
image_tag: ${{ steps.determine.outputs.image_tag }}
env_file: ${{ steps.determine.outputs.env_file }}
traefik_host: ${{ steps.determine.outputs.traefik_host }}
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
next_public_url: ${{ steps.determine.outputs.next_public_url }}
project_name: ${{ steps.determine.outputs.project_name }}
short_sha: ${{ steps.determine.outputs.short_sha }}
slug: ${{ steps.determine.outputs.slug }}
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
target: ${{ steps.determine.outputs.target }}
image_tag: ${{ steps.determine.outputs.image_tag }}
env_file: ${{ steps.determine.outputs.env_file }}
traefik_host: ${{ steps.determine.outputs.traefik_host }}
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
next_public_url: ${{ steps.determine.outputs.next_public_url }}
project_name: ${{ steps.determine.outputs.project_name }}
short_sha: ${{ steps.determine.outputs.short_sha }}
slug: ${{ steps.determine.outputs.slug }}
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
container:
image: catthehacker/ubuntu:act-latest
steps:
@@ -96,7 +96,7 @@ jobs:
TRAEFIK_RULE='Host(`'"$TRAEFIK_HOST"'`)'
PRIMARY_HOST="$TRAEFIK_HOST"
fi
GATEKEEPER_HOST="gatekeeper.$PRIMARY_HOST"
{
@@ -187,10 +187,13 @@ jobs:
- name: 🔒 Security Audit
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
- name: 🧹 Clean Workspace
run: rm -rf .next .turbo || true
- name: 🧪 QA Checks
if: github.event.inputs.skip_checks != 'true'
env:
TURBO_TELEMETRY_DISABLED: "1"
TURBO_TELEMETRY_DISABLED: '1'
run: npx turbo run lint typecheck test --cache-dir=".turbo"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push
@@ -252,54 +255,56 @@ jobs:
container:
image: catthehacker/ubuntu:act-latest
env:
TARGET: ${{ needs.prepare.outputs.target }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
TARGET: ${{ needs.prepare.outputs.target }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
SLUG: ${{ needs.prepare.outputs.slug }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
SLUG: ${{ needs.prepare.outputs.slug }}
# Secrets mapping (Payload CMS)
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }}
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }}
PAYLOAD_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_DB_PASSWORD) || secrets.PAYLOAD_DB_PASSWORD || vars.PAYLOAD_DB_PASSWORD || 'payload' }}
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }}
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }}
PAYLOAD_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_DB_PASSWORD) || secrets.PAYLOAD_DB_PASSWORD || vars.PAYLOAD_DB_PASSWORD || 'payload' }}
# Secrets mapping (Mail)
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
# Monitoring
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
# Gatekeeper
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
# Analytics
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
# Search & AI
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY || vars.MISTRAL_API_KEY }}
QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
KABELFACHMANN_MCP_URL: ${{ secrets.KABELFACHMANN_MCP_URL || vars.KABELFACHMANN_MCP_URL || 'http://klz-kabelfachmann:3007/sse' }}
# Container Registry (standalone)
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 📝 Generate Environment
shell: bash
env:
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
run: |
# Middleware Selection Logic
# Regular app routes get auth on non-production
@@ -307,7 +312,7 @@ jobs:
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
STD_MW="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-compress"
if [[ "$TARGET" == "production" ]]; then
AUTH_MIDDLEWARE="$STD_MW"
COMPOSE_PROFILES=""
@@ -352,9 +357,11 @@ jobs:
echo ""
echo "# Search & AI"
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
echo "MISTRAL_API_KEY=$MISTRAL_API_KEY"
echo "QDRANT_URL=$QDRANT_URL"
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
echo "REDIS_URL=$REDIS_URL"
echo "KABELFACHMANN_MCP_URL=$KABELFACHMANN_MCP_URL"
echo ""
echo "TARGET=$TARGET"
echo "SENTRY_ENVIRONMENT=$TARGET"
@@ -404,7 +411,7 @@ jobs:
- name: 🚀 SSH Deploy
shell: bash
env:
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
@@ -428,10 +435,10 @@ jobs:
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
# Execute remote commands — alpha is pre-logged into registry.infra.mintel.me
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --remove-orphans"
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
@@ -444,7 +451,7 @@ jobs:
echo " Attempt $i/15..."
sleep 2
done
echo "🔧 Sanitizing payload_migrations table (if exists)..."
REMOTE_DB_USER=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_USER=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
@@ -455,7 +462,7 @@ jobs:
# This ensures fresh branch deployments (empty DBs) get their schema on first deploy.
echo "🔄 Running Payload migrations..."
MIGRATOR_IMAGE="registry.infra.mintel.me/mintel/klz-2026:migrate-$IMAGE_TAG"
ssh root@alpha.mintel.me "
echo '${{ steps.auth.outputs.token }}' | docker login registry.infra.mintel.me -u '${{ steps.auth.outputs.user }}' --password-stdin 2>/dev/null || true
docker pull $MIGRATOR_IMAGE
@@ -466,7 +473,7 @@ jobs:
&& echo '✅ Migrations complete.' \
|| echo '⚠️ Migrations failed or already up-to-date — continuing.'
"
# Restart app to pick up clean migration state
APP_CONTAINER="${PROJECT_NAME}-klz-app-1"
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
@@ -518,7 +525,7 @@ jobs:
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'
run: |

3
.gitignore vendored
View File

@@ -32,4 +32,5 @@ backups/
.env
# Payload CMS auto-generated
app/(payload)/admin/importMap.js
# Knowledge base source files
kabelhandbuch.txt

View File

@@ -18,6 +18,7 @@ ENV CI=true
# Copy lockfile and manifest for dependency installation caching
COPY pnpm-lock.yaml package.json .npmrc* ./
COPY patches* ./patches/
# Configure private registry and install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
@@ -37,6 +38,11 @@ FROM base AS development
ENV NODE_ENV=development
CMD ["pnpm", "dev:local"]
# Stage: Migrator
FROM base AS migrator
ENV NODE_ENV=production
CMD ["pnpm", "cms:migrate"]
# Build application
# Stage 3: Builder (Production)
FROM base AS builder

View File

@@ -0,0 +1,84 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon';
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo';
import { ChatWindowProvider as ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5 } from '@mintel/payload-ai/components/ChatWindow';
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc';
export const importMap = {
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UploadFeatureClient':
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BlockquoteFeatureClient':
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#RelationshipFeatureClient':
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ChecklistFeatureClient':
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#OrderedListFeatureClient':
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UnorderedListFeatureClient':
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#IndentFeatureClient':
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#AlignFeatureClient':
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#InlineCodeFeatureClient':
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#SuperscriptFeatureClient':
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#SubscriptFeatureClient':
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#StrikethroughFeatureClient':
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'/src/payload/components/Icon#default': default_9ed509b5e5f7d08a16335393f27586cc,
'/src/payload/components/Logo#default': default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
'@mintel/payload-ai/components/ChatWindow#ChatWindowProvider':
ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5,
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
};

View File

@@ -3,6 +3,11 @@ import { searchProducts } from '../../../src/lib/qdrant';
import redis from '../../../src/lib/redis';
import { z } from 'zod';
import * as Sentry from '@sentry/nextjs';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
// @ts-expect-error - Local version of @mintel/payload-ai/tools/mcpAdapter might not have types published yet
import { createMcpTools } from '@mintel/payload-ai/tools/mcpAdapter';
export const dynamic = 'force-dynamic';
export const maxDuration = 60; // Max allowed duration (Vercel)
@@ -108,12 +113,9 @@ Das ECHTE KLZ Team:
.map((p: any) => p.payload?.content)
.join('\n\n');
const knowledgeDescriptions = searchResults
.filter((p) => p.payload?.type === 'knowledge')
.map((p: any) => p.payload?.content)
.join('\n\n');
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}\n\nKABELWISSEN (Handbuch):\n${knowledgeDescriptions}`;
if (productDescriptions) {
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}`;
}
foundProducts = searchResults
.filter((p) => (p.payload?.type === 'product' || !p.payload?.type) && p.payload?.data)
@@ -145,12 +147,14 @@ DEINE HAUPTAUFGABE: BERATEN, NICHT AUSFRAGEN!
- FRAGE NICHT nach abstrakten Dingen wie "Welchen Kabeltyp brauchst du?" -> DAS IST DEIN JOB, IHM DAS ZU SAGEN!
- FRAGE NICHT nach Längen oder genauen Trassen, es sei denn, der Kunde hat schon ganz klar gesagt, was er kaufen will.
- Biete aktiv Hilfe an: "Ich kann dir die passenden Querschnitte raussuchen, wenn du willst."
- Wenn technisches Wissen aus dem Kabelhandbuch benötigt wird, NUTZE UNBEDINGT eines der "kabelfachmann_*" Tools, anstatt zu raten oder zu behaupten du wüsstest es nicht! Das Tool weiss alles.
VORGEHEN:
1. Prüfe den KONTEXT auf passende Kabel für das Kundenprojekt.
2. Nenne direkt 1-2 passende Produktserien aus dem Kontext, die für diesen Fall Sinn machen.
3. Biete eine konkrete Hilfestellung an (z.B. Leitungsberechnung, Verfügbarkeitsprüfung) ODER stelle EINE einzige fachliche Rückfrage, um das Kabel weiter einzugrenzen (z.B. Alu oder Kupfer?).
4. Wenn das Projekt klar ist und die Kabeltypen besprochen sind, frag nach, ob ein Kollege (z.B. Micha) ein konkretes Angebot machen soll.
1. Prüfe den KONTEXT auf passende Katalog-Kabel für das Kundenprojekt.
2. Wenn du tiefgehendes Wissen zu einem Kabeltyp brauchst (z.B. Biegeradius, Normen, Querschnitte), rufe das Kabelfachmann-Tool auf.
3. Nenne direkt 1-2 passende Produktserien aus dem Kontext oder der Tool-Abfrage, die für diesen Fall Sinn machen.
4. Biete eine konkrete Hilfestellung an (z.B. Leitungsberechnung, Verfügbarkeitsprüfung) ODER stelle EINE einzige fachliche Rückfrage, um das Kabel weiter einzugrenzen (z.B. Alu oder Kupfer?).
5. Wenn das Projekt klar ist und die Kabeltypen besprochen sind, frag nach, ob ein Kollege (z.B. Micha) ein konkretes Angebot machen soll.
GRENZEN:
- PRIVAT-ANFRAGEN: B2B only. Private Hausinstallationen lehnen wir freundlich ab.
@@ -161,51 +165,42 @@ ${contextStr || 'Kein Katalogkontext verfügbar.'}
${teamContextStr}
`;
const mistralKey = process.env.MISTRAL_API_KEY;
if (!mistralKey) {
throw new Error('MISTRAL_API_KEY is not set');
const openrouterApiKey = process.env.OPENROUTER_API_KEY;
if (!openrouterApiKey) {
throw new Error('OPENROUTER_API_KEY is not set');
}
// DSGVO: Mistral AI API direkt (EU/Frankreich) statt OpenRouter (US)
const fetchRes = await fetch('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${mistralKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'ministral-8b-latest',
temperature: 0.3,
max_tokens: MAX_RESPONSE_TOKENS,
messages: [
{ role: 'system', content: systemPrompt },
...cappedMessages.map((m: any) => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
})),
],
}),
const openrouter = createOpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: openrouterApiKey,
});
if (!fetchRes.ok) {
const errBody = await fetchRes.text();
console.error('Mistral API Error:', errBody);
Sentry.captureException(new Error(`Mistral ${fetchRes.status}: ${errBody}`), {
tags: { context: 'ai-search-mistral' },
let mcpTools: Record<string, any> = {};
const mcpUrl = process.env.KABELFACHMANN_MCP_URL || 'http://host.docker.internal:3007/sse';
try {
const { tools } = await createMcpTools({
name: 'kabelfachmann',
url: mcpUrl,
});
// Return user-friendly error based on status
const userMsg =
fetchRes.status === 429
? 'Der KI-Service ist gerade überlastet. Bitte versuche es in ein paar Sekunden erneut.'
: fetchRes.status >= 500
? 'Der KI-Service ist vorübergehend nicht erreichbar. Bitte versuche es gleich nochmal.'
: 'Es gab ein Problem mit der KI-Anfrage. Bitte versuche es erneut.';
return NextResponse.json({ error: userMsg }, { status: 502 });
mcpTools = tools;
} catch (e) {
console.warn('Failed to load MCP tools', e);
Sentry.captureException(e, { tags: { context: 'ai-search-mcp' } });
}
const data = await fetchRes.json();
const text = data.choices[0].message.content;
const { text } = await generateText({
model: openrouter('openai/gpt-4o-mini'),
system: systemPrompt,
messages: cappedMessages.map((m: any) => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
})),
tools: mcpTools,
// @ts-expect-error - maxSteps might be missing in some versions of generateText types
maxSteps: 5,
temperature: 0.3,
maxTokens: MAX_RESPONSE_TOKENS,
});
// Return the AI's answer along with any found products
return NextResponse.json({

View File

@@ -106,8 +106,47 @@ export async function GET() {
}
}
// ── Kabelhandbuch (Static Text) ──
const os = require('os');
const path = require('path');
const fs = require('fs');
const crypto = await import('crypto');
const txtPath = path.join(process.cwd(), 'kabelhandbuch.txt');
let manualChunks = 0;
if (fs.existsSync(txtPath)) {
try {
const text = fs.readFileSync(txtPath, 'utf8');
const chunks = text
.split(/\n\s*\n/)
.map((c: string) => c.trim())
.filter((c: string) => c.length > 50);
for (let i = 0; i < chunks.length; i++) {
const chunkText = chunks[i];
const syntheticId = crypto.randomUUID();
await upsertProductVector(syntheticId, chunkText, {
type: 'knowledge',
content: chunkText,
data: {
title: `Kabelhandbuch Wissen - Bereich ${i + 1}`,
source: 'Kabelhandbuch KLZ.pdf',
},
});
manualChunks++;
}
console.log(`[Qdrant Sync] ✅ ${manualChunks} Kabelhandbuch-Chunks synced`);
} catch (e: any) {
results.errors.push(`kabelhandbuch: ${e.message}`);
}
} else {
console.log(`[Qdrant Sync] ⚠️ skipped Kabelhandbuch: ${txtPath} not found`);
}
console.log(
`[Qdrant Sync] ✅ ${results.products} products, ${results.posts} posts, ${results.pages} pages synced`,
`[Qdrant Sync] ✅ ${results.products} products, ${results.posts} posts, ${results.pages} pages synced, ${manualChunks} manual chunks synced`,
);
return NextResponse.json({

View File

@@ -112,9 +112,20 @@ services:
- klz_qdrant_data:/qdrant/storage
networks:
- default
ports:
- "16333:6333"
klz-kabelfachmann:
image: registry.infra.mintel.me/mintel/kabelfachmann-mcp:latest
restart: unless-stopped
networks:
- default
env_file:
- ${ENV_FILE:-.env}
environment:
QDRANT_URL: http://klz-qdrant:6333
depends_on:
- klz-qdrant
networks:
default:
name: ${PROJECT_NAME:-klz-cables}-internal

View File

@@ -119,6 +119,18 @@ services:
networks:
- default
klz-kabelfachmann:
image: registry.infra.mintel.me/mintel/kabelfachmann-mcp:latest
restart: unless-stopped
networks:
- default
env_file:
- ${ENV_FILE:-.env}
environment:
QDRANT_URL: http://klz-qdrant:6333
depends_on:
- klz-qdrant
networks:
default:
name: ${PROJECT_NAME:-klz-cables}-internal

View File

@@ -23,7 +23,9 @@ export default [
"tests/**",
"next-env.d.ts",
"reference/**",
"data/**"
"data/**",
"remotion/**",
"components/record-mode/**"
],
},

View File

@@ -138,6 +138,10 @@
"assets:pull:prod": "bash ./scripts/assets-sync.sh prod local",
"assets:sync:testing-to-staging": "bash ./scripts/assets-sync.sh testing staging",
"assets:sync:staging-to-prod": "bash ./scripts/assets-sync.sh staging prod",
"qdrant:push:testing": "bash ./scripts/qdrant-sync.sh testing",
"qdrant:push:staging": "bash ./scripts/qdrant-sync.sh staging",
"qdrant:push:prod": "bash ./scripts/qdrant-sync.sh prod",
"qdrant:push:branch": "bash ./scripts/qdrant-sync.sh",
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
"pagespeed:audit": "./scripts/audit-local.sh",
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
@@ -164,6 +168,9 @@
"overrides": {
"next": "16.1.6",
"minimatch": ">=10.2.2"
},
"patchedDependencies": {
"@mintel/payload-ai@1.9.15": "patches/@mintel__payload-ai@1.9.15.patch"
}
},
"browserslist": [

View File

@@ -0,0 +1,131 @@
diff --git a/dist/components/ChatWindow/index.js b/dist/components/ChatWindow/index.js
index 90c65bae4abb78beec98d8308e808e8ba341dcc2..f675dbc69ff82b64438288f53599c93a56391b64 100644
--- a/dist/components/ChatWindow/index.js
+++ b/dist/components/ChatWindow/index.js
@@ -2,7 +2,6 @@
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { useChat } from '@ai-sdk/react';
-import './ChatWindow.scss';
export const ChatWindowProvider = ({ children }) => {
return (_jsxs(_Fragment, { children: [children, _jsx(ChatWindow, {})] }));
};
@@ -14,47 +13,63 @@ const ChatWindow = () => {
initialMessages: []
});
// Basic implementation to toggle chat window and submit messages
- return (_jsxs("div", { className: "payload-mcp-chat-container", children: [_jsx("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'
- }, children: isOpen ? 'Close AI Chat' : 'Ask AI' }), isOpen && (_jsxs("div", { className: "payload-mcp-chat-window", style: {
- position: 'fixed',
- bottom: '80px',
- right: '20px',
- width: '400px',
- height: '600px',
- backgroundColor: '#fff',
- border: '1px solid #eaeaea',
- borderRadius: '12px',
- zIndex: 9999,
- display: 'flex',
- flexDirection: 'column',
- boxShadow: '0 10px 40px rgba(0,0,0,0.1)'
- }, children: [_jsx("div", { className: "chat-header", style: { padding: '16px', borderBottom: '1px solid #eaeaea', backgroundColor: '#f9f9f9', borderTopLeftRadius: '12px', borderTopRightRadius: '12px' }, children: _jsx("h3", { style: { margin: 0, fontSize: '16px' }, children: "Payload MCP Chat" }) }), _jsx("div", { className: "chat-messages", style: { flex: 1, padding: '16px', overflowY: 'auto' }, children: messages.map((m) => (_jsx("div", { style: {
- marginBottom: '12px',
- textAlign: m.role === 'user' ? 'right' : 'left'
- }, children: _jsxs("div", { style: {
- display: 'inline-block',
- padding: '8px 12px',
- borderRadius: '8px',
- backgroundColor: m.role === 'user' ? '#000' : '#f0f0f0',
- color: m.role === 'user' ? '#fff' : '#000',
- maxWidth: '80%'
- }, children: [m.role === 'user' ? 'G: ' : 'AI: ', m.content] }) }, m.id))) }), _jsx("form", { onSubmit: handleSubmit, style: { padding: '16px', borderTop: '1px solid #eaeaea' }, children: _jsx("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'
- } }) })] }))] }));
+ return (_jsxs("div", {
+ className: "payload-mcp-chat-container", children: [_jsx("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'
+ }, children: isOpen ? 'Close AI Chat' : 'Ask AI'
+ }), isOpen && (_jsxs("div", {
+ className: "payload-mcp-chat-window", style: {
+ position: 'fixed',
+ bottom: '80px',
+ right: '20px',
+ width: '400px',
+ height: '600px',
+ backgroundColor: '#fff',
+ border: '1px solid #eaeaea',
+ borderRadius: '12px',
+ zIndex: 9999,
+ display: 'flex',
+ flexDirection: 'column',
+ boxShadow: '0 10px 40px rgba(0,0,0,0.1)'
+ }, children: [_jsx("div", { className: "chat-header", style: { padding: '16px', borderBottom: '1px solid #eaeaea', backgroundColor: '#f9f9f9', borderTopLeftRadius: '12px', borderTopRightRadius: '12px' }, children: _jsx("h3", { style: { margin: 0, fontSize: '16px' }, children: "Payload MCP Chat" }) }), _jsx("div", {
+ className: "chat-messages", style: { flex: 1, padding: '16px', overflowY: 'auto' }, children: messages.map((m) => (_jsx("div", {
+ style: {
+ marginBottom: '12px',
+ textAlign: m.role === 'user' ? 'right' : 'left'
+ }, children: _jsxs("div", {
+ style: {
+ display: 'inline-block',
+ padding: '8px 12px',
+ borderRadius: '8px',
+ backgroundColor: m.role === 'user' ? '#000' : '#f0f0f0',
+ color: m.role === 'user' ? '#fff' : '#000',
+ maxWidth: '80%'
+ }, children: [m.role === 'user' ? 'G: ' : 'AI: ', m.content]
+ })
+ }, m.id)))
+ }), _jsx("form", {
+ onSubmit: handleSubmit, style: { padding: '16px', borderTop: '1px solid #eaeaea' }, children: _jsx("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'
+ }
+ })
+ })]
+ }))]
+ }));
};
//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/src/components/ChatWindow/index.tsx b/src/components/ChatWindow/index.tsx
index 9081ae77d4eae53ce660e285c1a6babde99ceaab..f262f1dd0fd1199734024cc27905d956e31900a2 100644
--- a/src/components/ChatWindow/index.tsx
+++ b/src/components/ChatWindow/index.tsx
@@ -2,7 +2,6 @@
import React, { useState } from 'react'
import { useChat } from '@ai-sdk/react'
-import './ChatWindow.scss'
export const ChatWindowProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (

9
pnpm-lock.yaml generated
View File

@@ -8,6 +8,11 @@ overrides:
next: 16.1.6
minimatch: '>=10.2.2'
patchedDependencies:
'@mintel/payload-ai@1.9.15':
hash: 934315d3f15180552789a94bf76c34624501f601cc7409c24da3dc27af96c8fb
path: patches/@mintel__payload-ai@1.9.15.patch
importers:
.:
@@ -32,7 +37,7 @@ importers:
version: 1.9.5(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.18)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)(typescript@5.9.3)
'@mintel/payload-ai':
specifier: ^1.9.15
version: 1.9.15(@payloadcms/next@3.77.0(@types/react@19.2.13)(graphql@16.12.0)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@payloadcms/ui@3.77.0(@types/react@19.2.13)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(ws@8.19.0)
version: 1.9.15(patch_hash=934315d3f15180552789a94bf76c34624501f601cc7409c24da3dc27af96c8fb)(@payloadcms/next@3.77.0(@types/react@19.2.13)(graphql@16.12.0)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@payloadcms/ui@3.77.0(@types/react@19.2.13)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(ws@8.19.0)
'@payloadcms/db-postgres':
specifier: ^3.77.0
version: 3.77.0(@opentelemetry/api@1.9.0)(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))
@@ -10800,7 +10805,7 @@ snapshots:
- sass
- typescript
'@mintel/payload-ai@1.9.15(@payloadcms/next@3.77.0(@types/react@19.2.13)(graphql@16.12.0)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@payloadcms/ui@3.77.0(@types/react@19.2.13)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(ws@8.19.0)':
'@mintel/payload-ai@1.9.15(patch_hash=934315d3f15180552789a94bf76c34624501f601cc7409c24da3dc27af96c8fb)(@payloadcms/next@3.77.0(@types/react@19.2.13)(graphql@16.12.0)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@payloadcms/ui@3.77.0(@types/react@19.2.13)(monaco-editor@0.55.1)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(payload@3.77.0(graphql@16.12.0)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(ws@8.19.0)':
dependencies:
'@ai-sdk/openai': 3.0.41(zod@3.25.76)
'@ai-sdk/react': 3.0.118(react@19.2.4)(zod@3.25.76)

132
scripts/qdrant-sync.sh Executable file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# ────────────────────────────────────────────────────────────────────────────
# Qdrant Snapshot Sync Tool
# Syncs a Qdrant collection from the local machine to a remote environment
# using the safe Snapshot API to avoid RocksDB corruption.
# ────────────────────────────────────────────────────────────────────────────
set -euo pipefail
# Load environment variables
if [ -f .env ]; then
set -a; source .env; set +a
fi
# ── Configuration ──────────────────────────────────────────────────────────
TARGET_ENV="${1:-}" # testing | staging | branch_slug | prod
COLLECTION="${2:-kabelfachmann}"
SSH_HOST="root@alpha.mintel.me"
if [[ -z "$TARGET_ENV" ]]; then
echo "Usage: pnpm run qdrant:push <target_env> [collection]"
echo "Example: pnpm run qdrant:push testing kabelfachmann"
echo "Example: pnpm run qdrant:push mein-feature-slug kabelfachmann"
exit 1
fi
LOCAL_QDRANT_URL=${QDRANT_URL:-"http://localhost:6337"}
TIMEOUT=300 # 5 minutes for large snapshots
get_target_path() {
case "$1" in
testing) echo "/home/deploy/sites/testing.klz-cables.com" ;;
staging) echo "/home/deploy/sites/staging.klz-cables.com" ;;
prod|production) echo "/home/deploy/sites/klz-cables.com" ;;
*) echo "/home/deploy/sites/branch.klz-cables.com/$1" ;;
esac
}
get_project_name() {
case "$1" in
testing) echo "klz-testing" ;;
staging) echo "klz-staging" ;;
prod|production) echo "klz-cablescom" ;;
*) echo "klz-branch-$1" ;;
esac
}
TGT_PATH=$(get_target_path "$TARGET_ENV")
PROJECT_NAME=$(get_project_name "$TARGET_ENV")
QDRANT_CONTAINER="${PROJECT_NAME}-klz-qdrant-1"
WORK_DIR=$(mktemp -d)
echo "🚀 Syncing Qdrant Collection '$COLLECTION' to: $TARGET_ENV"
# 1. Create Snapshot Locally
echo "📸 1/5 Creating snapshot on local Qdrant ($LOCAL_QDRANT_URL)..."
SNAPSHOT_INFO=$(curl --max-time $TIMEOUT -s -X POST "$LOCAL_QDRANT_URL/collections/$COLLECTION/snapshots")
if ! echo "$SNAPSHOT_INFO" | grep -q '"status":"ok"'; then
echo "❌ Failed to create snapshot."
echo "Response: $SNAPSHOT_INFO"
exit 1
fi
SNAPSHOT_NAME=$(echo "$SNAPSHOT_INFO" | grep -o '"name":"[^"]*' | cut -d'"' -f4)
echo " ✅ Snapshot created: $SNAPSHOT_NAME"
# 2. Download Snapshot
echo "⬇️ 2/5 Downloading snapshot..."
curl --max-time $TIMEOUT -s -o "$WORK_DIR/$SNAPSHOT_NAME" "$LOCAL_QDRANT_URL/collections/$COLLECTION/snapshots/$SNAPSHOT_NAME"
echo " ✅ Downloaded to $WORK_DIR/$SNAPSHOT_NAME"
# 3. Compress and Transfer Snapshot
echo "📦 3/6 Compressing snapshot to save bandwidth..."
gzip -c "$WORK_DIR/$SNAPSHOT_NAME" > "$WORK_DIR/$SNAPSHOT_NAME.gz"
echo " ✅ Compressed $SNAPSHOT_NAME.gz"
echo "📤 4/6 Uploading compressed snapshot to Alpha ($SSH_HOST)..."
SSH_OPTS="-o ServerAliveInterval=60 -o ServerAliveCountMax=10 -o ConnectTimeout=30"
ssh $SSH_OPTS "$SSH_HOST" "mkdir -p $TGT_PATH/qdrant_tmp"
rsync --partial --progress --timeout=600 -e "ssh $SSH_OPTS" \
"$WORK_DIR/$SNAPSHOT_NAME.gz" "$SSH_HOST:$TGT_PATH/qdrant_tmp/$SNAPSHOT_NAME.gz"
echo " ✅ Upload complete."
# 4. Restore Snapshot on Remote Server
echo "🔄 5/6 Restoring snapshot on target container ($QDRANT_CONTAINER)..."
# Qdrant restore process:
# - Extract snapshot on server
# - Recreate collection (so it is clean)
# - Download snapshot to container
# - Recover from snapshot file
ssh $SSH_OPTS "$SSH_HOST" << EOF
set -e
# Step A: Extract the compressed file
echo " [Remote] Extracting snapshot..."
gunzip -f "$TGT_PATH/qdrant_tmp/$SNAPSHOT_NAME.gz"
# Step B: Copy file into the container
docker cp "$TGT_PATH/qdrant_tmp/$SNAPSHOT_NAME" $QDRANT_CONTAINER:/qdrant/$SNAPSHOT_NAME
# Step C: Delete existing collection
curl -s -X DELETE "http://127.0.0.1:6333/collections/$COLLECTION" > /dev/null
# Step D: Re-create empty collection (required before recovery)
# wir nutzen die standard vector config vom Kabelfachmann (Cosine, 384 dim für all-MiniLM-L6-v2)
curl -s -X PUT "http://127.0.0.1:6333/collections/$COLLECTION" \
-H 'Content-Type: application/json' \
-d '{ "vectors": { "size": 384, "distance": "Cosine" } }' > /dev/null
# Step E: Recover
echo " [Remote] Triggering recover API..."
curl -s -X PUT "http://127.0.0.1:6333/collections/$COLLECTION/snapshots/recover" \
-H 'Content-Type: application/json' \
-d '{ "location": "file:///qdrant/'$SNAPSHOT_NAME'" }' > /dev/null
# Step F: Cleanup
docker exec $QDRANT_CONTAINER rm /qdrant/$SNAPSHOT_NAME
rm -rf "$TGT_PATH/qdrant_tmp"
EOF
echo " ✅ Restore complete."
# 5. Local Cleanup
echo "🧹 6/6 Cleaning up..."
rm -rf "$WORK_DIR"
# Delete snapshot from local Qdrant server to save space
curl -s -X DELETE "$LOCAL_QDRANT_URL/collections/$COLLECTION/snapshots/$SNAPSHOT_NAME" > /dev/null
echo " ✅ Local cleanup done."
echo ""
echo "🎉 Successfully synced Qdrant collection '$COLLECTION' to $TARGET_ENV!"

View File

@@ -16,7 +16,7 @@ export const qdrant = new QdrantClient({
});
export const COLLECTION_NAME = 'klz_products';
export const VECTOR_SIZE = 1536; // OpenAI text-embedding-3-small
export const VECTOR_SIZE = 1024; // Mistral mistral-embed
// Cache TTLs
const EMBEDDING_CACHE_TTL = 60 * 60 * 24; // 24h — embeddings are deterministic
@@ -50,26 +50,15 @@ export async function ensureCollection() {
}
/**
* Simple hash for cache keys
* Hash text for cache key
*/
function hashKey(text: string): string {
let hash = 0;
for (let i = 0; i < text.length; i++) {
const chr = text.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0;
}
return hash.toString(36);
const { createHash } = require('crypto');
return createHash('sha256').update(text).digest('hex').slice(0, 32);
}
/**
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy).
* Results are cached in Redis for 24h since embeddings are deterministic.
*
* NOTE: We keep OpenRouter for embeddings because the Qdrant collection uses 1536-dim
* vectors (OpenAI text-embedding-3-small). Switching to Mistral embed (1024-dim) would
* require re-indexing the entire product catalog.
* User-facing chat uses Mistral AI directly for DSGVO compliance.
* Generate embedding using Mistral API (EU/DSGVO-compliant)
*/
export async function generateEmbedding(text: string): Promise<number[]> {
const cacheKey = `emb:${hashKey(text.toLowerCase().trim())}`;
@@ -84,22 +73,20 @@ export async function generateEmbedding(text: string): Promise<number[]> {
// Redis down — proceed without cache
}
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (!openRouterKey) {
throw new Error('OPENROUTER_API_KEY is not set');
const mistralKey = process.env.MISTRAL_API_KEY;
if (!mistralKey) {
throw new Error('MISTRAL_API_KEY is not set');
}
const response = await fetch('https://openrouter.ai/api/v1/embeddings', {
const response = await fetch('https://api.mistral.ai/v1/embeddings', {
method: 'POST',
headers: {
Authorization: `Bearer ${openRouterKey}`,
Authorization: `Bearer ${mistralKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
'X-Title': 'KLZ Cables Search AI',
},
body: JSON.stringify({
model: 'openai/text-embedding-3-small',
input: text,
model: 'mistral-embed',
input: [text],
}),
});

View File

@@ -26,6 +26,66 @@ export const Pages: CollectionConfig = {
};
},
},
hooks: {
afterChange: [
async ({ doc, req }) => {
// Run index sync asynchronously to not block the CMS save operation
setTimeout(async () => {
try {
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
// Check if page is published
if (doc._status !== 'published') {
await deleteProductVector(`page_${doc.id}`);
req.payload.logger.info(`Removed drafted page ${doc.slug} from Qdrant`);
} else {
// Serialize payload
const contentText = [
`Seite: ${doc.title}`,
doc.excerpt ? `Beschreibung: ${doc.excerpt}` : '',
]
.filter(Boolean)
.join('\n');
const payload = {
type: 'knowledge',
content: contentText,
data: {
title: doc.title,
slug: doc.slug,
},
};
await upsertProductVector(`page_${doc.id}`, contentText, payload);
req.payload.logger.info(`Upserted page ${doc.slug} to Qdrant`);
}
} catch (error) {
req.payload.logger.error({
msg: 'Error syncing page to Qdrant',
err: error,
pageId: doc.id,
});
}
}, 0);
return doc;
},
],
afterDelete: [
async ({ id, req }) => {
try {
const { deleteProductVector } = await import('../../lib/qdrant');
await deleteProductVector(`page_${id}`);
req.payload.logger.info(`Deleted page ${id} from Qdrant`);
} catch (error) {
req.payload.logger.error({
msg: 'Error deleting page from Qdrant',
err: error,
pageId: id,
});
}
},
],
},
fields: [
{
name: 'title',

View File

@@ -45,6 +45,67 @@ export const Posts: CollectionConfig = {
};
},
},
hooks: {
afterChange: [
async ({ doc, req }) => {
// Run index sync asynchronously to not block the CMS save operation
setTimeout(async () => {
try {
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
// Check if post is published
if (doc._status !== 'published') {
await deleteProductVector(`post_${doc.id}`);
req.payload.logger.info(`Removed drafted post ${doc.slug} from Qdrant`);
} else {
// Serialize payload
const contentText = [
`Blog-Artikel: ${doc.title}`,
doc.excerpt ? `Zusammenfassung: ${doc.excerpt}` : '',
doc.category ? `Kategorie: ${doc.category}` : '',
]
.filter(Boolean)
.join('\n');
const payload = {
type: 'knowledge',
content: contentText,
data: {
title: doc.title,
slug: doc.slug,
},
};
await upsertProductVector(`post_${doc.id}`, contentText, payload);
req.payload.logger.info(`Upserted post ${doc.slug} to Qdrant`);
}
} catch (error) {
req.payload.logger.error({
msg: 'Error syncing post to Qdrant',
err: error,
postId: doc.id,
});
}
}, 0);
return doc;
},
],
afterDelete: [
async ({ id, req }) => {
try {
const { deleteProductVector } = await import('../../lib/qdrant');
await deleteProductVector(`post_${id}`);
req.payload.logger.info(`Deleted post ${id} from Qdrant`);
} catch (error) {
req.payload.logger.error({
msg: 'Error deleting post from Qdrant',
err: error,
postId: id,
});
}
},
],
},
fields: [
{
name: 'title',

BIN
src/scripts/error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import 'dotenv/config';
// Override Qdrant URL for local script execution outside docker
process.env.QDRANT_URL = process.env.QDRANT_URL || 'http://localhost:6333';