Compare commits
2 Commits
v2.3.9
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
| 32361403d0 | |||
| e6225ba6fd |
38
.env
Normal file
38
.env
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Application
|
||||||
|
NODE_ENV=production
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||||
|
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
|
||||||
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||||
|
LOG_LEVEL=info
|
||||||
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||||
|
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||||
|
|
||||||
|
# SMTP Configuration
|
||||||
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USERNAME=postmaster@mg.mintel.me
|
||||||
|
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
|
||||||
|
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
||||||
|
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Payload Infrastructure (Dockerized)
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
|
||||||
|
# by docker-compose.yml using these base DB credentials, so you don't need to
|
||||||
|
# manually write the connection strings here.
|
||||||
|
PAYLOAD_DB_NAME=payload
|
||||||
|
PAYLOAD_DB_USER=payload
|
||||||
|
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Hetzner S3 Object Storage
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||||
|
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS
|
||||||
|
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3
|
||||||
|
S3_BUCKET=mintel
|
||||||
|
S3_REGION=fsn1
|
||||||
|
S3_PREFIX=klz-cables
|
||||||
111
.env.bak
111
.env.bak
@@ -1,111 +0,0 @@
|
|||||||
# ============================================================================
|
|
||||||
# KLZ Cables - Environment Configuration
|
|
||||||
# ============================================================================
|
|
||||||
# Copy this file to .env for local development
|
|
||||||
# For production, use .env.production as a template
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Application Configuration
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
NODE_ENV=development
|
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
|
||||||
# TARGET is used to differentiate between environments (testing, staging, production)
|
|
||||||
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
|
||||||
TARGET=development
|
|
||||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
|
||||||
NEXT_PUBLIC_RECORD_MODE_ENABLED=true
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Analytics (Umami)
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Optional: Leave empty to disable analytics
|
|
||||||
UMAMI_WEBSITE_ID=
|
|
||||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Optional: Leave empty to disable error tracking
|
|
||||||
SENTRY_DSN=
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Email Configuration (SMTP)
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Required for contact form functionality
|
|
||||||
MAIL_HOST=smtp.eu.mailgun.org
|
|
||||||
MAIL_PORT=587
|
|
||||||
MAIL_USERNAME=
|
|
||||||
MAIL_PASSWORD=
|
|
||||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
|
||||||
MAIL_RECIPIENTS=info@klz-cables.com
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Logging
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
LOG_LEVEL=info
|
|
||||||
GATEKEEPER_PASSWORD=klz2026
|
|
||||||
SENTRY_DSN=
|
|
||||||
# SENTRY_ENVIRONMENT is set automatically by CI
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# AI Agent (Payload CMS Agent via OpenRouter)
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Required for the Payload CMS AI Chat Agent
|
|
||||||
MISTRAL_API_KEY=
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Payload Infrastructure (Dockerized)
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
|
|
||||||
# by docker-compose.yml using these base DB credentials, so you don't need to
|
|
||||||
# manually write the connection strings here.
|
|
||||||
PAYLOAD_DB_NAME=payload
|
|
||||||
PAYLOAD_DB_USER=payload
|
|
||||||
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Deployment Configuration (CI/CD only)
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# These are typically set by the CI/CD workflow
|
|
||||||
IMAGE_TAG=latest
|
|
||||||
TRAEFIK_HOST=klz-cables.com
|
|
||||||
ENV_FILE=.env
|
|
||||||
# IMGPROXY_URL: The backend URL of the imgproxy instance (e.g. img.infra.mintel.me)
|
|
||||||
# Next.js will proxy requests from /_img to this URL.
|
|
||||||
IMGPROXY_URL=https://img.infra.mintel.me
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Varnish Configuration
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
VARNISH_CACHE_SIZE=256M
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# IMPORTANT NOTES
|
|
||||||
# ============================================================================
|
|
||||||
#
|
|
||||||
# BUILD-TIME vs RUNTIME Variables:
|
|
||||||
# ─────────────────────────────────
|
|
||||||
# • NEXT_PUBLIC_* variables are baked into the client bundle at BUILD time
|
|
||||||
# They must be provided as --build-arg when building the Docker image
|
|
||||||
#
|
|
||||||
# • All other variables are used at RUNTIME only
|
|
||||||
# They are loaded from the .env file by docker-compose
|
|
||||||
#
|
|
||||||
# Docker Deployment:
|
|
||||||
# ──────────────────
|
|
||||||
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
|
|
||||||
# 2. Runtime: All vars are loaded from .env file on the server
|
|
||||||
# 3. Branch Deployments:
|
|
||||||
# - main branch uses .env.prod
|
|
||||||
# - staging branch uses .env.staging
|
|
||||||
# - CI/CD supports STAGING_ prefix for all secrets to override defaults
|
|
||||||
# - TRAEFIK_HOST is automatically derived from NEXT_PUBLIC_BASE_URL
|
|
||||||
#
|
|
||||||
# Security:
|
|
||||||
# ─────────
|
|
||||||
# • NEVER commit .env files with real credentials to git
|
|
||||||
# • Use Gitea/GitHub secrets for CI/CD workflows
|
|
||||||
# • Store production .env file securely on the server only
|
|
||||||
#
|
|
||||||
# ============================================================================
|
|
||||||
@@ -48,12 +48,6 @@ GATEKEEPER_PASSWORD=klz2026
|
|||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
# SENTRY_ENVIRONMENT is set automatically by CI
|
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# AI Agent (Payload CMS Agent via OpenRouter)
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Required for the Payload CMS AI Chat Agent
|
|
||||||
MISTRAL_API_KEY=
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Payload Infrastructure (Dockerized)
|
# Payload Infrastructure (Dockerized)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -121,23 +121,16 @@ jobs:
|
|||||||
# ⏳ Wait for Upstream Packages/Images if Tagged
|
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
echo "🔎 Checking for @mintel dependencies in package.json..."
|
echo "🔎 Checking for @mintel dependencies in package.json..."
|
||||||
# Extract @mintel versions, cleaning caret/tilde, and filtering out workspace refs
|
# Extract any @mintel/ version (they should be synced in monorepo)
|
||||||
VERSIONS=$(grep "@mintel/" package.json | sed -E 's/.*: "[^0-9]*([0-9]+\.[0-9]+\.[0-9]+).*/\1/' | sort -u)
|
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
|
||||||
echo "Found @mintel versions in package.json: $VERSIONS"
|
|
||||||
|
|
||||||
UPSTREAM_VERSION=$(echo "$VERSIONS" | sort -V | tail -1)
|
|
||||||
|
|
||||||
if [[ -n "$UPSTREAM_VERSION" ]]; then
|
|
||||||
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||||
echo "🔎 Verifying tag $TAG_TO_WAIT in mmintel/at-mintel..."
|
|
||||||
|
|
||||||
|
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||||
# 1. Discovery (Works without token for public repositories)
|
# 1. Discovery (Works without token for public repositories)
|
||||||
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
||||||
|
|
||||||
if [[ -z "$UPSTREAM_SHA" ]]; then
|
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||||
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||||
echo "Full @mintel dependencies in package.json:"
|
|
||||||
grep "@mintel/" package.json
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||||
@@ -278,11 +271,9 @@ jobs:
|
|||||||
|
|
||||||
# Search & AI
|
# Search & AI
|
||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
|
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_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
|
||||||
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
|
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
|
||||||
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
|
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)
|
# Container Registry (standalone)
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
|
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
|
||||||
@@ -346,11 +337,9 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "# Search & AI"
|
echo "# Search & AI"
|
||||||
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
|
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
|
||||||
echo "MISTRAL_API_KEY=$MISTRAL_API_KEY"
|
|
||||||
echo "QDRANT_URL=$QDRANT_URL"
|
echo "QDRANT_URL=$QDRANT_URL"
|
||||||
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
|
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
|
||||||
echo "REDIS_URL=$REDIS_URL"
|
echo "REDIS_URL=$REDIS_URL"
|
||||||
echo "KABELFACHMANN_MCP_URL=$KABELFACHMANN_MCP_URL"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "TARGET=$TARGET"
|
echo "TARGET=$TARGET"
|
||||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -28,9 +28,3 @@ html-errors*.json
|
|||||||
reference/
|
reference/
|
||||||
# Database backups
|
# Database backups
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Payload CMS auto-generated
|
|
||||||
# Knowledge base source files
|
|
||||||
kabelhandbuch.txt
|
|
||||||
1
.npmrc
1
.npmrc
@@ -1 +1,2 @@
|
|||||||
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||||
|
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ ENV CI=true
|
|||||||
|
|
||||||
# Copy lockfile and manifest for dependency installation caching
|
# Copy lockfile and manifest for dependency installation caching
|
||||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||||
COPY patches* ./patches/
|
|
||||||
|
|
||||||
# Configure private registry and install dependencies
|
# Configure private registry and install dependencies
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
@@ -38,11 +37,6 @@ FROM base AS development
|
|||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
CMD ["pnpm", "dev:local"]
|
CMD ["pnpm", "dev:local"]
|
||||||
|
|
||||||
# Stage: Migrator
|
|
||||||
FROM base AS migrator
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
CMD ["pnpm", "cms:migrate"]
|
|
||||||
|
|
||||||
# Build application
|
# Build application
|
||||||
# Stage 3: Builder (Production)
|
# Stage 3: Builder (Production)
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install essential build tools if needed (e.g., for node-gyp)
|
# Install essential build tools if needed (e.g., for node-gyp)
|
||||||
RUN apk add --no-cache libc6-compat python3 make g++ curl
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +1,57 @@
|
|||||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
import { RscEntryLexicalField as RscEntryLexicalField_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 { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_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 { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { UploadFeatureClient as UploadFeatureClient_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 { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { RelationshipFeatureClient as RelationshipFeatureClient_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 { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { ChecklistFeatureClient as ChecklistFeatureClient_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 { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_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 { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { AlignFeatureClient as AlignFeatureClient_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 { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { ParagraphFeatureClient as ParagraphFeatureClient_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 { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_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 { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_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 { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { BoldFeatureClient as BoldFeatureClient_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 { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon';
|
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon'
|
||||||
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo';
|
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'
|
||||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc';
|
|
||||||
|
|
||||||
export const importMap = {
|
export const importMap = {
|
||||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
|
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
|
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#UploadFeatureClient':
|
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#BlockquoteFeatureClient':
|
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#RelationshipFeatureClient':
|
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#ChecklistFeatureClient':
|
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#OrderedListFeatureClient':
|
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#UnorderedListFeatureClient':
|
"/src/payload/components/Icon#default": default_9ed509b5e5f7d08a16335393f27586cc,
|
||||||
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"/src/payload/components/Logo#default": default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
|
||||||
'@payloadcms/richtext-lexical/client#IndentFeatureClient':
|
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
export async function requestBrochureAction(formData: FormData) {
|
export async function requestBrochureAction(formData: FormData) {
|
||||||
const services = getServerAppServices();
|
const services = getServerAppServices();
|
||||||
@@ -76,7 +77,8 @@ export async function requestBrochureAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Send Brochure via Email
|
// 3. Send Brochure via Email
|
||||||
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
|
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||||
|
const brochureUrl = `${baseUrl}/brochure/klz-product-catalog-${locale}.pdf`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { sendEmail } = await import('@/lib/mail/mailer');
|
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
import { NextResponse, NextRequest } from 'next/server'; // Added NextRequest
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Config and constants
|
|
||||||
const RATE_LIMIT_POINTS = 20; // 20 requests per minute
|
|
||||||
const RATE_LIMIT_DURATION = 60; // 1 minute window
|
|
||||||
const DAILY_BUDGET_LIMIT = 200; // max 200 requests per IP per day
|
|
||||||
const DAILY_BUDGET_DURATION = 60 * 60 * 24; // 24h
|
|
||||||
const MAX_CONVERSATION_MESSAGES = 20; // max messages in context
|
|
||||||
const MAX_RESPONSE_TOKENS = 300; // cap AI response length — keeps it chat-like
|
|
||||||
|
|
||||||
// Removed requestSchema as it's replaced by direct parsing
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
// Changed req type to NextRequest
|
|
||||||
try {
|
|
||||||
let body: any;
|
|
||||||
try {
|
|
||||||
body = await req.json();
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
|
||||||
}
|
|
||||||
const { messages, honeypot } = body;
|
|
||||||
|
|
||||||
// Get client IP for rate limiting
|
|
||||||
const forwarded = req.headers.get('x-forwarded-for');
|
|
||||||
const clientIp = forwarded?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown';
|
|
||||||
|
|
||||||
// 1. Basic Validation
|
|
||||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
||||||
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestMessage = messages[messages.length - 1].content;
|
|
||||||
const isBot = honeypot && honeypot.length > 0;
|
|
||||||
|
|
||||||
// Check if the input itself is obviously spam/too long
|
|
||||||
if (latestMessage.length > 500) {
|
|
||||||
return NextResponse.json({ error: 'Message too long' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Honeypot check
|
|
||||||
if (isBot) {
|
|
||||||
console.warn('Honeypot triggered in AI search');
|
|
||||||
// Tarpit the bot
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
return NextResponse.json({
|
|
||||||
answerText: 'Vielen Dank für Ihre Anfrage.',
|
|
||||||
products: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Rate Limiting via Redis (IP-based)
|
|
||||||
try {
|
|
||||||
// Per-minute burst limit
|
|
||||||
const minuteKey = `ai_rate:${clientIp}:min`;
|
|
||||||
const minuteCount = await redis.incr(minuteKey);
|
|
||||||
if (minuteCount === 1) await redis.expire(minuteKey, RATE_LIMIT_DURATION);
|
|
||||||
|
|
||||||
if (minuteCount > RATE_LIMIT_POINTS) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Zu viele Anfragen. Bitte warte einen Moment.' },
|
|
||||||
{ status: 429 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Daily budget limit
|
|
||||||
const dayKey = `ai_rate:${clientIp}:day`;
|
|
||||||
const dayCount = await redis.incr(dayKey);
|
|
||||||
if (dayCount === 1) await redis.expire(dayKey, DAILY_BUDGET_DURATION);
|
|
||||||
|
|
||||||
if (dayCount > DAILY_BUDGET_LIMIT) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Tägliches Limit erreicht. Bitte versuche es morgen erneut.' },
|
|
||||||
{ status: 429 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (redisError) {
|
|
||||||
console.error('Redis Rate Limiting Error:', redisError);
|
|
||||||
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
|
|
||||||
// Fail open if Redis is down
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Cap conversation length to limit token usage
|
|
||||||
const cappedMessages = messages.slice(-MAX_CONVERSATION_MESSAGES);
|
|
||||||
|
|
||||||
// 4. Fetch Context from Qdrant based on the latest message
|
|
||||||
let contextStr = '';
|
|
||||||
let foundProducts: any[] = [];
|
|
||||||
|
|
||||||
// Team context — hardcoded from translation data (no Payload collection for team)
|
|
||||||
const teamContextStr = `
|
|
||||||
Das ECHTE KLZ Team:
|
|
||||||
- Michael Bodemer (Geschäftsführer) — Der Macher, packt an wenn es kompliziert wird, kennt Kabelnetze in- und auswendig
|
|
||||||
- Klaus Mintel (Geschäftsführer) — Der Fels in der Brandung, jahrzehntelange Erfahrung, stabiles Netzwerk`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const searchResults = await searchProducts(latestMessage, 5);
|
|
||||||
|
|
||||||
if (searchResults && searchResults.length > 0) {
|
|
||||||
const productDescriptions = searchResults
|
|
||||||
.filter((p) => p.payload?.type === 'product' || !p.payload?.type)
|
|
||||||
.map((p: any) => p.payload?.content)
|
|
||||||
.join('\n\n');
|
|
||||||
|
|
||||||
if (productDescriptions) {
|
|
||||||
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
foundProducts = searchResults
|
|
||||||
.filter((p) => (p.payload?.type === 'product' || !p.payload?.type) && p.payload?.data)
|
|
||||||
.map((p: any) => ({
|
|
||||||
id: p.id as string,
|
|
||||||
title: p.payload?.data?.title as string,
|
|
||||||
sku: p.payload?.data?.sku as string,
|
|
||||||
slug: p.payload?.data?.slug as string,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (searchError) {
|
|
||||||
console.error('Qdrant Search Error:', searchError);
|
|
||||||
Sentry.captureException(searchError, { tags: { context: 'ai-search-qdrant' } });
|
|
||||||
// We can still proceed without context if Qdrant fails
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Generate AI Response via OpenRouter (Mistral for DSGVO)
|
|
||||||
const systemPrompt = `Du bist "Ohm" — der digitale KI-Berater von KLZ Cables. Dein Name ist eine Anspielung auf die Einheit des elektrischen Widerstands.
|
|
||||||
|
|
||||||
STIL & PERSÖNLICHKEIT:
|
|
||||||
- Antworte KURZ, KNAPP und PROFESSIONELL (maximal 2-3 Sätze).
|
|
||||||
- Schreibe wie in einem lockeren, aber kompetenten B2B-Chat (Du-Form ist okay, aber fachlich top).
|
|
||||||
- Kein Markdown, nur Fließtext.
|
|
||||||
- NIEMALS Platzhalter wie [Ihr Name], [Name], [Firma] verwenden.
|
|
||||||
|
|
||||||
DEINE HAUPTAUFGABE: BERATEN, NICHT AUSFRAGEN!
|
|
||||||
- Wenn der Kunde ein Projekt nennt (z.B. "Windpark 30kV"), dann lies im KONTEXT nach, welche Kabel passen, und EMPFIEHL SIE DIREKT! (z.B. "Für 30kV Windparks nehmen wir meistens NA2XS(F)2Y.").
|
|
||||||
- Stelle NIEMALS mehr als EINE Rückfrage pro Nachricht.
|
|
||||||
- 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 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.
|
|
||||||
- Keine Preise oder genauen Lieferzeiten versprechen. Immer auf die menschlichen Kollegen verweisen für finale Angebote.
|
|
||||||
|
|
||||||
KONTEXT KABEL & TEAM:
|
|
||||||
${contextStr || 'Kein Katalogkontext verfügbar.'}
|
|
||||||
${teamContextStr}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY;
|
|
||||||
if (!openrouterApiKey) {
|
|
||||||
throw new Error('OPENROUTER_API_KEY is not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
const openrouter = createOpenAI({
|
|
||||||
baseURL: 'https://openrouter.ai/api/v1',
|
|
||||||
apiKey: openrouterApiKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
mcpTools = tools;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to load MCP tools', e);
|
|
||||||
Sentry.captureException(e, { tags: { context: 'ai-search-mcp' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
|
||||||
answerText: text,
|
|
||||||
products: foundProducts,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('AI Search API Error:', error);
|
|
||||||
Sentry.captureException(error, { tags: { context: 'ai-search-api' } });
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Ein interner Fehler ist aufgetreten. Bitte versuche es erneut.' },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
import { renderToStream } from '@react-pdf/renderer';
|
||||||
|
import React from 'react';
|
||||||
|
import { PDFPage } from '@/lib/pdf-page';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
try {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
// Get Payload App
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
// Fetch the page
|
||||||
|
const pages = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: {
|
||||||
|
slug: { equals: slug },
|
||||||
|
_status: { equals: 'published' },
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pages.totalDocs === 0) {
|
||||||
|
return new NextResponse('Page not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pages.docs[0];
|
||||||
|
|
||||||
|
// Determine locale from searchParams or default to 'de'
|
||||||
|
const searchParams = req.nextUrl.searchParams;
|
||||||
|
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
|
||||||
|
|
||||||
|
// Render the React-PDF document into a stream
|
||||||
|
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
|
||||||
|
|
||||||
|
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
stream.on('data', (chunk) => controller.enqueue(chunk));
|
||||||
|
stream.on('end', () => controller.close());
|
||||||
|
stream.on('error', (err) => controller.error(err));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
(stream as any).destroy?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `${slug}.pdf`;
|
||||||
|
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
// Cache control if needed, skip for now.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating PDF:', error);
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getPayload } from 'payload';
|
|
||||||
import configPromise from '../../../payload.config';
|
|
||||||
import { upsertProductVector } from '../../../src/lib/qdrant';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
export const maxDuration = 120;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal endpoint called by the warmup script on every dev boot.
|
|
||||||
* Syncs posts, pages, and products from Payload CMS into Qdrant.
|
|
||||||
* NOT for form entries, media, or users.
|
|
||||||
*/
|
|
||||||
export async function GET() {
|
|
||||||
const results = { products: 0, posts: 0, pages: 0, errors: [] as string[] };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await getPayload({ config: configPromise });
|
|
||||||
|
|
||||||
// ── Products ──
|
|
||||||
const { docs: products } = await payload.find({
|
|
||||||
collection: 'products',
|
|
||||||
limit: 1000,
|
|
||||||
depth: 0,
|
|
||||||
where: { _status: { equals: 'published' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const product of products) {
|
|
||||||
try {
|
|
||||||
const contentText = `${product.title} - SKU: ${product.sku}\n${product.description || ''}`;
|
|
||||||
await upsertProductVector(String(product.id), contentText, {
|
|
||||||
type: 'product',
|
|
||||||
data: {
|
|
||||||
title: product.title,
|
|
||||||
sku: product.sku,
|
|
||||||
slug: product.slug,
|
|
||||||
description: product.description,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
results.products++;
|
|
||||||
} catch (e: any) {
|
|
||||||
results.errors.push(`product:${product.sku}: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Posts ──
|
|
||||||
const { docs: posts } = await payload.find({
|
|
||||||
collection: 'posts',
|
|
||||||
limit: 1000,
|
|
||||||
depth: 0,
|
|
||||||
where: { _status: { equals: 'published' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const post of posts) {
|
|
||||||
try {
|
|
||||||
const contentText = [
|
|
||||||
`Blog-Artikel: ${post.title}`,
|
|
||||||
post.excerpt ? `Zusammenfassung: ${post.excerpt}` : '',
|
|
||||||
post.category ? `Kategorie: ${post.category}` : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
await upsertProductVector(`post_${post.id}`, contentText, {
|
|
||||||
type: 'knowledge',
|
|
||||||
content: contentText,
|
|
||||||
data: {
|
|
||||||
title: post.title,
|
|
||||||
slug: post.slug,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
results.posts++;
|
|
||||||
} catch (e: any) {
|
|
||||||
results.errors.push(`post:${post.slug}: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pages ──
|
|
||||||
const { docs: pages } = await payload.find({
|
|
||||||
collection: 'pages',
|
|
||||||
limit: 1000,
|
|
||||||
depth: 0,
|
|
||||||
where: { _status: { equals: 'published' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const page of pages) {
|
|
||||||
try {
|
|
||||||
const contentText = [
|
|
||||||
`Seite: ${page.title}`,
|
|
||||||
page.excerpt ? `Beschreibung: ${page.excerpt}` : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
await upsertProductVector(`page_${page.id}`, contentText, {
|
|
||||||
type: 'knowledge',
|
|
||||||
content: contentText,
|
|
||||||
data: {
|
|
||||||
title: page.title,
|
|
||||||
slug: page.slug,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
results.pages++;
|
|
||||||
} catch (e: any) {
|
|
||||||
results.errors.push(`page:${page.slug}: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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, ${manualChunks} manual chunks synced`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
synced: {
|
|
||||||
products: results.products,
|
|
||||||
posts: results.posts,
|
|
||||||
pages: results.pages,
|
|
||||||
},
|
|
||||||
errors: results.errors.length > 0 ? results.errors : undefined,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[Qdrant Sync] ❌ Fatal error:', error);
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { ShieldCheck, Leaf, Lock, Accessibility, Zap } from 'lucide-react';
|
|
||||||
import { Container } from './ui';
|
import { Container } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
@@ -282,48 +281,6 @@ export default function Footer() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Brand & Quality Sub-Footer */}
|
|
||||||
<div className="pt-8 mt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6 text-white/40 text-[10px] sm:text-xs">
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://mintel.me"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={() =>
|
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
|
||||||
target: 'mintel_agency',
|
|
||||||
location: 'sub_footer',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="hover:text-white/80 transition-colors flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
Website entwickelt von Marc Mintel
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap justify-center md:justify-end gap-x-6 gap-y-3">
|
|
||||||
<div className="flex items-center gap-1.5" title="SSL Secured">
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
|
||||||
<span>SSL Secured</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="Green Hosting">
|
|
||||||
<Leaf className="w-3.5 h-3.5" />
|
|
||||||
<span>Green Hosting</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="DSGVO Compliant">
|
|
||||||
<Lock className="w-3.5 h-3.5" />
|
|
||||||
<span>DSGVO Compliant</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="WCAG">
|
|
||||||
<Accessibility className="w-3.5 h-3.5" />
|
|
||||||
<span>WCAG</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5" title="PageSpeed 90+">
|
|
||||||
<Zap className="w-3.5 h-3.5" />
|
|
||||||
<span>PageSpeed 90+</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
</Container>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { cn } from './ui';
|
import { cn } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
import { Search } from 'lucide-react';
|
|
||||||
import { AISearchResults } from './search/AISearchResults';
|
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const t = useTranslations('Navigation');
|
const t = useTranslations('Navigation');
|
||||||
@@ -18,7 +16,6 @@ export default function Header() {
|
|||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
||||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Extract locale from pathname
|
// Extract locale from pathname
|
||||||
@@ -278,19 +275,6 @@ export default function Header() {
|
|||||||
<div
|
<div
|
||||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||||
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsSearchOpen(true)}
|
|
||||||
className="hover:text-accent transition-colors p-2"
|
|
||||||
aria-label="Search"
|
|
||||||
>
|
|
||||||
<Search className="w-5 h-5 md:w-6 md:h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
|
||||||
style={{ animationDuration: '600ms', animationDelay: '800ms' }}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
@@ -485,8 +469,6 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AISearchResults isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,8 +121,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
|
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
|
||||||
<div
|
<div
|
||||||
id={`voltage-table-${idx}`}
|
id={`voltage-table-${idx}`}
|
||||||
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${
|
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<table className="min-w-full border-separate border-spacing-0">
|
<table className="min-w-full border-separate border-spacing-0">
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
|
|||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const formatted = formatTechnicalValue(item.value);
|
const formatted = formatTechnicalValue(item.value);
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
||||||
key={index}
|
|
||||||
className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,127 +1,37 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useAnalytics } from '../analytics/useAnalytics';
|
import { useAnalytics } from '../analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import { AISearchResults } from '../search/AISearchResults';
|
|
||||||
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
const AIOrb = dynamic(() => import('../search/AIOrb'), { ssr: false });
|
|
||||||
|
|
||||||
export default function Hero({ data }: { data?: any }) {
|
export default function Hero({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Home.hero');
|
const t = useTranslations('Home.hero');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
||||||
const [heroPlaceholder, setHeroPlaceholder] = useState(
|
|
||||||
'Projekt beschreiben oder Kabel suchen...',
|
|
||||||
);
|
|
||||||
const typingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const HERO_PLACEHOLDERS = [
|
|
||||||
'Querschnittsberechnung für 110kV Trasse', // Hochspannung
|
|
||||||
'Wie schwer ist NAYY 4x150?',
|
|
||||||
'Ich plane einen Solarpark, was brauche ich?', // Projekt Solar
|
|
||||||
'Unterschied zwischen N2XSY und NAY2XSY?', // Fach
|
|
||||||
'Mittelspannungskabel für Windkraftanlage', // Windpark
|
|
||||||
'Welches Aluminiumkabel für 20kV?', // Mittelspannung
|
|
||||||
];
|
|
||||||
|
|
||||||
// Typing animation for the hero search placeholder
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchQuery) {
|
|
||||||
setHeroPlaceholder('Projekt beschreiben oder Kabel suchen...');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let textIdx = 0;
|
|
||||||
let charIdx = 0;
|
|
||||||
let deleting = false;
|
|
||||||
|
|
||||||
const tick = () => {
|
|
||||||
const fullText = HERO_PLACEHOLDERS[textIdx];
|
|
||||||
|
|
||||||
if (deleting) {
|
|
||||||
charIdx--;
|
|
||||||
setHeroPlaceholder(fullText.substring(0, charIdx));
|
|
||||||
} else {
|
|
||||||
charIdx++;
|
|
||||||
setHeroPlaceholder(fullText.substring(0, charIdx));
|
|
||||||
}
|
|
||||||
|
|
||||||
let delay = deleting ? 30 : 70;
|
|
||||||
|
|
||||||
if (!deleting && charIdx === fullText.length) {
|
|
||||||
delay = 2500;
|
|
||||||
deleting = true;
|
|
||||||
} else if (deleting && charIdx === 0) {
|
|
||||||
deleting = false;
|
|
||||||
textIdx = (textIdx + 1) % HERO_PLACEHOLDERS.length;
|
|
||||||
delay = 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
typingRef.current = setTimeout(tick, delay);
|
|
||||||
};
|
|
||||||
|
|
||||||
typingRef.current = setTimeout(tick, 1500);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (typingRef.current) clearTimeout(typingRef.current);
|
|
||||||
};
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
setIsSearchOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||||
<div className="max-w-5xl mx-auto md:mx-0">
|
<div className="max-w-5xl mx-auto md:mx-0">
|
||||||
<div>
|
<div>
|
||||||
<Heading
|
<Heading
|
||||||
level={1}
|
level={1}
|
||||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
|
||||||
>
|
>
|
||||||
{data?.title ? (
|
{data?.title ? (
|
||||||
<span
|
<span
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: data.title
|
__html: data.title
|
||||||
.replace(
|
.replace(/<green>/g, '<span class="text-accent italic">')
|
||||||
/<green>/g,
|
.replace(/<\/green>/g, '</span>'),
|
||||||
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
/<\/green>/g,
|
|
||||||
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
t.rich('title', {
|
t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="relative z-10 text-accent italic inline-block">
|
|
||||||
{chunks}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
|
||||||
style={{ animationDelay: '500ms' }}
|
|
||||||
>
|
|
||||||
<Scribble variant="circle" />
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -131,39 +41,13 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
{data?.subtitle || t('subtitle')}
|
{data?.subtitle || t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form
|
|
||||||
onSubmit={handleSearchSubmit}
|
|
||||||
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent shadow-lg relative"
|
|
||||||
>
|
|
||||||
<div className="absolute left-1 w-20 h-20 flex items-center justify-center z-10 overflow-visible">
|
|
||||||
<AIOrb isThinking={false} />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder={heroPlaceholder}
|
|
||||||
className="flex-1 bg-transparent border-none text-white pl-20 pr-2 py-4 placeholder:text-white/50 focus:outline-none text-lg lg:text-xl"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="accent"
|
|
||||||
size="lg"
|
|
||||||
className="rounded-xl px-6 py-4 shrink-0 flex items-center shadow-md font-bold cursor-pointer hover:bg-accent hover:brightness-110"
|
|
||||||
>
|
|
||||||
Fragen
|
|
||||||
<ChevronRight className="w-5 h-5 ml-2 -mr-1" />
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
href="/contact"
|
href="/contact"
|
||||||
variant="white"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-all outline-none"
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
label: data?.ctaLabel || t('cta'),
|
label: data?.ctaLabel || t('cta'),
|
||||||
@@ -180,9 +64,9 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||||
variant="outline"
|
variant="white"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg text-white border-white/30 hover:bg-white/10 hover:border-white transition-all"
|
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
label: data?.secondaryCtaLabel || t('exploreProducts'),
|
label: data?.secondaryCtaLabel || t('exploreProducts'),
|
||||||
@@ -210,12 +94,5 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
<AISearchResults
|
|
||||||
isOpen={isSearchOpen}
|
|
||||||
onClose={() => setIsSearchOpen(false)}
|
|
||||||
initialQuery={searchQuery}
|
|
||||||
triggerSearch={true}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useRef, useEffect, useCallback } from 'react';
|
|
||||||
|
|
||||||
interface AIOrbProps {
|
|
||||||
isThinking: boolean;
|
|
||||||
hasError?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lerp(a: number, b: number, t: number) {
|
|
||||||
return a + (b - a) * t;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple noise function for organic movement
|
|
||||||
function noise(x: number, y: number, t: number): number {
|
|
||||||
return (
|
|
||||||
Math.sin(x * 1.3 + t * 0.7) * Math.cos(y * 0.9 + t * 0.5) * 0.5 +
|
|
||||||
Math.sin(x * 2.7 + y * 1.1 + t * 1.3) * 0.25 +
|
|
||||||
Math.cos(x * 0.8 - y * 2.3 + t * 0.9) * 0.25
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Particle ───────────────────────────────────────────────────
|
|
||||||
interface Particle {
|
|
||||||
// Sphere position (target shape)
|
|
||||||
theta: number;
|
|
||||||
phi: number;
|
|
||||||
// Current position
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
z: number;
|
|
||||||
// Velocity
|
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
vz: number;
|
|
||||||
// Properties
|
|
||||||
size: number;
|
|
||||||
baseSize: number;
|
|
||||||
hue: number; // 0=blue, 1=green
|
|
||||||
brightness: number;
|
|
||||||
phase: number;
|
|
||||||
orbitSpeed: number;
|
|
||||||
noiseScale: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createParticles(count: number): Particle[] {
|
|
||||||
const particles: Particle[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
// Fibonacci sphere distribution for even spacing
|
|
||||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
||||||
const y = 1 - (i / (count - 1)) * 2;
|
|
||||||
const radiusAtY = Math.sqrt(1 - y * y);
|
|
||||||
const theta = golden * i;
|
|
||||||
const phi = Math.acos(y);
|
|
||||||
|
|
||||||
particles.push({
|
|
||||||
theta,
|
|
||||||
phi,
|
|
||||||
x: Math.cos(theta) * radiusAtY,
|
|
||||||
y,
|
|
||||||
z: Math.sin(theta) * radiusAtY,
|
|
||||||
vx: 0,
|
|
||||||
vy: 0,
|
|
||||||
vz: 0,
|
|
||||||
size: 0.4 + Math.random() * 0.8,
|
|
||||||
baseSize: 0.4 + Math.random() * 0.8,
|
|
||||||
hue: Math.random() > 0.45 ? 0 : 1,
|
|
||||||
brightness: 0.5 + Math.random() * 0.5,
|
|
||||||
phase: Math.random() * Math.PI * 2,
|
|
||||||
orbitSpeed: (0.1 + Math.random() * 0.4) * (Math.random() > 0.5 ? 1 : -1),
|
|
||||||
noiseScale: 0.5 + Math.random() * 1.5,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return particles;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AIOrb({ isThinking = false, hasError = false }: AIOrbProps) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
|
||||||
const animRef = useRef<number>(0);
|
|
||||||
const particlesRef = useRef<Particle[]>([]);
|
|
||||||
|
|
||||||
const mouse = useRef({ x: 0.5, y: 0.5, hover: false });
|
|
||||||
const state = useRef({
|
|
||||||
pulse: 0,
|
|
||||||
hover: 0,
|
|
||||||
error: 0,
|
|
||||||
mouseX: 0.5,
|
|
||||||
mouseY: 0.5,
|
|
||||||
rotY: 0,
|
|
||||||
rotX: 0,
|
|
||||||
breathe: 0,
|
|
||||||
scatter: 0,
|
|
||||||
shake: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onMove = useCallback((e: React.PointerEvent) => {
|
|
||||||
const r = wrapRef.current?.getBoundingClientRect();
|
|
||||||
if (!r) return;
|
|
||||||
mouse.current.x = (e.clientX - r.left) / r.width;
|
|
||||||
mouse.current.y = (e.clientY - r.top) / r.height;
|
|
||||||
}, []);
|
|
||||||
const onEnter = useCallback(() => {
|
|
||||||
mouse.current.hover = true;
|
|
||||||
}, []);
|
|
||||||
const onLeave = useCallback(() => {
|
|
||||||
mouse.current.hover = false;
|
|
||||||
mouse.current.x = 0.5;
|
|
||||||
mouse.current.y = 0.5;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const draw = useCallback(
|
|
||||||
function drawStep() {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const w = rect.width * dpr;
|
|
||||||
const h = rect.height * dpr;
|
|
||||||
if (canvas.width !== w || canvas.height !== h) {
|
|
||||||
canvas.width = w;
|
|
||||||
canvas.height = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cx = w / 2;
|
|
||||||
const cy = h / 2;
|
|
||||||
const minDim = Math.min(w, h);
|
|
||||||
// Reduced further to give maximum breathing room for glow + movement
|
|
||||||
const sphereR = minDim * 0.16;
|
|
||||||
const time = performance.now() / 1000;
|
|
||||||
const s = state.current;
|
|
||||||
const m = mouse.current;
|
|
||||||
|
|
||||||
// ── Interpolate state ──
|
|
||||||
s.pulse = lerp(s.pulse, isThinking ? 1 : 0, 0.03);
|
|
||||||
s.hover = lerp(s.hover, m.hover ? 1 : 0, 0.12);
|
|
||||||
s.error = lerp(s.error, hasError ? 1 : 0, 0.05);
|
|
||||||
s.mouseX = lerp(s.mouseX, m.x, 0.12);
|
|
||||||
s.mouseY = lerp(s.mouseY, m.y, 0.12);
|
|
||||||
s.scatter = lerp(s.scatter, m.hover ? 0.8 : hasError ? 0.5 : 0, 0.06);
|
|
||||||
s.shake += 0.15 * s.error;
|
|
||||||
|
|
||||||
// Global rotation — ALWAYS rotating + ALWAYS facing cursor
|
|
||||||
s.rotY += lerp(0.008, 0.04, Math.max(s.pulse, s.hover));
|
|
||||||
const mouseRotY = (s.mouseX - 0.5) * 1.2; // always face cursor
|
|
||||||
const mouseRotX = (s.mouseY - 0.5) * 0.8;
|
|
||||||
|
|
||||||
s.breathe += lerp(1.2, 3.0, s.pulse) / 60;
|
|
||||||
const breathe = Math.sin(s.breathe) * 0.5 + 0.5;
|
|
||||||
|
|
||||||
// ── Clear ──
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
|
|
||||||
// ── Subtle core glow ──
|
|
||||||
const shakeX = Math.sin(s.shake * 17) * s.error * minDim * 0.02;
|
|
||||||
const glowCX = cx + shakeX;
|
|
||||||
const glowCY = cy;
|
|
||||||
// Clamp glow radius so it never exceeds ~48% of canvas (leaves padding for movement)
|
|
||||||
const glowR = Math.min(
|
|
||||||
sphereR * lerp(2.2, 4.0, Math.max(s.pulse, s.hover * 0.8)),
|
|
||||||
minDim * 0.48,
|
|
||||||
);
|
|
||||||
const glowA = lerp(0.1, 0.4, Math.max(s.pulse, s.hover * 0.7, s.error * 0.8));
|
|
||||||
const glow = ctx.createRadialGradient(glowCX, glowCY, 0, glowCX, glowCY, glowR);
|
|
||||||
// Glow color: blue normally, red on error
|
|
||||||
const glowR1 = Math.round(lerp(20, 255, s.error));
|
|
||||||
const glowG1 = Math.round(lerp(60, 40, s.error));
|
|
||||||
const glowB1 = Math.round(lerp(255, 40, s.error));
|
|
||||||
glow.addColorStop(0, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 2})`);
|
|
||||||
glow.addColorStop(
|
|
||||||
0.25,
|
|
||||||
`rgba(${Math.round(lerp(80, 200, s.error))}, ${Math.round(lerp(140, 50, s.error))}, ${Math.round(lerp(255, 50, s.error))}, ${glowA * 1.2})`,
|
|
||||||
);
|
|
||||||
glow.addColorStop(0.6, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 0.4})`);
|
|
||||||
glow.addColorStop(1, `rgba(${glowR1}, ${glowG1}, ${glowB1}, 0)`);
|
|
||||||
ctx.fillStyle = glow;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(glowCX, glowCY, glowR, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// ── Create particles if empty ──
|
|
||||||
if (particlesRef.current.length === 0) {
|
|
||||||
particlesRef.current = createParticles(350);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Update & draw particles ──
|
|
||||||
const cosRY = Math.cos(s.rotY + mouseRotY);
|
|
||||||
const sinRY = Math.sin(s.rotY + mouseRotY);
|
|
||||||
const cosRX = Math.cos(mouseRotX);
|
|
||||||
const sinRX = Math.sin(mouseRotX);
|
|
||||||
|
|
||||||
// Sort by z for correct layering
|
|
||||||
type ParticleWithScreen = { p: Particle; sx: number; sy: number; sz: number; depth: number };
|
|
||||||
const projected: ParticleWithScreen[] = [];
|
|
||||||
|
|
||||||
for (const p of particlesRef.current) {
|
|
||||||
// Target position: sphere surface + noise displacement
|
|
||||||
const n = noise(p.theta * p.noiseScale, p.phi * p.noiseScale, time * 0.5 + p.phase);
|
|
||||||
const displacement = 1 + n * lerp(0.12, 0.3, s.pulse);
|
|
||||||
|
|
||||||
// Orbit: rotate theta — always moving, faster idle
|
|
||||||
const activeTheta = p.theta + time * p.orbitSpeed * lerp(0.35, 0.8, s.pulse);
|
|
||||||
|
|
||||||
// Sphere coordinates to cartesian
|
|
||||||
const sinPhi = Math.sin(p.phi);
|
|
||||||
const tgtX = Math.cos(activeTheta) * sinPhi * displacement;
|
|
||||||
// Excitement from hover + pulse + error
|
|
||||||
const targetExcite = Math.max(s.hover * 0.9, s.pulse, s.error * 0.8);
|
|
||||||
const tgtY = Math.cos(p.phi) * displacement;
|
|
||||||
const tgtZ = Math.sin(activeTheta) * sinPhi * displacement;
|
|
||||||
|
|
||||||
// Scatter on hover: push particles outward
|
|
||||||
const scatterMul = 1 + s.scatter * (0.5 + n * 0.5);
|
|
||||||
|
|
||||||
// Spring physics toward target
|
|
||||||
const tx = tgtX * scatterMul;
|
|
||||||
const ty = tgtY * scatterMul;
|
|
||||||
const tz = tgtZ * scatterMul;
|
|
||||||
|
|
||||||
p.vx += (tx - p.x) * 0.08;
|
|
||||||
p.vy += (ty - p.y) * 0.08;
|
|
||||||
p.vz += (tz - p.z) * 0.08;
|
|
||||||
p.vx *= 0.88;
|
|
||||||
p.vy *= 0.88;
|
|
||||||
p.vz *= 0.88;
|
|
||||||
p.x += p.vx;
|
|
||||||
p.y += p.vy;
|
|
||||||
p.z += p.vz;
|
|
||||||
|
|
||||||
// 3D rotation (Y then X)
|
|
||||||
const rx = p.x * cosRY - p.z * sinRY;
|
|
||||||
const rz = p.x * sinRY + p.z * cosRY;
|
|
||||||
const ry = p.y * cosRX - rz * sinRX;
|
|
||||||
const finalZ = p.y * sinRX + rz * cosRX;
|
|
||||||
|
|
||||||
// Project to screen
|
|
||||||
const perspective = 3;
|
|
||||||
const scale = perspective / (perspective + finalZ);
|
|
||||||
const sx = cx + rx * sphereR * scale;
|
|
||||||
const sy = cy + ry * sphereR * scale;
|
|
||||||
|
|
||||||
projected.push({ p, sx, sy, sz: finalZ, depth: scale });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort back-to-front
|
|
||||||
projected.sort((a, b) => a.sz - b.sz);
|
|
||||||
|
|
||||||
for (const { p, sx, sy, sz, depth } of projected) {
|
|
||||||
// Depth-based alpha and size
|
|
||||||
const depthAlpha = 0.25 + (sz + 1) * 0.375; // 0.25 (back) → 1.0 (front)
|
|
||||||
const twinkle = 0.75 + 0.25 * Math.sin(time * 3.5 + p.phase);
|
|
||||||
|
|
||||||
const alpha =
|
|
||||||
depthAlpha * twinkle * p.brightness * lerp(0.8, 1.3, Math.max(s.pulse, s.hover * 0.8));
|
|
||||||
|
|
||||||
const drawSize =
|
|
||||||
p.baseSize * depth * dpr * lerp(1.0, 2.0, Math.max(s.pulse, s.hover * 0.7));
|
|
||||||
|
|
||||||
// Color — shift to red on error
|
|
||||||
let r: number, g: number, b: number;
|
|
||||||
if (s.error > 0.1) {
|
|
||||||
// Error: red family
|
|
||||||
if (p.hue === 0) {
|
|
||||||
r = Math.round(lerp(40 + sz * 30, 255, s.error));
|
|
||||||
g = Math.round(lerp(80 + sz * 40, 40 + sz * 20, s.error));
|
|
||||||
b = Math.round(lerp(255, 40, s.error));
|
|
||||||
} else {
|
|
||||||
r = Math.round(lerp(100 + sz * 30, 230, s.error));
|
|
||||||
g = Math.round(lerp(220 + sz * 17, 60, s.error));
|
|
||||||
b = Math.round(lerp(20, 20, s.error));
|
|
||||||
}
|
|
||||||
} else if (p.hue === 0) {
|
|
||||||
r = 60 + Math.round(sz * 40);
|
|
||||||
g = 100 + Math.round(sz * 50);
|
|
||||||
b = 255;
|
|
||||||
} else {
|
|
||||||
r = 120 + Math.round(sz * 30);
|
|
||||||
g = 237 + Math.round(sz * 10);
|
|
||||||
b = 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thinking: shift toward brighter, more saturated
|
|
||||||
if (s.pulse > 0.1) {
|
|
||||||
r = Math.round(lerp(r, p.hue === 0 ? 100 : 130, s.pulse * 0.3));
|
|
||||||
g = Math.round(lerp(g, p.hue === 0 ? 140 : 237, s.pulse * 0.3));
|
|
||||||
b = Math.round(lerp(b, p.hue === 0 ? 255 : 32, s.pulse * 0.3));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Micro glow — always visible, stronger on front
|
|
||||||
if (depthAlpha > 0.25) {
|
|
||||||
const gSize = drawSize * lerp(4, 7, s.hover);
|
|
||||||
const pg = ctx.createRadialGradient(sx, sy, 0, sx, sy, gSize);
|
|
||||||
pg.addColorStop(0, `rgba(${r},${g},${b},${alpha * 0.5})`);
|
|
||||||
pg.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
|
||||||
ctx.fillStyle = pg;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(sx, sy, gSize, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Core dot — bright
|
|
||||||
ctx.fillStyle = `rgba(${Math.min(r + 40, 255)},${Math.min(g + 30, 255)},${b},${Math.min(alpha * 1.6, 1)})`;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(sx, sy, Math.max(drawSize * 0.5, 0.3 * dpr), 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Loading rings (thinking) ──
|
|
||||||
if (s.pulse > 0.02) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(cx, cy);
|
|
||||||
|
|
||||||
// Spinning arc
|
|
||||||
const spinAngle = time * 2;
|
|
||||||
const arcLen = Math.PI * lerp(0.3, 1.0, (Math.sin(time * 1.5) + 1) / 2);
|
|
||||||
ctx.rotate(spinAngle);
|
|
||||||
ctx.strokeStyle = `rgba(130, 237, 32, ${s.pulse * 0.4})`;
|
|
||||||
ctx.lineWidth = 1.2 * dpr;
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, 0, sphereR * 1.25, 0, arcLen);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Counter-spinning arc
|
|
||||||
ctx.rotate(-spinAngle * 2);
|
|
||||||
ctx.strokeStyle = `rgba(1, 29, 255, ${s.pulse * 0.3})`;
|
|
||||||
ctx.lineWidth = 0.8 * dpr;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, 0, sphereR * 1.35, 0, arcLen * 0.6);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Expanding pulse
|
|
||||||
const pulsePhase = (time * 0.8) % 1;
|
|
||||||
const pulseR = sphereR * (1 + pulsePhase * 1.5);
|
|
||||||
const pulseA = s.pulse * (1 - pulsePhase) * 0.15;
|
|
||||||
ctx.strokeStyle = `rgba(130, 237, 32, ${pulseA})`;
|
|
||||||
ctx.lineWidth = 1 * dpr;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, pulseR, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
animRef.current = requestAnimationFrame(drawStep);
|
|
||||||
},
|
|
||||||
[isThinking, hasError],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
animRef.current = requestAnimationFrame(draw);
|
|
||||||
return () => cancelAnimationFrame(animRef.current);
|
|
||||||
}, [draw]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={wrapRef}
|
|
||||||
className="w-full h-full relative overflow-visible"
|
|
||||||
onPointerMove={onMove}
|
|
||||||
onPointerEnter={onEnter}
|
|
||||||
onPointerLeave={onLeave}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<canvas ref={canvasRef} className="w-full h-full block" style={{ imageRendering: 'auto' }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,646 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
|
||||||
import { ArrowUp, X, Sparkles, ChevronRight, RotateCcw, Copy, Check } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useAnalytics } from '../analytics/useAnalytics';
|
|
||||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
const AIOrb = dynamic(() => import('./AIOrb'), { ssr: false });
|
|
||||||
|
|
||||||
const LOADING_TEXTS = [
|
|
||||||
'Durchsuche das Kabelhandbuch... 📖',
|
|
||||||
'Frage den Senior-Ingenieur... 👴🔧',
|
|
||||||
'Frage ChatGPTs Cousin 2. Grades... 🤖',
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ProductMatch {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
sku: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
role: 'user' | 'assistant';
|
|
||||||
content: string;
|
|
||||||
products?: ProductMatch[];
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
interface ComponentProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
initialQuery?: string;
|
|
||||||
triggerSearch?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AISearchResults({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
initialQuery = '',
|
|
||||||
triggerSearch = false,
|
|
||||||
}: ComponentProps) {
|
|
||||||
const { trackEvent } = useAnalytics();
|
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [honeypot, setHoneypot] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
|
||||||
const [copiedAll, setCopiedAll] = useState(false);
|
|
||||||
const [loadingText, setLoadingText] = useState(LOADING_TEXTS[0]);
|
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const loadingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const hasTriggeredRef = useRef(false);
|
|
||||||
|
|
||||||
// Dedicated focus effect — polls until the input actually has focus
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
let attempts = 0;
|
|
||||||
const focusTimer = setInterval(() => {
|
|
||||||
const el = inputRef.current;
|
|
||||||
if (el && document.activeElement !== el) {
|
|
||||||
el.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
attempts++;
|
|
||||||
if (attempts >= 15 || document.activeElement === el) {
|
|
||||||
clearInterval(focusTimer);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
return () => clearInterval(focusTimer);
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
// Trigger initial search only once
|
|
||||||
if (triggerSearch && initialQuery && !hasTriggeredRef.current) {
|
|
||||||
hasTriggeredRef.current = true;
|
|
||||||
handleSearch(initialQuery);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = 'unset';
|
|
||||||
setQuery('');
|
|
||||||
setMessages([]);
|
|
||||||
setError(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
hasTriggeredRef.current = false;
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = 'unset';
|
|
||||||
};
|
|
||||||
}, [isOpen, triggerSearch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}, [messages, isLoading]);
|
|
||||||
|
|
||||||
// Global ESC handler
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
const handleEsc = (e: globalThis.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const activeElement = document.activeElement;
|
|
||||||
const isInputFocused = activeElement === inputRef.current;
|
|
||||||
|
|
||||||
if (query.trim()) {
|
|
||||||
// If there's text, clear it but keep chat open
|
|
||||||
setQuery('');
|
|
||||||
inputRef.current?.focus();
|
|
||||||
} else if (!isInputFocused) {
|
|
||||||
// If no text and input is not focused, focus it
|
|
||||||
inputRef.current?.focus();
|
|
||||||
} else {
|
|
||||||
// If no text and input IS focused, close the chat
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleEsc);
|
|
||||||
return () => document.removeEventListener('keydown', handleEsc);
|
|
||||||
}, [isOpen, onClose, query]);
|
|
||||||
|
|
||||||
const handleSearch = async (searchQuery: string = query) => {
|
|
||||||
if (!searchQuery.trim() || isLoading) return;
|
|
||||||
|
|
||||||
const newUserMessage: Message = { role: 'user', content: searchQuery, timestamp: Date.now() };
|
|
||||||
const newMessagesContext = [...messages, newUserMessage];
|
|
||||||
|
|
||||||
setMessages(newMessagesContext);
|
|
||||||
setQuery(''); // Always clear input after send
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Give the user message animation 400ms to arrive before showing "thinking"
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLoading(true);
|
|
||||||
// Start rotating loading texts
|
|
||||||
let textIdx = Math.floor(Math.random() * LOADING_TEXTS.length);
|
|
||||||
setLoadingText(LOADING_TEXTS[textIdx]);
|
|
||||||
loadingIntervalRef.current = setInterval(() => {
|
|
||||||
textIdx = (textIdx + 1) % LOADING_TEXTS.length;
|
|
||||||
setLoadingText(LOADING_TEXTS[textIdx]);
|
|
||||||
}, 2500);
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
|
|
||||||
type: 'ai_chat',
|
|
||||||
query: searchQuery,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 60000);
|
|
||||||
|
|
||||||
const res = await fetch('/api/ai-search', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
signal: controller.signal,
|
|
||||||
body: JSON.stringify({
|
|
||||||
messages: newMessagesContext,
|
|
||||||
_honeypot: honeypot,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
|
|
||||||
if (!res.ok || !data) {
|
|
||||||
throw new Error(data?.error || `Server antwortete mit Status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: data.answerText,
|
|
||||||
products: data.products,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 100);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
const msg =
|
|
||||||
err.name === 'AbortError'
|
|
||||||
? 'Anfrage hat zu lange gedauert. Bitte versuche es erneut.'
|
|
||||||
: err.message || 'Ein Fehler ist aufgetreten.';
|
|
||||||
|
|
||||||
// Show error as a system message in the chat instead of a separate error banner
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: `⚠️ ${msg}`,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
trackEvent(AnalyticsEvents.ERROR, {
|
|
||||||
location: 'ai_chat',
|
|
||||||
message: err.message,
|
|
||||||
query: searchQuery,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
if (loadingIntervalRef.current) {
|
|
||||||
clearInterval(loadingIntervalRef.current);
|
|
||||||
loadingIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
// Always re-focus the input
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 50);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSearch();
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowUp' && !query) {
|
|
||||||
// Find the last user message and put it into the input
|
|
||||||
const lastUserNav = [...messages].reverse().find((m) => m.role === 'user');
|
|
||||||
if (lastUserNav) {
|
|
||||||
e.preventDefault();
|
|
||||||
setQuery(lastUserNav.content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = (content: string, index?: number) => {
|
|
||||||
navigator.clipboard.writeText(content);
|
|
||||||
if (index !== undefined) {
|
|
||||||
setCopiedIndex(index);
|
|
||||||
setTimeout(() => setCopiedIndex(null), 2000);
|
|
||||||
} else {
|
|
||||||
setCopiedAll(true);
|
|
||||||
setTimeout(() => setCopiedAll(false), 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyChat = () => {
|
|
||||||
const fullChat = messages
|
|
||||||
.map((m) => `${m.role === 'user' ? 'Du' : 'Ohm'}:\n${m.content}`)
|
|
||||||
.join('\n\n');
|
|
||||||
handleCopy(fullChat);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[100] flex items-start justify-center pt-6 md:pt-12 px-4"
|
|
||||||
onClick={handleBackdropClick}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
style={{ animation: 'chatBackdropIn 0.4s ease-out forwards' }}
|
|
||||||
>
|
|
||||||
{/* Animated backdrop */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-[#000a18]/90 backdrop-blur-2xl"
|
|
||||||
style={{ animation: 'chatFadeIn 0.3s ease-out' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={modalRef}
|
|
||||||
className="relative w-full max-w-3xl flex flex-col"
|
|
||||||
style={{
|
|
||||||
height: 'min(90vh, 900px)',
|
|
||||||
animation: 'chatSlideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* ── Glassmorphism container ── */}
|
|
||||||
<div className="flex flex-col h-full rounded-3xl overflow-hidden border border-white/[0.08] bg-gradient-to-b from-white/[0.06] to-white/[0.02] shadow-[0_32px_64px_-12px_rgba(0,0,0,0.6)]">
|
|
||||||
{/* ── Header ── */}
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 overflow-hidden rounded-full">
|
|
||||||
<AIOrb isThinking={isLoading} hasError={!!error} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-white font-bold text-sm tracking-wide">Ohm</h2>
|
|
||||||
<p className="text-[10px] text-white/30 font-medium tracking-wider uppercase">
|
|
||||||
{isLoading ? 'Denkt nach...' : error ? 'Fehler aufgetreten' : 'Online'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{messages.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={handleCopyChat}
|
|
||||||
className="flex items-center gap-1.5 text-[10px] font-bold text-white/40 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-full px-3 py-1.5 cursor-pointer uppercase tracking-wider"
|
|
||||||
title="gesamten Chat kopieren"
|
|
||||||
>
|
|
||||||
{copiedAll ? (
|
|
||||||
<Check className="w-3.5 h-3.5 text-accent" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
<span>{copiedAll ? 'Kopiert' : 'Chat kopieren'}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-white/30 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-xl p-2 cursor-pointer"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Chat Area ── */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-6 space-y-5 scroll-smooth chat-scrollbar">
|
|
||||||
{/* Empty state */}
|
|
||||||
{messages.length === 0 && !isLoading && !error && (
|
|
||||||
<div
|
|
||||||
className="flex flex-col items-center justify-center h-full text-center space-y-5"
|
|
||||||
style={{ animation: 'chatFadeIn 0.6s ease-out 0.3s both' }}
|
|
||||||
>
|
|
||||||
<div className="w-24 h-24 mb-2">
|
|
||||||
<AIOrb isThinking={false} hasError={false} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xl md:text-2xl font-bold text-white/80">
|
|
||||||
Wie kann ich helfen?
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-white/30 mt-2 max-w-md">
|
|
||||||
Beschreibe dein Projekt, frag nach bestimmten Kabeln, oder nenne mir deine
|
|
||||||
Anforderungen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/* Quick prompts */}
|
|
||||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
|
||||||
{['Windpark 33kV Verkabelung', 'NYCWY 4x185', 'Erdkabel für Solarpark'].map(
|
|
||||||
(prompt) => (
|
|
||||||
<button
|
|
||||||
key={prompt}
|
|
||||||
onClick={() => handleSearch(prompt)}
|
|
||||||
className="text-xs text-white/40 hover:text-white/80 border border-white/10 hover:border-white/20 hover:bg-white/5 rounded-full px-4 py-2 transition-all duration-200 cursor-pointer"
|
|
||||||
>
|
|
||||||
{prompt}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
{messages.map((msg, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
|
||||||
style={{
|
|
||||||
animation: `chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) ${index * 0.05}s both`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`relative group max-w-[85%] rounded-2xl px-5 py-4 ${
|
|
||||||
msg.role === 'user'
|
|
||||||
? 'bg-accent text-primary font-semibold rounded-br-lg'
|
|
||||||
: 'bg-white/[0.05] border border-white/[0.06] text-white/90 rounded-bl-lg'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Copy Button */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopy(msg.content, index)}
|
|
||||||
className={`absolute opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-lg cursor-pointer ${
|
|
||||||
msg.role === 'user'
|
|
||||||
? 'top-2 right-2 bg-primary/10 hover:bg-primary/20 text-primary/60 hover:text-primary'
|
|
||||||
: 'top-2 right-2 bg-white/5 hover:bg-white/10 text-white/40 hover:text-white'
|
|
||||||
}`}
|
|
||||||
title="Nachricht kopieren"
|
|
||||||
>
|
|
||||||
{copiedIndex === index ? (
|
|
||||||
<Check className="w-3.5 h-3.5" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{msg.role === 'assistant' && (
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Sparkles className="w-3 h-3 text-accent/60" />
|
|
||||||
<span className="text-[10px] font-bold tracking-widest uppercase text-accent/50">
|
|
||||||
Ohm
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`text-sm md:text-[15px] leading-relaxed ${
|
|
||||||
msg.role === 'assistant'
|
|
||||||
? 'prose prose-invert prose-sm prose-p:leading-relaxed prose-a:text-accent prose-strong:text-accent/90 prose-ul:list-disc prose-ol:list-decimal'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{msg.role === 'assistant' ? (
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
|
||||||
) : (
|
|
||||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timestamp */}
|
|
||||||
{!msg.products?.length && (
|
|
||||||
<p
|
|
||||||
className={`text-[9px] mt-2 font-medium tracking-wide ${msg.role === 'user' ? 'text-primary/40' : 'text-white/20'}`}
|
|
||||||
>
|
|
||||||
{new Date(msg.timestamp).toLocaleTimeString('de', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Product cards */}
|
|
||||||
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
|
|
||||||
<div className="mt-4 space-y-2 border-t border-white/[0.06] pt-4">
|
|
||||||
<h4 className="text-[10px] font-bold tracking-widest uppercase text-white/30 mb-2">
|
|
||||||
Empfohlene Produkte
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
||||||
{msg.products.map((product, idx) => (
|
|
||||||
<Link
|
|
||||||
key={idx}
|
|
||||||
href={`/produkte/${product.slug}`}
|
|
||||||
onClick={() => {
|
|
||||||
onClose();
|
|
||||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
|
||||||
target: product.slug,
|
|
||||||
location: 'ai_chat',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="group flex items-center justify-between bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.06] hover:border-accent/30 rounded-xl px-4 py-3 transition-all duration-300"
|
|
||||||
style={{ animation: `chatFadeIn 0.3s ease-out ${idx * 0.1}s both` }}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[9px] font-bold text-white/25 tracking-wider">
|
|
||||||
{product.sku}
|
|
||||||
</p>
|
|
||||||
<h5 className="text-xs font-bold text-white/70 group-hover:text-accent truncate transition-colors">
|
|
||||||
{product.title}
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="w-3.5 h-3.5 text-white/20 group-hover:text-accent shrink-0 ml-3 group-hover:translate-x-0.5 transition-all" />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Loading indicator */}
|
|
||||||
{isLoading && (
|
|
||||||
<div
|
|
||||||
className="flex justify-start"
|
|
||||||
style={{ animation: 'chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 bg-white/[0.03] border border-white/[0.06] rounded-2xl rounded-bl-lg px-5 py-4">
|
|
||||||
<div className="w-10 h-10 shrink-0">
|
|
||||||
<AIOrb isThinking={true} hasError={false} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
className="text-sm text-white/50 font-medium"
|
|
||||||
style={{ animation: 'chatTextSwap 0.4s ease-out' }}
|
|
||||||
key={loadingText}
|
|
||||||
>
|
|
||||||
{loadingText}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-1 mt-2">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="w-1.5 h-1.5 rounded-full bg-accent/40"
|
|
||||||
style={{
|
|
||||||
animation: 'chatDotBounce 1.2s ease-in-out infinite',
|
|
||||||
animationDelay: `${i * 0.15}s`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="flex justify-start" style={{ animation: 'chatShake 0.5s ease-out' }}>
|
|
||||||
<div className="flex items-center gap-4 bg-red-500/[0.06] border border-red-500/20 rounded-2xl rounded-bl-lg px-5 py-4">
|
|
||||||
<div className="w-10 h-10 shrink-0">
|
|
||||||
<AIOrb isThinking={false} hasError={true} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-bold text-red-300">Da ist was schiefgelaufen 😬</h3>
|
|
||||||
<p className="text-xs text-red-300/60 mt-1">{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setError(null);
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1.5 text-[10px] font-bold text-red-300/50 hover:text-red-300 mt-2 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3 h-3" />
|
|
||||||
Nochmal versuchen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Input Area ── */}
|
|
||||||
<div className="px-5 pb-5 pt-3 border-t border-white/[0.04]">
|
|
||||||
<div
|
|
||||||
className={`relative flex items-center rounded-2xl transition-all duration-300 ${
|
|
||||||
query.trim()
|
|
||||||
? 'bg-white/[0.08] border border-accent/30 shadow-[0_0_20px_-4px_rgba(130,237,32,0.1)]'
|
|
||||||
: 'bg-white/[0.04] border border-white/[0.06]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
placeholder="Nachricht eingeben..."
|
|
||||||
className="flex-1 bg-transparent border-none text-white text-sm md:text-base px-5 py-4 focus:outline-none placeholder:text-white/20"
|
|
||||||
disabled={isLoading}
|
|
||||||
tabIndex={1}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="hidden"
|
|
||||||
value={honeypot}
|
|
||||||
onChange={(e) => setHoneypot(e.target.value)}
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSearch()}
|
|
||||||
disabled={!query.trim() || isLoading}
|
|
||||||
className={`mr-2 w-9 h-9 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300 cursor-pointer ${
|
|
||||||
query.trim()
|
|
||||||
? 'bg-accent text-primary shadow-lg shadow-accent/20 hover:shadow-accent/40 hover:scale-105 active:scale-95'
|
|
||||||
: 'bg-white/5 text-white/20'
|
|
||||||
}`}
|
|
||||||
aria-label="Nachricht senden"
|
|
||||||
>
|
|
||||||
<ArrowUp className="w-4 h-4" strokeWidth={2.5} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center gap-3 mt-2.5">
|
|
||||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
|
||||||
Enter zum Senden · Esc zum Schließen
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
|
||||||
·
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-accent/40 flex items-center gap-1">
|
|
||||||
🛡️ DSGVO-konform · EU-Server
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Keyframe animations ── */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes chatBackdropIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes chatFadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes chatSlideUp {
|
|
||||||
from { opacity: 0; transform: translateY(40px) scale(0.96); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
@keyframes chatMessageIn {
|
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
@keyframes chatDotBounce {
|
|
||||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
|
|
||||||
40% { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes chatTextSwap {
|
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
@keyframes chatShake {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
15% { transform: translateX(-6px); }
|
|
||||||
30% { transform: translateX(5px); }
|
|
||||||
45% { transform: translateX(-4px); }
|
|
||||||
60% { transform: translateX(3px); }
|
|
||||||
75% { transform: translateX(-1px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
.chat-scrollbar::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
.chat-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.chat-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.chat-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.16);
|
|
||||||
}
|
|
||||||
.chat-scrollbar {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
- infra
|
- infra
|
||||||
labels:
|
labels:
|
||||||
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
||||||
- "caddy.reverse_proxy=http://klz-app:3000"
|
- "caddy.reverse_proxy=host.docker.internal:3100"
|
||||||
|
|
||||||
# Full Docker dev (use with `pnpm run dev:docker`)
|
# Full Docker dev (use with `pnpm run dev:docker`)
|
||||||
klz-app:
|
klz-app:
|
||||||
@@ -26,20 +26,13 @@ services:
|
|||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
# Force Garbage Collection before Docker kills the container (OOM)
|
|
||||||
NODE_OPTIONS: "--max-old-space-size=6144"
|
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
||||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
|
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
|
||||||
UV_THREADPOOL_SIZE: "1"
|
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||||
RAYON_NUM_THREADS: "1"
|
UV_THREADPOOL_SIZE: "4"
|
||||||
NEXT_PRIVATE_WORKER_THREADS: "false"
|
|
||||||
NPM_TOKEN: ${NPM_TOKEN:-}
|
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||||
CI: "true"
|
CI: "true"
|
||||||
QDRANT_URL: "http://klz-qdrant:6333"
|
|
||||||
REDIS_URL: "redis://klz-redis:6379"
|
|
||||||
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
|
|
||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- klz_node_modules:/app/node_modules
|
- klz_node_modules:/app/node_modules
|
||||||
@@ -49,34 +42,19 @@ services:
|
|||||||
- /app/.git
|
- /app/.git
|
||||||
- /app/reference
|
- /app/reference
|
||||||
- /app/data
|
- /app/data
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
cpus: '4'
|
||||||
memory: 8G
|
memory: 8G
|
||||||
command: >
|
command: >
|
||||||
sh -c "pnpm install --no-frozen-lockfile &&
|
sh -c "pnpm install && pnpm next dev --webpack --hostname 0.0.0.0"
|
||||||
while true; do
|
|
||||||
(
|
|
||||||
echo '[warmup] Waiting for Next.js to be reachable...'
|
|
||||||
until curl -sf http://localhost:3000 > /dev/null; do sleep 2; done
|
|
||||||
echo '[warmup] Server is up! Pre-compiling routes...'
|
|
||||||
curl -sf http://localhost:3000/de > /dev/null 2>&1 && echo '[warmup] /de ready'
|
|
||||||
curl -sf http://localhost:3000/api/health/cms > /dev/null 2>&1 && echo '[warmup] /api/health/cms ready'
|
|
||||||
curl -sf -X POST -H 'Content-Type: application/json' -d '{\"messages\":[{\"role\":\"user\",\"content\":\"warmup\"}]}' http://localhost:3000/api/ai-search > /dev/null 2>&1 && echo '[warmup] /api/ai-search ready'
|
|
||||||
echo '[warmup] Syncing CMS data to Qdrant...'
|
|
||||||
SYNC_RESULT=$(curl -sf http://localhost:3000/api/sync-qdrant 2>&1)
|
|
||||||
echo \"[warmup] Qdrant sync: $SYNC_RESULT\"
|
|
||||||
echo '[warmup] All routes pre-compiled + Qdrant synced ✓'
|
|
||||||
) &
|
|
||||||
pnpm next dev --webpack --hostname 0.0.0.0;
|
|
||||||
echo '[klz-app] next dev exited, restarting in 2s...';
|
|
||||||
sleep 2;
|
|
||||||
done"
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
||||||
|
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||||
|
|
||||||
klz-db:
|
klz-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
@@ -97,36 +75,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "54322:5432"
|
- "54322:5432"
|
||||||
|
|
||||||
klz-redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
ports:
|
|
||||||
- "16379:6379"
|
|
||||||
|
|
||||||
klz-qdrant:
|
|
||||||
image: qdrant/qdrant:v1.13.2
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- 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:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||||
@@ -136,8 +84,6 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
klz_db_data:
|
klz_db_data:
|
||||||
external: false
|
external: false
|
||||||
klz_qdrant_data:
|
|
||||||
external: false
|
|
||||||
klz_node_modules:
|
klz_node_modules:
|
||||||
klz_next_cache:
|
klz_next_cache:
|
||||||
klz_turbo_cache:
|
klz_turbo_cache:
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
|
|
||||||
klz-gatekeeper:
|
klz-gatekeeper:
|
||||||
profiles: [ "gatekeeper" ]
|
profiles: [ "gatekeeper" ]
|
||||||
image: registry.infra.mintel.me/mintel/gatekeeper:testing
|
image: git.infra.mintel.me/mmintel/gatekeeper:v1.9.18
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
infra:
|
infra:
|
||||||
@@ -101,37 +101,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|
||||||
klz-redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
|
|
||||||
klz-qdrant:
|
|
||||||
image: qdrant/qdrant:v1.13.2
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "6333:6333"
|
|
||||||
environment:
|
|
||||||
QDRANT__SERVICE__HTTP_PORT: 6333
|
|
||||||
QDRANT__SERVICE__GRPC_PORT: 6334
|
|
||||||
volumes:
|
|
||||||
- klz_qdrant_data:/qdrant/storage
|
|
||||||
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:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||||
@@ -143,7 +112,5 @@ volumes:
|
|||||||
external: false
|
external: false
|
||||||
klz_media_data:
|
klz_media_data:
|
||||||
external: false
|
external: false
|
||||||
klz_qdrant_data:
|
|
||||||
external: false
|
|
||||||
klz_datasheets:
|
klz_datasheets:
|
||||||
external: false
|
external: false
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ export default [
|
|||||||
"tests/**",
|
"tests/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"reference/**",
|
"reference/**",
|
||||||
"data/**",
|
"data/**"
|
||||||
"remotion/**",
|
|
||||||
"components/record-mode/**"
|
|
||||||
],
|
],
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,26 +51,12 @@ export default async function middleware(request: NextRequest) {
|
|||||||
const hostHeader = headers.get('x-forwarded-host') || headers.get('host') || fallbackHost;
|
const hostHeader = headers.get('x-forwarded-host') || headers.get('host') || fallbackHost;
|
||||||
|
|
||||||
urlObj.protocol = proto;
|
urlObj.protocol = proto;
|
||||||
// Do NOT reconstruct NextRequest for GET/HEAD requests to avoid stream locking/piping errors.
|
|
||||||
// next-intl middleware primarily uses the URL and headers, so a simplified request-like object
|
|
||||||
// or a redirected URL is often enough. For robustness, we only clone if we really have to.
|
|
||||||
if (method === 'GET' || method === 'HEAD') {
|
|
||||||
// In Next.js 15+, we can clone headers but avoid re-passing the body stream.
|
|
||||||
effectiveRequest = new NextRequest(urlObj, {
|
effectiveRequest = new NextRequest(urlObj, {
|
||||||
headers: new Headers(request.headers),
|
headers: request.headers,
|
||||||
method: request.method,
|
|
||||||
// No body for GET/HEAD
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// For POST/PUT etc, we must be careful. Re-passing the body can only be done once.
|
|
||||||
// If we don't need to fix the URL for the body-reading part, we can skip it.
|
|
||||||
// But usually intl-middleware doesn't care about the body.
|
|
||||||
effectiveRequest = new NextRequest(urlObj, {
|
|
||||||
headers: new Headers(request.headers),
|
|
||||||
method: request.method,
|
method: request.method,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -7,31 +7,20 @@ import { withPayload } from '@payloadcms/next/withPayload';
|
|||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
transpilePackages: ['react-image-crop', '@react-three/fiber', '@mintel/payload-ai'],
|
|
||||||
onDemandEntries: {
|
onDemandEntries: {
|
||||||
// Keep compiled pages/routes in memory for 5 minutes (reduced from 25m to prevent OOM)
|
// Make sure entries are not disposed too quickly
|
||||||
maxInactiveAge: 5 * 60 * 1000,
|
maxInactiveAge: 60 * 1000,
|
||||||
// Keep up to 2 pages in the dev buffer (reduced from 10 to prevent OOM)
|
|
||||||
pagesBufferLength: 2,
|
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: [
|
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
||||||
'lucide-react',
|
cpus: 3,
|
||||||
'framer-motion',
|
|
||||||
'@/components/ui',
|
|
||||||
'@sentry/nextjs',
|
|
||||||
'@payloadcms/richtext-lexical',
|
|
||||||
'react-hook-form',
|
|
||||||
'zod',
|
|
||||||
'date-fns',
|
|
||||||
],
|
|
||||||
workerThreads: false,
|
workerThreads: false,
|
||||||
memoryBasedWorkersCount: true,
|
|
||||||
},
|
},
|
||||||
serverActions: {
|
serverActions: {
|
||||||
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
|
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
|
||||||
},
|
},
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
|
swcMinify: true,
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
logging: {
|
logging: {
|
||||||
fetches: {
|
fetches: {
|
||||||
@@ -39,21 +28,6 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(isProd ? { output: 'standalone' } : {}),
|
...(isProd ? { output: 'standalone' } : {}),
|
||||||
// Prevent webpack from restarting when .env files are touched via Docker volume mount
|
|
||||||
webpack: (config, { dev }) => {
|
|
||||||
if (dev) {
|
|
||||||
config.watchOptions = {
|
|
||||||
...config.watchOptions,
|
|
||||||
ignored: /node_modules|\.env/,
|
|
||||||
// Reduce poll frequency to lower CPU churn from VirtioFS
|
|
||||||
poll: 1000,
|
|
||||||
aggregateTimeout: 300,
|
|
||||||
};
|
|
||||||
// Reduce source map quality in dev for faster rebuilds
|
|
||||||
config.devtool = 'eval';
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
// Rewrites moved to bottom merged function
|
// Rewrites moved to bottom merged function
|
||||||
async headers() {
|
async headers() {
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
@@ -77,7 +51,7 @@ const nextConfig = {
|
|||||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||||
font-src 'self' https://fonts.gstatic.com;
|
font-src 'self' https://fonts.gstatic.com;
|
||||||
img-src 'self' data: blob: ${extraImgDomains};
|
img-src 'self' data: blob: ${extraImgDomains};
|
||||||
connect-src 'self' ${umamiDomain} ${glitchtipDomain} https://raw.githack.com https://raw.githubusercontent.com;
|
connect-src 'self' ${umamiDomain} ${glitchtipDomain};
|
||||||
frame-src 'self';
|
frame-src 'self';
|
||||||
object-src 'none';
|
object-src 'none';
|
||||||
base-uri 'self';
|
base-uri 'self';
|
||||||
@@ -423,7 +397,7 @@ const nextConfig = {
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
qualities: [25, 50, 75, 100],
|
qualities: [75, 100],
|
||||||
formats: ['image/webp'],
|
formats: ['image/webp'],
|
||||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@@ -461,16 +435,16 @@ const nextConfig = {
|
|||||||
destination: '/api/datasheets/:path*',
|
destination: '/api/datasheets/:path*',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/:locale/brochures/:path*',
|
source: '/:locale/brochure/:path*',
|
||||||
destination: '/api/brochures/:path*',
|
destination: '/brochure/:path*',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/datasheets/:path*',
|
source: '/datasheets/:path*',
|
||||||
destination: '/api/datasheets/:path*',
|
destination: '/api/datasheets/:path*',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/brochures/:path*',
|
source: '/brochure/:path*',
|
||||||
destination: '/api/brochures/:path*',
|
destination: '/brochure/:path*',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/de/produkte',
|
source: '/de/produkte',
|
||||||
|
|||||||
38
package.json
38
package.json
@@ -4,26 +4,19 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.18.3",
|
"packageManager": "pnpm@10.18.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/google": "^3.0.31",
|
"@mintel/mail": "1.9.18",
|
||||||
"@ai-sdk/openai": "^3.0.36",
|
"@mintel/next-config": "1.9.18",
|
||||||
"@mintel/mail": "^1.9.17",
|
"@mintel/next-feedback": "1.9.18",
|
||||||
"@mintel/next-config": "^1.9.17",
|
"@mintel/next-utils": "1.9.18",
|
||||||
"@mintel/next-feedback": "^1.9.17",
|
|
||||||
"@mintel/next-utils": "^1.9.17",
|
|
||||||
"@mintel/payload-ai": "^1.9.17",
|
|
||||||
"@payloadcms/db-postgres": "^3.77.0",
|
"@payloadcms/db-postgres": "^3.77.0",
|
||||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||||
"@payloadcms/next": "^3.77.0",
|
"@payloadcms/next": "^3.77.0",
|
||||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||||
"@payloadcms/ui": "^3.77.0",
|
"@payloadcms/ui": "^3.77.0",
|
||||||
"@qdrant/js-client-rest": "^1.17.0",
|
|
||||||
"@react-email/components": "1.0.8",
|
"@react-email/components": "1.0.8",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"@react-three/drei": "^10.7.7",
|
|
||||||
"@react-three/fiber": "^9.5.0",
|
|
||||||
"@sentry/nextjs": "^10.39.0",
|
"@sentry/nextjs": "^10.39.0",
|
||||||
"@types/recharts": "^2.0.1",
|
"@types/recharts": "^2.0.1",
|
||||||
"ai": "^6.0.101",
|
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
@@ -31,7 +24,6 @@
|
|||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"i18next": "^25.7.3",
|
"i18next": "^25.7.3",
|
||||||
"import-in-the-middle": "^1.11.0",
|
"import-in-the-middle": "^1.11.0",
|
||||||
"ioredis": "^5.9.3",
|
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
@@ -46,17 +38,13 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-email": "^5.2.5",
|
"react-email": "^5.2.5",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"rehype-raw": "^7.0.0",
|
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"require-in-the-middle": "^8.0.1",
|
"require-in-the-middle": "^8.0.1",
|
||||||
"resend": "^3.5.0",
|
"resend": "^3.5.0",
|
||||||
"schema-dts": "^1.1.5",
|
"schema-dts": "^1.1.5",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"svg-to-pdfkit": "^0.1.8",
|
"svg-to-pdfkit": "^0.1.8",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"three": "^0.183.1",
|
|
||||||
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
@@ -65,8 +53,8 @@
|
|||||||
"@commitlint/config-conventional": "^20.4.0",
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
"@cspell/dict-de-de": "^4.1.2",
|
"@cspell/dict-de-de": "^4.1.2",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@mintel/eslint-config": "^1.9.17",
|
"@mintel/eslint-config": "1.9.18",
|
||||||
"@mintel/tsconfig": "^1.9.17",
|
"@mintel/tsconfig": "1.9.18",
|
||||||
"@next/bundle-analyzer": "^16.1.6",
|
"@next/bundle-analyzer": "^16.1.6",
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
@@ -92,7 +80,6 @@
|
|||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"pa11y-ci": "^4.0.1",
|
"pa11y-ci": "^4.0.1",
|
||||||
"pdf-parse": "^2.4.5",
|
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"puppeteer": "^24.37.3",
|
"puppeteer": "^24.37.3",
|
||||||
@@ -106,8 +93,8 @@
|
|||||||
"xlsx-cli": "^1.1.3"
|
"xlsx-cli": "^1.1.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bash -c '[ -f .env ] || (cp .env.example .env && sed -i.bak \"s/TRAEFIK_HOST=klz-cables.com/TRAEFIK_HOST=klz.localhost/\" .env && rm -f .env.bak && echo \"✅ Created .env from .env.example\"); trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db klz-proxy klz-qdrant klz-redis --remove-orphans'",
|
"dev": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db --remove-orphans'",
|
||||||
"dev:local": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy klz-qdrant klz-redis && POSTGRES_URI=NODE_ENV=development next dev --webpack --port 3100 --hostname 0.0.0.0'",
|
"dev:local": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy && POSTGRES_URI=NODE_ENV=development next dev --webpack --port 3100 --hostname 0.0.0.0'",
|
||||||
"dev:infra": "COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy",
|
"dev:infra": "COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
@@ -142,10 +129,6 @@
|
|||||||
"assets:pull:prod": "bash ./scripts/assets-sync.sh prod local",
|
"assets:pull:prod": "bash ./scripts/assets-sync.sh prod local",
|
||||||
"assets:sync:testing-to-staging": "bash ./scripts/assets-sync.sh testing staging",
|
"assets:sync:testing-to-staging": "bash ./scripts/assets-sync.sh testing staging",
|
||||||
"assets:sync:staging-to-prod": "bash ./scripts/assets-sync.sh staging prod",
|
"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:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
||||||
"pagespeed:audit": "./scripts/audit-local.sh",
|
"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')))\"",
|
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
||||||
@@ -160,7 +143,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.3.9",
|
"version": "2.2.12",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
@@ -172,9 +155,6 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"minimatch": ">=10.2.2"
|
"minimatch": ">=10.2.2"
|
||||||
},
|
|
||||||
"patchedDependencies": {
|
|
||||||
"@mintel/payload-ai@1.9.17": "patches/@mintel__payload-ai@1.9.15.patch"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
diff --git a/dist/endpoints/chatEndpoint.js b/dist/endpoints/chatEndpoint.js
|
|
||||||
index 85a4a2d1ab757e32a303d9f4b2462436af5e2398..23902b2a18eedb583d8a6ee7028c5e855177880b 100644
|
|
||||||
--- a/dist/endpoints/chatEndpoint.js
|
|
||||||
+++ b/dist/endpoints/chatEndpoint.js
|
|
||||||
@@ -49,7 +49,7 @@ export const handleMcpChat = async (req) => {
|
|
||||||
try {
|
|
||||||
const result = streamText({
|
|
||||||
// @ts-ignore - AI SDK type mismatch
|
|
||||||
- model: openrouter('google/gemini-3.0-flash'),
|
|
||||||
+ model: openrouter('google/gemini-3.1-flash-lite-preview'),
|
|
||||||
messages,
|
|
||||||
tools: activeTools,
|
|
||||||
system: `You are a helpful Payload CMS MCP Assistant orchestrating the local Mintel ecosystem.
|
|
||||||
@@ -23,13 +23,6 @@ import { Products } from './src/payload/collections/Products';
|
|||||||
import { Pages } from './src/payload/collections/Pages';
|
import { Pages } from './src/payload/collections/Pages';
|
||||||
import { seedDatabase } from './src/payload/seed';
|
import { seedDatabase } from './src/payload/seed';
|
||||||
|
|
||||||
const isMigrate = process.argv.includes('migrate');
|
|
||||||
let chatPlugin: any = null;
|
|
||||||
if (!isMigrate) {
|
|
||||||
const mod = await import('@mintel/payload-ai');
|
|
||||||
chatPlugin = mod.payloadChatPlugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url);
|
const filename = fileURLToPath(import.meta.url);
|
||||||
const dirname = path.dirname(filename);
|
const dirname = path.dirname(filename);
|
||||||
|
|
||||||
@@ -105,14 +98,5 @@ export default buildConfig({
|
|||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
sharp,
|
sharp,
|
||||||
plugins: [
|
plugins: [],
|
||||||
...(chatPlugin
|
|
||||||
? [
|
|
||||||
chatPlugin({
|
|
||||||
enabled: true,
|
|
||||||
mcpServers: [{ name: 'klz-qdrant-mcp' }],
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
5001
pnpm-lock.yaml
generated
5001
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,7 @@ async function main() {
|
|||||||
locale: 'en' as any,
|
locale: 'en' as any,
|
||||||
});
|
});
|
||||||
const doc = result.docs[0];
|
const doc = result.docs[0];
|
||||||
if (!doc) {
|
if (!doc) { console.log('No doc found'); process.exit(0); }
|
||||||
console.log('No doc found');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
console.log('--- doc.title:', doc.title);
|
console.log('--- doc.title:', doc.title);
|
||||||
|
|
||||||
if (doc.content?.root?.children) {
|
if (doc.content?.root?.children) {
|
||||||
@@ -24,32 +21,15 @@ async function main() {
|
|||||||
if (child.fields) {
|
if (child.fields) {
|
||||||
console.log(' fields keys:', Object.keys(child.fields));
|
console.log(' fields keys:', Object.keys(child.fields));
|
||||||
if (child.fields.items) console.log(' fields.items count:', child.fields.items.length);
|
if (child.fields.items) console.log(' fields.items count:', child.fields.items.length);
|
||||||
if (child.fields.technicalItems)
|
if (child.fields.technicalItems) console.log(' fields.technicalItems count:', child.fields.technicalItems.length);
|
||||||
console.log(' fields.technicalItems count:', child.fields.technicalItems.length);
|
if (child.fields.voltageTables) console.log(' fields.voltageTables count:', child.fields.voltageTables.length);
|
||||||
if (child.fields.voltageTables)
|
|
||||||
console.log(' fields.voltageTables count:', child.fields.voltageTables.length);
|
|
||||||
}
|
}
|
||||||
// Also check top-level (in case fields are flat)
|
// Also check top-level (in case fields are flat)
|
||||||
const topKeys = Object.keys(child).filter(
|
const topKeys = Object.keys(child).filter(k => !['children', 'type', 'version', 'format', 'indent', 'direction', 'textFormat', 'textStyle', 'fields'].includes(k));
|
||||||
(k) =>
|
|
||||||
![
|
|
||||||
'children',
|
|
||||||
'type',
|
|
||||||
'version',
|
|
||||||
'format',
|
|
||||||
'indent',
|
|
||||||
'direction',
|
|
||||||
'textFormat',
|
|
||||||
'textStyle',
|
|
||||||
'fields',
|
|
||||||
].includes(k),
|
|
||||||
);
|
|
||||||
if (topKeys.length > 0) console.log(' top-level keys:', topKeys);
|
if (topKeys.length > 0) console.log(' top-level keys:', topKeys);
|
||||||
if (child.items) console.log(' items (top-level) count:', child.items.length);
|
if (child.items) console.log(' items (top-level) count:', child.items.length);
|
||||||
if (child.technicalItems)
|
if (child.technicalItems) console.log(' technicalItems (top-level) count:', child.technicalItems.length);
|
||||||
console.log(' technicalItems (top-level) count:', child.technicalItems.length);
|
if (child.voltageTables) console.log(' voltageTables (top-level) count:', child.voltageTables.length);
|
||||||
if (child.voltageTables)
|
|
||||||
console.log(' voltageTables (top-level) count:', child.voltageTables.length);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('No content.root.children');
|
console.log('No content.root.children');
|
||||||
@@ -58,7 +38,4 @@ async function main() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main().catch((e) => { console.error(e); process.exit(1); });
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -6,22 +6,15 @@ async function debug() {
|
|||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
slug: { equals: 'na2xsy' },
|
slug: { equals: 'na2xsy' }
|
||||||
},
|
},
|
||||||
locale: 'de',
|
locale: 'de'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.docs.length > 0) {
|
if (result.docs.length > 0) {
|
||||||
const doc = result.docs[0];
|
const doc = result.docs[0];
|
||||||
console.log('Product:', doc.title);
|
console.log('Product:', doc.title);
|
||||||
console.log(
|
console.log('Content Blocks:', JSON.stringify(doc.content?.root?.children?.filter((n: any) => n.type === 'block'), null, 2));
|
||||||
'Content Blocks:',
|
|
||||||
JSON.stringify(
|
|
||||||
doc.content?.root?.children?.filter((n: any) => n.type === 'block'),
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.log('Product not found');
|
console.log('Product not found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { getPayload } from 'payload';
|
import { getPayload } from "payload";
|
||||||
import configPromise from '@payload-config';
|
import configPromise from "@payload-config";
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const resEn = await payload.find({ collection: 'pages', locale: 'en' as any });
|
const resEn = await payload.find({ collection: "pages", locale: "en" as any });
|
||||||
const resDe = await payload.find({ collection: 'pages', locale: 'de' as any });
|
const resDe = await payload.find({ collection: "pages", locale: "de" as any });
|
||||||
|
|
||||||
console.log('EN Pages:');
|
console.log("EN Pages:");
|
||||||
resEn.docs.forEach((d) => console.log(`- ${d.slug}: ${d.title}`));
|
resEn.docs.forEach(d => console.log(`- ${d.slug}: ${d.title}`));
|
||||||
console.log('DE Pages:');
|
console.log("DE Pages:");
|
||||||
resDe.docs.forEach((d) => console.log(`- ${d.slug}: ${d.title}`));
|
resDe.docs.forEach(d => console.log(`- ${d.slug}: ${d.title}`));
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ async function inspectStart() {
|
|||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'pages',
|
collection: 'pages',
|
||||||
where: { slug: { equals: 'start' } },
|
where: { slug: { equals: 'start' } },
|
||||||
locale: 'de',
|
locale: 'de'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.docs.length > 0) {
|
if (result.docs.length > 0) {
|
||||||
@@ -16,10 +16,7 @@ async function inspectStart() {
|
|||||||
// Print block types in content
|
// Print block types in content
|
||||||
if (doc.content?.root?.children) {
|
if (doc.content?.root?.children) {
|
||||||
const blocks = doc.content.root.children.filter((n: any) => n.type === 'block');
|
const blocks = doc.content.root.children.filter((n: any) => n.type === 'block');
|
||||||
console.log(
|
console.log('Blocks found:', blocks.map((b: any) => b.blockType || b.type));
|
||||||
'Blocks found:',
|
|
||||||
blocks.map((b: any) => b.blockType || b.type),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('Start page not found');
|
console.log('Start page not found');
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ async function listPages() {
|
|||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'pages',
|
collection: 'pages',
|
||||||
locale: 'de',
|
locale: 'de'
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Pages detected:');
|
console.log('Pages detected:');
|
||||||
result.docs.forEach((d) => {
|
result.docs.forEach(d => {
|
||||||
console.log(`- ${d.title} (slug: ${d.slug})`);
|
console.log(`- ${d.title} (slug: ${d.slug})`);
|
||||||
});
|
});
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
#!/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!"
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
async function test() {
|
async function test() {
|
||||||
const u =
|
const u = 'https://testing.klz-cables.com/de/blog/johannes-gleich-startet-als-senior-key-account-manager-durch';
|
||||||
'https://testing.klz-cables.com/de/blog/johannes-gleich-startet-als-senior-key-account-manager-durch';
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(u, {
|
const res = await axios.get(u, {
|
||||||
headers: { Cookie: 'klz_gatekeeper_session=lassmichrein' },
|
headers: { Cookie: 'klz_gatekeeper_session=lassmichrein' },
|
||||||
validateStatus: (status) => status < 400,
|
validateStatus: (status) => status < 400
|
||||||
});
|
});
|
||||||
console.log('Status:', res.status);
|
console.log('Status:', res.status);
|
||||||
console.log('Headers:', res.headers);
|
console.log('Headers:', res.headers);
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
|
||||||
import redis from './redis';
|
|
||||||
|
|
||||||
const isDockerContainer =
|
|
||||||
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
|
|
||||||
const qdrantUrl =
|
|
||||||
process.env.QDRANT_URL ||
|
|
||||||
(isDockerContainer ? 'http://klz-qdrant:6333' : 'http://localhost:6333');
|
|
||||||
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
|
|
||||||
|
|
||||||
export const qdrant = new QdrantClient({
|
|
||||||
url: qdrantUrl,
|
|
||||||
apiKey: qdrantApiKey || undefined,
|
|
||||||
// Disable qdrant client's own version check to avoid the warning spam
|
|
||||||
checkCompatibility: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const COLLECTION_NAME = 'klz_products';
|
|
||||||
export const VECTOR_SIZE = 1024; // Mistral mistral-embed
|
|
||||||
|
|
||||||
// Cache TTLs
|
|
||||||
const EMBEDDING_CACHE_TTL = 60 * 60 * 24; // 24h — embeddings are deterministic
|
|
||||||
const SEARCH_CACHE_TTL = 60 * 30; // 30 min — product data could change
|
|
||||||
|
|
||||||
// Track collection existence in-memory (don't re-check every request)
|
|
||||||
let collectionVerified = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the collection exists in Qdrant (only checks once per process lifetime).
|
|
||||||
*/
|
|
||||||
export async function ensureCollection() {
|
|
||||||
if (collectionVerified) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const collections = await qdrant.getCollections();
|
|
||||||
const exists = collections.collections.some((c) => c.name === COLLECTION_NAME);
|
|
||||||
if (!exists) {
|
|
||||||
await qdrant.createCollection(COLLECTION_NAME, {
|
|
||||||
vectors: {
|
|
||||||
size: VECTOR_SIZE,
|
|
||||||
distance: 'Cosine',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
|
|
||||||
}
|
|
||||||
collectionVerified = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error ensuring Qdrant collection:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hash text for cache key
|
|
||||||
*/
|
|
||||||
function hashKey(text: string): string {
|
|
||||||
const { createHash } = require('crypto');
|
|
||||||
return createHash('sha256').update(text).digest('hex').slice(0, 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate embedding using Mistral API (EU/DSGVO-compliant)
|
|
||||||
*/
|
|
||||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
|
||||||
const cacheKey = `emb:${hashKey(text.toLowerCase().trim())}`;
|
|
||||||
|
|
||||||
// Try Redis cache first
|
|
||||||
try {
|
|
||||||
const cached = await redis.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return JSON.parse(cached);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Redis down — proceed without cache
|
|
||||||
}
|
|
||||||
|
|
||||||
const mistralKey = process.env.MISTRAL_API_KEY;
|
|
||||||
if (!mistralKey) {
|
|
||||||
throw new Error('MISTRAL_API_KEY is not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('https://api.mistral.ai/v1/embeddings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${mistralKey}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'mistral-embed',
|
|
||||||
input: [text],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorBody = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`Failed to generate embedding: ${response.status} ${response.statusText} ${errorBody}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const embedding = data.data[0].embedding;
|
|
||||||
|
|
||||||
// Cache the embedding in Redis
|
|
||||||
try {
|
|
||||||
await redis.set(cacheKey, JSON.stringify(embedding), 'EX', EMBEDDING_CACHE_TTL);
|
|
||||||
} catch {
|
|
||||||
// Redis down — proceed without caching
|
|
||||||
}
|
|
||||||
|
|
||||||
return embedding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upsert a product into Qdrant
|
|
||||||
*/
|
|
||||||
export async function upsertProductVector(
|
|
||||||
id: string | number,
|
|
||||||
text: string,
|
|
||||||
payload: Record<string, any>,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await ensureCollection();
|
|
||||||
const vector = await generateEmbedding(text);
|
|
||||||
|
|
||||||
await qdrant.upsert(COLLECTION_NAME, {
|
|
||||||
wait: true,
|
|
||||||
points: [
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
vector,
|
|
||||||
payload,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error writing to Qdrant:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a product from Qdrant
|
|
||||||
*/
|
|
||||||
export async function deleteProductVector(id: string | number) {
|
|
||||||
try {
|
|
||||||
await ensureCollection();
|
|
||||||
await qdrant.delete(COLLECTION_NAME, {
|
|
||||||
wait: true,
|
|
||||||
points: [id] as [string | number],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting from Qdrant:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search products in Qdrant.
|
|
||||||
* Results are cached in Redis for 30 minutes keyed by query text.
|
|
||||||
*/
|
|
||||||
export async function searchProducts(query: string, limit = 5) {
|
|
||||||
const cacheKey = `search:${hashKey(query.toLowerCase().trim())}:${limit}`;
|
|
||||||
|
|
||||||
// Try Redis cache first
|
|
||||||
try {
|
|
||||||
const cached = await redis.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
console.log(`[Qdrant] Cache HIT for query: "${query.substring(0, 50)}"`);
|
|
||||||
return JSON.parse(cached);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Redis down — proceed without cache
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ensureCollection();
|
|
||||||
const vector = await generateEmbedding(query);
|
|
||||||
|
|
||||||
const results = await qdrant.search(COLLECTION_NAME, {
|
|
||||||
vector,
|
|
||||||
limit,
|
|
||||||
with_payload: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cache results in Redis
|
|
||||||
try {
|
|
||||||
await redis.set(cacheKey, JSON.stringify(results), 'EX', SEARCH_CACHE_TTL);
|
|
||||||
} catch {
|
|
||||||
// Redis down — proceed without caching
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching in Qdrant:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import Redis from 'ioredis';
|
|
||||||
|
|
||||||
const isDockerContainer =
|
|
||||||
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
|
|
||||||
const redisUrl =
|
|
||||||
process.env.REDIS_URL ||
|
|
||||||
(isDockerContainer ? 'redis://klz-redis:6379' : 'redis://localhost:6379');
|
|
||||||
|
|
||||||
// Only create a single instance in Node.js
|
|
||||||
const globalForRedis = global as unknown as { redis: Redis };
|
|
||||||
|
|
||||||
export const redis =
|
|
||||||
globalForRedis.redis ||
|
|
||||||
new Redis(redisUrl, {
|
|
||||||
maxRetriesPerRequest: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
globalForRedis.redis = redis;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default redis;
|
|
||||||
@@ -26,66 +26,6 @@ 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: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
|
|||||||
@@ -45,67 +45,6 @@ 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: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
|
|||||||
@@ -37,51 +37,6 @@ export const Products: CollectionConfig = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
hooks: {
|
|
||||||
afterChange: [
|
|
||||||
async ({ doc, req, operation }) => {
|
|
||||||
// Run index sync asynchronously to not block the CMS save operation
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
|
|
||||||
|
|
||||||
// Check if product is published
|
|
||||||
if (doc._status !== 'published') {
|
|
||||||
await deleteProductVector(doc.id);
|
|
||||||
req.payload.logger.info(`Removed drafted product ${doc.sku} from Qdrant`);
|
|
||||||
} else {
|
|
||||||
// Serialize payload
|
|
||||||
const contentText = `${doc.title} - SKU: ${doc.sku}\n${doc.description || ''}`;
|
|
||||||
const payload = {
|
|
||||||
id: doc.id,
|
|
||||||
title: doc.title,
|
|
||||||
sku: doc.sku,
|
|
||||||
slug: doc.slug,
|
|
||||||
description: doc.description,
|
|
||||||
featuredImage: doc.featuredImage, // usually just ID or URL depending on depth
|
|
||||||
};
|
|
||||||
await upsertProductVector(doc.id, contentText, payload);
|
|
||||||
req.payload.logger.info(`Upserted product ${doc.sku} to Qdrant`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
req.payload.logger.error({ msg: 'Error syncing product to Qdrant', err: error, productId: doc.id });
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
return doc;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
afterDelete: [
|
|
||||||
async ({ id, req }) => {
|
|
||||||
try {
|
|
||||||
const { deleteProductVector } = await import('../../lib/qdrant');
|
|
||||||
await deleteProductVector(id as string | number);
|
|
||||||
req.payload.logger.info(`Deleted product ${id} from Qdrant`);
|
|
||||||
} catch (error) {
|
|
||||||
req.payload.logger.error({ msg: 'Error deleting product from Qdrant', err: error, productId: id });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
@@ -1,64 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
import { upsertProductVector } from '../lib/qdrant';
|
|
||||||
|
|
||||||
// Ingests the extracted Kabelhandbuch text into Qdrant as distinct knowledge topics.
|
|
||||||
async function ingestPDF(txtPath: string) {
|
|
||||||
if (!fs.existsSync(txtPath)) {
|
|
||||||
console.error(`File not found: ${txtPath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const text = fs.readFileSync(txtPath, 'utf8');
|
|
||||||
|
|
||||||
// Simple sentence/paragraph chunking
|
|
||||||
// We split by standard paragraph breaks (double newline) or large content blocks.
|
|
||||||
const chunks = text
|
|
||||||
.split(/\n\s*\n/)
|
|
||||||
.map((c) => c.trim())
|
|
||||||
.filter((c) => c.length > 50);
|
|
||||||
|
|
||||||
console.log(`Extracted ${text.length} characters from PDF.`);
|
|
||||||
console.log(`Generated ${chunks.length} chunks for vector ingestion.\n`);
|
|
||||||
|
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
|
||||||
// We limit chuck sizes to ensure Openrouter embedding models don't timeout/fail,
|
|
||||||
// stringing multiple paragraphs if they are short, or cutting them if too long.
|
|
||||||
// For baseline, we'll index every chunk individually mapped as 'knowledge' with a unique ID
|
|
||||||
|
|
||||||
const chunkText = chunks[i];
|
|
||||||
|
|
||||||
// Generate a synthetic ID that won't collide with Payload Product IDs
|
|
||||||
// Qdrant strictly requires UUID or unsigned int.
|
|
||||||
const syntheticId = crypto.randomUUID();
|
|
||||||
|
|
||||||
const payloadData = {
|
|
||||||
type: 'knowledge', // Custom flag to differentiate from 'product'
|
|
||||||
title: `Kabelhandbuch Wissen - Bereich ${i + 1}`,
|
|
||||||
content: chunkText,
|
|
||||||
source: 'Kabelhandbuch KLZ.pdf',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use the existing upsert function since it just embeds the text and stores the payload
|
|
||||||
await upsertProductVector(syntheticId, chunkText, payloadData);
|
|
||||||
console.log(`✅ Upserted chunk ${i + 1}/${chunks.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🎉 PDF Ingestion Complete!');
|
|
||||||
process.exit(0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to parse PDF:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run mapping
|
|
||||||
const targetTxt = '/Users/marcmintel/Downloads/kabelhandbuch.txt';
|
|
||||||
ingestPDF(targetTxt);
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import fetch from 'node-fetch';
|
|
||||||
|
|
||||||
async function test() {
|
|
||||||
const messages = [
|
|
||||||
{ role: 'user', content: 'Ich will einen Windpark bauen' }
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log('Sending message:', messages[0].content);
|
|
||||||
|
|
||||||
const res = await fetch('http://localhost:3000/api/ai-search', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ messages })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
console.log('\nAI Response:', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
test().catch(console.error);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { generateText } from 'ai';
|
|
||||||
import { createOpenAI } from '@ai-sdk/openai';
|
|
||||||
|
|
||||||
const openrouter = createOpenAI({
|
|
||||||
baseURL: 'https://openrouter.ai/api/v1',
|
|
||||||
apiKey: process.env.OPENROUTER_API_KEY,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const { text } = await generateText({
|
|
||||||
model: openrouter('mistralai/mistral-large-2407'),
|
|
||||||
prompt: 'Hello world! Reply in one word.',
|
|
||||||
});
|
|
||||||
console.log('Result:', text);
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
Reference in New Issue
Block a user