Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1670b8e5ef | |||
| 1c43d12e4d | |||
| 5cf9922822 | |||
| 9a4a95feea | |||
| d3902c4c77 | |||
| 21ec8a33ae | |||
| 79d221de5e | |||
| 24fde20030 | |||
| 4a4409ca85 | |||
| d96d6a4b13 | |||
| 8f6b12d827 | |||
| a11714d07d |
@@ -1,5 +1,5 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.9.8
|
||||
IMAGE_TAG=v1.9.10
|
||||
PROJECT_NAME=sample-website
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
|
||||
@@ -202,9 +202,9 @@ jobs:
|
||||
- name: 🔐 Registry Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
registry: git.infra.mintel.me
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -218,6 +218,6 @@ jobs:
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||
git.infra.mintel.me/mmintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
git.infra.mintel.me/mmintel/${{ matrix.image }}:latest
|
||||
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -46,4 +46,8 @@ directus/uploads/directus-health-file
|
||||
# Estimation Engine Data
|
||||
data/crawls/
|
||||
packages/estimation-engine/out/
|
||||
apps/web/out/estimations/
|
||||
apps/web/out/estimations/
|
||||
|
||||
# Memory MCP
|
||||
data/qdrant/
|
||||
packages/memory-mcp/models/
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Builder
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||
FROM git.infra.mintel.me/mmintel/nextjs:latest AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Clean the workspace in case the base image is dirty
|
||||
@@ -37,7 +37,7 @@ COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sample-website",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
16
docker-compose.mcps.yml
Normal file
16
docker-compose.mcps.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: qdrant-mcp
|
||||
ports:
|
||||
- "6333:6333"
|
||||
- "6334:6334"
|
||||
volumes:
|
||||
- ./data/qdrant:/qdrant/storage
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
networks:
|
||||
mcp-network:
|
||||
driver: bridge
|
||||
12
fix-private.mjs
Normal file
12
fix-private.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import fs from 'fs';
|
||||
import glob from 'glob';
|
||||
|
||||
const files = glob.sync('/Users/marcmintel/Projects/at-mintel/packages/*/package.json');
|
||||
files.forEach(f => {
|
||||
const content = fs.readFileSync(f, 'utf8');
|
||||
if (content.includes('"private": true,')) {
|
||||
console.log(`Fixing ${f}`);
|
||||
const newContent = content.replace(/\s*"private": true,?\n/g, '\n');
|
||||
fs.writeFileSync(f, newContent);
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,10 @@
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm -r dev",
|
||||
"dev:gatekeeper": "bash -c 'trap \"COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml up --build --remove-orphans'",
|
||||
"dev:mcps:up": "docker-compose -f docker-compose.mcps.yml up -d",
|
||||
"dev:mcps:down": "docker-compose -f docker-compose.mcps.yml down",
|
||||
"dev:mcps:watch": "pnpm -r --filter=\"./packages/*-mcp\" exec tsc -w",
|
||||
"dev:mcps": "npm run dev:mcps:up && npm run dev:mcps:watch",
|
||||
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
||||
"test": "pnpm -r test",
|
||||
"changeset": "changeset",
|
||||
@@ -49,7 +53,7 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"require-in-the-middle": "^8.0.1"
|
||||
},
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cli",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cloner",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/concept-engine",
|
||||
"version": "1.9.8",
|
||||
"private": true,
|
||||
"version": "1.9.10",
|
||||
"description": "AI-powered web project concept generation and analysis",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/content-engine",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/eslint-config",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/estimation-engine",
|
||||
"version": "1.9.8",
|
||||
"private": true,
|
||||
"version": "1.9.10",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/gatekeeper",
|
||||
"version": "1.9.8",
|
||||
"private": true,
|
||||
"version": "1.9.10",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/gitea-mcp",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"description": "Native Gitea MCP server for 100% Antigravity compatibility",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/husky-config",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Start from the pre-built Nextjs Base image
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||
FROM git.infra.mintel.me/mmintel/nextjs:latest AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -20,7 +20,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
RUN pnpm --filter ${APP_NAME:-app} build
|
||||
|
||||
# Production runner image
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Copy standalone output and static files
|
||||
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-app}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
|
||||
gatekeeper:
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:${IMAGE_TAG:-latest}
|
||||
image: git.infra.mintel.me/mmintel/gatekeeper:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
||||
|
||||
directus:
|
||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||
image: git.infra.mintel.me/mmintel/directus:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
|
||||
@@ -180,9 +180,9 @@ jobs:
|
||||
- name: 🔐 Registry Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
registry: git.infra.mintel.me
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: 🏗️ Docker Build & Push
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
|
||||
tags: git.infra.mintel.me/mmintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
set -e
|
||||
cd "/home/deploy/sites/${{ github.event.repository.name }}"
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
echo "${{ secrets.NPM_TOKEN }}" | docker login git.infra.mintel.me -u "${{ github.repository_owner }}" --password-stdin
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||
docker system prune -f --filter "until=24h"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/infra",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/journaling",
|
||||
"version": "1.9.8",
|
||||
"private": true,
|
||||
"version": "1.9.10",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/mail",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/meme-generator",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
25
packages/memory-mcp/package.json
Normal file
25
packages/memory-mcp/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@mintel/memory-mcp",
|
||||
"version": "1.9.10",
|
||||
"description": "Local Qdrant-based Memory MCP server",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||
"@qdrant/js-client-rest": "^1.12.0",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.19.1",
|
||||
"vitest": "^2.1.3"
|
||||
}
|
||||
}
|
||||
78
packages/memory-mcp/src/index.ts
Normal file
78
packages/memory-mcp/src/index.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { QdrantMemoryService } from './qdrant.js';
|
||||
|
||||
async function main() {
|
||||
const server = new McpServer({
|
||||
name: '@mintel/memory-mcp',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const qdrantService = new QdrantMemoryService(process.env.QDRANT_URL || 'http://localhost:6333');
|
||||
|
||||
// Initialize embedding model and Qdrant connection
|
||||
try {
|
||||
await qdrantService.initialize();
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize local dependencies. Exiting.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
server.tool(
|
||||
'store_memory',
|
||||
'Store a new piece of knowledge/memory into the vector database. Use this to remember architectural decisions, preferences, aliases, etc.',
|
||||
{
|
||||
label: z.string().describe('A short, descriptive label or title for the memory (e.g., "Architektur-Entscheidungen")'),
|
||||
content: z.string().describe('The actual content to remember (e.g., "In diesem Projekt nutzen wir lieber Composition over Inheritance.")'),
|
||||
},
|
||||
async (args) => {
|
||||
const success = await qdrantService.storeMemory(args.label, args.content);
|
||||
if (success) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Successfully stored memory: [${args.label}]` }],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to store memory: [${args.label}]` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'retrieve_memory',
|
||||
'Retrieve relevant memories from the vector database based on a semantic search query.',
|
||||
{
|
||||
query: z.string().describe('The search query to find relevant memories.'),
|
||||
limit: z.number().optional().describe('Maximum number of results to return (default: 5)'),
|
||||
},
|
||||
async (args) => {
|
||||
const results = await qdrantService.retrieveMemory(args.query, args.limit || 5);
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'No relevant memories found.' }],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedResults = results
|
||||
.map(r => `- [${r.label}] (Score: ${r.score.toFixed(3)}): ${r.content}`)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${results.length} memories:\n\n${formattedResults}` }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('Memory MCP server is running and ready to accept connections over stdio.');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error in main():', error);
|
||||
process.exit(1);
|
||||
});
|
||||
89
packages/memory-mcp/src/qdrant.test.ts
Normal file
89
packages/memory-mcp/src/qdrant.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { QdrantMemoryService } from './qdrant.js';
|
||||
|
||||
vi.mock('@xenova/transformers', () => {
|
||||
return {
|
||||
env: { allowRemoteModels: false, localModelPath: './models' },
|
||||
pipeline: vi.fn().mockResolvedValue(async (text: string) => {
|
||||
// Mock embedding generation: returns an array of 384 numbers
|
||||
return { data: new Float32Array(384).fill(0.1) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockCreateCollection = vi.fn();
|
||||
const mockGetCollections = vi.fn().mockResolvedValue({ collections: [] });
|
||||
const mockUpsert = vi.fn();
|
||||
const mockSearch = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'test-id',
|
||||
version: 1,
|
||||
score: 0.9,
|
||||
payload: { label: 'Test Label', content: 'Test Content' }
|
||||
}
|
||||
]);
|
||||
|
||||
vi.mock('@qdrant/js-client-rest', () => {
|
||||
return {
|
||||
QdrantClient: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
getCollections: mockGetCollections,
|
||||
createCollection: mockCreateCollection,
|
||||
upsert: mockUpsert,
|
||||
search: mockSearch
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('QdrantMemoryService', () => {
|
||||
let service: QdrantMemoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new QdrantMemoryService('http://localhost:6333');
|
||||
});
|
||||
|
||||
it('should initialize and create collection if missing', async () => {
|
||||
mockGetCollections.mockResolvedValueOnce({ collections: [] });
|
||||
await service.initialize();
|
||||
|
||||
expect(mockGetCollections).toHaveBeenCalled();
|
||||
expect(mockCreateCollection).toHaveBeenCalledWith('mcp_memory', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should not create collection if it already exists', async () => {
|
||||
mockGetCollections.mockResolvedValueOnce({ collections: [{ name: 'mcp_memory' }] });
|
||||
await service.initialize();
|
||||
|
||||
expect(mockCreateCollection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should store memory', async () => {
|
||||
await service.initialize();
|
||||
const result = await service.storeMemory('Design', 'Composition over Inheritance');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockUpsert).toHaveBeenCalledWith('mcp_memory', expect.objectContaining({
|
||||
wait: true,
|
||||
points: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
label: 'Design',
|
||||
content: 'Composition over Inheritance'
|
||||
})
|
||||
})
|
||||
])
|
||||
}));
|
||||
});
|
||||
|
||||
it('should retrieve memory', async () => {
|
||||
await service.initialize();
|
||||
const results = await service.retrieveMemory('Design');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].label).toBe('Test Label');
|
||||
expect(results[0].content).toBe('Test Content');
|
||||
expect(results[0].score).toBe(0.9);
|
||||
});
|
||||
});
|
||||
110
packages/memory-mcp/src/qdrant.ts
Normal file
110
packages/memory-mcp/src/qdrant.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { pipeline, env } from '@xenova/transformers';
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
|
||||
// Be sure to set local caching options for transformers
|
||||
env.allowRemoteModels = true;
|
||||
env.localModelPath = './models';
|
||||
|
||||
export class QdrantMemoryService {
|
||||
private client: QdrantClient;
|
||||
private collectionName = 'mcp_memory';
|
||||
private embedder: any = null;
|
||||
|
||||
constructor(url: string = 'http://localhost:6333') {
|
||||
this.client = new QdrantClient({ url });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the embedding model and the Qdrant collection
|
||||
*/
|
||||
async initialize() {
|
||||
// 1. Load the embedding model (using a lightweight model suitable for semantic search)
|
||||
console.error('Loading embedding model...');
|
||||
this.embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
|
||||
|
||||
// 2. Ensure collection exists
|
||||
console.error(`Checking for collection: ${this.collectionName}`);
|
||||
try {
|
||||
const collections = await this.client.getCollections();
|
||||
const exists = collections.collections.some(c => c.name === this.collectionName);
|
||||
|
||||
if (!exists) {
|
||||
console.error(`Creating collection: ${this.collectionName}`);
|
||||
await this.client.createCollection(this.collectionName, {
|
||||
vectors: {
|
||||
size: 384, // size for all-MiniLM-L6-v2
|
||||
distance: 'Cosine'
|
||||
}
|
||||
});
|
||||
console.error('Collection created successfully.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize Qdrant collection:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a vector embedding for the given text
|
||||
*/
|
||||
private async getEmbedding(text: string): Promise<number[]> {
|
||||
if (!this.embedder) {
|
||||
throw new Error('Embedder not initialized. Call initialize() first.');
|
||||
}
|
||||
const output = await this.embedder(text, { pooling: 'mean', normalize: true });
|
||||
return Array.from(output.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a memory entry into Qdrant
|
||||
*/
|
||||
async storeMemory(label: string, content: string): Promise<boolean> {
|
||||
try {
|
||||
const fullText = `${label}: ${content}`;
|
||||
const vector = await this.getEmbedding(fullText);
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
await this.client.upsert(this.collectionName, {
|
||||
wait: true,
|
||||
points: [
|
||||
{
|
||||
id,
|
||||
vector,
|
||||
payload: {
|
||||
label,
|
||||
content,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to store memory:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves memory entries relevant to the query
|
||||
*/
|
||||
async retrieveMemory(query: string, limit: number = 5): Promise<Array<{ label: string, content: string, score: number }>> {
|
||||
try {
|
||||
const vector = await this.getEmbedding(query);
|
||||
const searchResults = await this.client.search(this.collectionName, {
|
||||
vector,
|
||||
limit,
|
||||
with_payload: true
|
||||
});
|
||||
|
||||
return searchResults.map(result => ({
|
||||
label: String(result.payload?.label || ''),
|
||||
content: String(result.payload?.content || ''),
|
||||
score: result.score
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to retrieve memory:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/memory-mcp/tsconfig.json
Normal file
16
packages/memory-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-config",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-feedback",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-observability",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-utils",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/observability",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/page-audit",
|
||||
"version": "1.9.8",
|
||||
"private": true,
|
||||
"version": "1.9.10",
|
||||
"description": "AI-powered website IST-analysis using DataForSEO and Gemini",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
2
packages/payload-ai/.npmrc
Normal file
2
packages/payload-ai/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/payload-ai",
|
||||
"version": "1.9.8",
|
||||
"private": true,
|
||||
"version": "1.9.15",
|
||||
"description": "Reusable Payload CMS AI Extensions",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,7 +15,8 @@
|
||||
"./actions/*": "./dist/actions/*",
|
||||
"./globals/*": "./dist/globals/*",
|
||||
"./endpoints/*": "./dist/endpoints/*",
|
||||
"./utils/*": "./dist/utils/*"
|
||||
"./utils/*": "./dist/utils/*",
|
||||
"./tools/*": "./dist/tools/*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@payloadcms/next": ">=3.0.0",
|
||||
@@ -26,20 +26,26 @@
|
||||
"react-dom": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^3.0.39",
|
||||
"@ai-sdk/react": "^3.0.110",
|
||||
"@mintel/content-engine": "workspace:*",
|
||||
"@mintel/thumbnail-generator": "workspace:*",
|
||||
"replicate": "^1.4.0"
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@qdrant/js-client-rest": "^1.17.0",
|
||||
"ai": "^6.0.108",
|
||||
"replicate": "^1.4.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/next": "3.77.0",
|
||||
"@payloadcms/ui": "3.77.0",
|
||||
"payload": "3.77.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"@types/node": "^20.17.17",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"next": "^15.1.0",
|
||||
"payload": "3.77.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
90
packages/payload-ai/src/chatPlugin.ts
Normal file
90
packages/payload-ai/src/chatPlugin.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Config, Plugin } from 'payload'
|
||||
import { AIChatPermissionsCollection } from './collections/AIChatPermissions.js'
|
||||
import type { PayloadChatPluginConfig } from './types.js'
|
||||
import { optimizePostEndpoint } from './endpoints/optimizeEndpoint.js'
|
||||
import { generateSlugEndpoint, generateThumbnailEndpoint, generateSingleFieldEndpoint } from './endpoints/generateEndpoints.js'
|
||||
|
||||
export const payloadChatPlugin =
|
||||
(pluginOptions: PayloadChatPluginConfig): Plugin =>
|
||||
(incomingConfig) => {
|
||||
let config = { ...incomingConfig }
|
||||
|
||||
// If disabled, return config untouched
|
||||
if (pluginOptions.enabled === false) {
|
||||
return config
|
||||
}
|
||||
|
||||
// 1. Inject the Permissions Collection into the Schema
|
||||
const existingCollections = config.collections || []
|
||||
|
||||
const mcpServers = pluginOptions.mcpServers || []
|
||||
|
||||
// Dynamically populate the select options for Collections and MCP Servers
|
||||
const permissionCollection = { ...AIChatPermissionsCollection }
|
||||
const collectionField = permissionCollection.fields.find(f => 'name' in f && f.name === 'allowedCollections') as any
|
||||
if (collectionField) {
|
||||
collectionField.options = existingCollections.map(c => ({
|
||||
label: c.labels?.singular || c.slug,
|
||||
value: c.slug
|
||||
}))
|
||||
}
|
||||
|
||||
const mcpField = permissionCollection.fields.find(f => 'name' in f && f.name === 'allowedMcpServers') as any
|
||||
if (mcpField) {
|
||||
mcpField.options = mcpServers.map(s => ({
|
||||
label: s.name,
|
||||
value: s.name
|
||||
}))
|
||||
}
|
||||
|
||||
config.collections = [...existingCollections, permissionCollection]
|
||||
|
||||
// 2. Register Custom API Endpoint for the AI Chat
|
||||
config.endpoints = [
|
||||
...(config.endpoints || []),
|
||||
{
|
||||
path: '/api/mcp-chat',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
// Fallback simple handler while developing endpoint logic
|
||||
return Response.json({ message: "Chat endpoint active" })
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/api/mintel-ai/optimize',
|
||||
method: 'post',
|
||||
handler: optimizePostEndpoint,
|
||||
},
|
||||
{
|
||||
path: '/api/mintel-ai/generate-slug',
|
||||
method: 'post',
|
||||
handler: generateSlugEndpoint,
|
||||
},
|
||||
{
|
||||
path: '/api/mintel-ai/generate-thumbnail',
|
||||
method: 'post',
|
||||
handler: generateThumbnailEndpoint,
|
||||
},
|
||||
{
|
||||
path: '/api/mintel-ai/generate-single-field',
|
||||
method: 'post',
|
||||
handler: generateSingleFieldEndpoint,
|
||||
},
|
||||
]
|
||||
|
||||
// 3. Inject Chat React Component into Admin UI
|
||||
if (pluginOptions.renderChatBubble !== false) {
|
||||
config.admin = {
|
||||
...(config.admin || {}),
|
||||
components: {
|
||||
...(config.admin?.components || {}),
|
||||
providers: [
|
||||
...(config.admin?.components?.providers || []),
|
||||
'@mintel/payload-ai/components/ChatWindow#ChatWindowProvider',
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
69
packages/payload-ai/src/collections/AIChatPermissions.ts
Normal file
69
packages/payload-ai/src/collections/AIChatPermissions.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
/**
|
||||
* A central collection to manage which AI Tools/MCPs a User or Role is allowed to use.
|
||||
*/
|
||||
export const AIChatPermissionsCollection: CollectionConfig = {
|
||||
slug: 'ai-chat-permissions',
|
||||
labels: {
|
||||
singular: 'AI Chat Permission',
|
||||
plural: 'AI Chat Permissions',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'description',
|
||||
group: 'AI & Tools',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'E.g. "Editors default AI permissions"',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'targetUser',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
hasMany: false,
|
||||
admin: {
|
||||
description: 'Apply these permissions to a specific user (optional).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'targetRole',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
{ label: 'Editor', value: 'editor' },
|
||||
], // Ideally this is dynamically populated in a real scenario, but we hardcode standard roles for now
|
||||
admin: {
|
||||
description: 'Apply these permissions to all users with this role.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'allowedCollections',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: [], // Will be populated dynamically in the plugin init based on actual collections
|
||||
admin: {
|
||||
description: 'Which Payload collections is the AI allowed to read/write on behalf of this user?',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'allowedMcpServers',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: [], // Will be populated dynamically based on plugin config
|
||||
admin: {
|
||||
description: 'Which external MCP Servers is the AI allowed to execute tools from?',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
108
packages/payload-ai/src/components/ChatWindow/index.tsx
Normal file
108
packages/payload-ai/src/components/ChatWindow/index.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import './ChatWindow.scss'
|
||||
|
||||
export const ChatWindowProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<ChatWindow />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ChatWindow: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
// @ts-ignore - AI hook version mismatch between core and react packages
|
||||
const { messages, input, handleInputChange, handleSubmit, setMessages } = useChat({
|
||||
api: '/api/mcp-chat',
|
||||
initialMessages: []
|
||||
} as any)
|
||||
|
||||
// Basic implementation to toggle chat window and submit messages
|
||||
return (
|
||||
<div className="payload-mcp-chat-container">
|
||||
<button
|
||||
className="payload-mcp-chat-toggle"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
zIndex: 9999,
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#000',
|
||||
color: '#fff',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{isOpen ? 'Close AI Chat' : 'Ask AI'}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="payload-mcp-chat-window"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '80px',
|
||||
right: '20px',
|
||||
width: '400px',
|
||||
height: '600px',
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #eaeaea',
|
||||
borderRadius: '12px',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 10px 40px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<div className="chat-header" style={{ padding: '16px', borderBottom: '1px solid #eaeaea', backgroundColor: '#f9f9f9', borderTopLeftRadius: '12px', borderTopRightRadius: '12px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px' }}>Payload MCP Chat</h3>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages" style={{ flex: 1, padding: '16px', overflowY: 'auto' }}>
|
||||
{messages.map((m: any) => (
|
||||
<div key={m.id} style={{
|
||||
marginBottom: '12px',
|
||||
textAlign: m.role === 'user' ? 'right' : 'left'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: m.role === 'user' ? '#000' : '#f0f0f0',
|
||||
color: m.role === 'user' ? '#fff' : '#000',
|
||||
maxWidth: '80%'
|
||||
}}>
|
||||
{m.role === 'user' ? 'G: ' : 'AI: '}
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ padding: '16px', borderTop: '1px solid #eaeaea' }}>
|
||||
<input
|
||||
value={input}
|
||||
placeholder="Ask me anything or use /commands..."
|
||||
onChange={handleInputChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #eaeaea',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useField, useDocumentInfo, useForm } from "@payloadcms/ui";
|
||||
import { generateSingleFieldAction } from "../../actions/generateField.js";
|
||||
|
||||
export function AiFieldButton({ path, field }: { path: string; field: any }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
@@ -44,19 +42,26 @@ export function AiFieldButton({ path, field }: { path: string; field: any }) {
|
||||
? field.admin.description
|
||||
: "";
|
||||
|
||||
const res = await generateSingleFieldAction(
|
||||
(title as string) || "",
|
||||
draftContent,
|
||||
fieldName,
|
||||
fieldDescription,
|
||||
instructions,
|
||||
);
|
||||
const resData = await fetch("/api/api/mintel-ai/generate-single-field", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentTitle: (title as string) || "",
|
||||
documentContent: draftContent,
|
||||
fieldName,
|
||||
fieldDescription,
|
||||
instructions,
|
||||
}),
|
||||
});
|
||||
const res = await resData.json();
|
||||
|
||||
if (res.success && res.text) {
|
||||
setValue(res.text);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
alert("Fehler bei der Generierung.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useField } from "@payloadcms/ui";
|
||||
import { generateSlugAction } from "../../actions/generateField.js";
|
||||
|
||||
export function GenerateSlugButton({ path }: { path: string }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
@@ -45,18 +43,24 @@ export function GenerateSlugButton({ path }: { path: string }) {
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const res = await generateSlugAction(
|
||||
title,
|
||||
draftContent,
|
||||
initialValue as string,
|
||||
instructions,
|
||||
);
|
||||
const resData = await fetch("/api/api/mintel-ai/generate-slug", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
draftContent,
|
||||
oldSlug: initialValue as string,
|
||||
instructions,
|
||||
}),
|
||||
});
|
||||
const res = await resData.json();
|
||||
|
||||
if (res.success && res.slug) {
|
||||
setValue(res.slug);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
alert("Unerwarteter Fehler.");
|
||||
} finally {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useField } from "@payloadcms/ui";
|
||||
import { generateThumbnailAction } from "../../actions/generateField.js";
|
||||
|
||||
export function GenerateThumbnailButton({ path }: { path: string }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
@@ -45,17 +43,23 @@ export function GenerateThumbnailButton({ path }: { path: string }) {
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const res = await generateThumbnailAction(
|
||||
draftContent,
|
||||
title,
|
||||
instructions,
|
||||
);
|
||||
const resData = await fetch("/api/api/mintel-ai/generate-thumbnail", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
draftContent,
|
||||
title,
|
||||
instructions,
|
||||
}),
|
||||
});
|
||||
const res = await resData.json();
|
||||
|
||||
if (res.success && res.mediaId) {
|
||||
setValue(res.mediaId);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
alert("Unerwarteter Fehler.");
|
||||
} finally {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useDocumentInfo } from "@payloadcms/ui";
|
||||
import { optimizePostText } from "../actions/optimizePost.js";
|
||||
import { Button } from "@payloadcms/ui";
|
||||
|
||||
export function OptimizeButton() {
|
||||
@@ -57,7 +56,12 @@ export function OptimizeButton() {
|
||||
// 2. We inject the title so the AI knows what it's writing about
|
||||
const payloadText = `---\ntitle: "${title}"\n---\n\n${draftContent}`;
|
||||
|
||||
const response = await optimizePostText(payloadText, instructions);
|
||||
const res = await fetch("/api/api/mintel-ai/optimize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ draftContent: payloadText, instructions }),
|
||||
});
|
||||
const response = await res.json();
|
||||
|
||||
if (response.success && response.lexicalAST) {
|
||||
// 3. Inject the new Lexical AST directly into the field form state
|
||||
|
||||
75
packages/payload-ai/src/endpoints/chatEndpoint.ts
Normal file
75
packages/payload-ai/src/endpoints/chatEndpoint.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { streamText } from 'ai'
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import { generatePayloadLocalTools } from '../tools/payloadLocal.js'
|
||||
import { createMcpTools } from '../tools/mcpAdapter.js'
|
||||
import { generateMemoryTools } from '../tools/memoryDb.js'
|
||||
import type { PayloadRequest } from 'payload'
|
||||
|
||||
const openrouter = createOpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: process.env.OPENROUTER_API_KEY || 'dummy_key',
|
||||
})
|
||||
|
||||
export const handleMcpChat = async (req: PayloadRequest) => {
|
||||
if (!req.user) {
|
||||
return Response.json({ error: 'Unauthorized. You must be logged in to use AI Chat.' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { messages } = (await req.json?.() || { messages: [] }) as { messages: any[] }
|
||||
|
||||
// 1. Check AI Permissions for req.user
|
||||
// In a real implementation this looks up the global or collection for permissions
|
||||
const allowedCollections = ['users'] // Stub
|
||||
let activeTools: Record<string, any> = {}
|
||||
|
||||
// 2. Generate Payload Local Tools
|
||||
if (allowedCollections.length > 0) {
|
||||
const payloadTools = generatePayloadLocalTools(req.payload, req, allowedCollections)
|
||||
activeTools = { ...activeTools, ...payloadTools }
|
||||
}
|
||||
|
||||
// 3. Connect External MCPs
|
||||
const allowedMcpServers: string[] = [] // Stub
|
||||
if (allowedMcpServers.includes('gitea')) {
|
||||
try {
|
||||
const { tools: giteaTools } = await createMcpTools({
|
||||
name: 'gitea',
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-gitea', '--url', 'https://git.mintel.int', '--token', process.env.GITEA_TOKEN || '']
|
||||
})
|
||||
activeTools = { ...activeTools, ...giteaTools }
|
||||
} catch (e) {
|
||||
console.error('Failed to connect to Gitea MCP', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Inject Memory Database Tools
|
||||
// We provide the user ID so memory is partitioned per user
|
||||
const memoryTools = generateMemoryTools(req.user.id)
|
||||
activeTools = { ...activeTools, ...memoryTools }
|
||||
|
||||
// 5. Build prompt to ensure it asks before saving
|
||||
const memorySystemPrompt = `
|
||||
You have access to a long-term vector memory database (Qdrant).
|
||||
If the user says "speicher das", "merk dir das", "vergiss das nicht" etc., you MUST use the save_memory tool.
|
||||
If the user shares important context but doesn't explicitly ask you to remember it, you should ask "Soll ich mir das für die Zukunft merken?" before saving it. Do not ask for trivial things.
|
||||
`
|
||||
|
||||
try {
|
||||
const result = streamText({
|
||||
// @ts-ignore - AI SDK type mismatch
|
||||
model: openrouter('google/gemini-3.0-flash'),
|
||||
messages,
|
||||
tools: activeTools,
|
||||
system: `You are a helpful Payload CMS MCP Assistant orchestrating the local Mintel ecosystem.
|
||||
You only have access to tools explicitly granted by the Admin.
|
||||
You cannot do anything outside these tools. Always explain what you are doing.
|
||||
${memorySystemPrompt}`
|
||||
})
|
||||
|
||||
return result.toTextStreamResponse()
|
||||
} catch (error) {
|
||||
console.error("AI Error:", error)
|
||||
return Response.json({ error: 'Failed to process AI request' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
"use server";
|
||||
|
||||
import { getPayloadHMR } from "@payloadcms/next/utilities";
|
||||
import configPromise from "@payload-config";
|
||||
import { PayloadRequest } from "payload";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
@@ -29,13 +26,9 @@ async function getOrchestrator() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateSlugAction(
|
||||
title: string,
|
||||
draftContent: string,
|
||||
oldSlug?: string,
|
||||
instructions?: string,
|
||||
) {
|
||||
export const generateSlugEndpoint = async (req: PayloadRequest) => {
|
||||
try {
|
||||
const { title, draftContent, oldSlug, instructions } = (await req.json?.() || {}) as any;
|
||||
const orchestrator = await getOrchestrator();
|
||||
const newSlug = await orchestrator.generateSlug(
|
||||
draftContent,
|
||||
@@ -44,9 +37,8 @@ export async function generateSlugAction(
|
||||
);
|
||||
|
||||
if (oldSlug && oldSlug !== newSlug) {
|
||||
const payload = await getPayloadHMR({ config: configPromise as any });
|
||||
await payload.create({
|
||||
collection: "redirects",
|
||||
await req.payload.create({
|
||||
collection: "redirects" as any,
|
||||
data: {
|
||||
from: oldSlug,
|
||||
to: newSlug,
|
||||
@@ -54,42 +46,25 @@ export async function generateSlugAction(
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, slug: newSlug };
|
||||
return Response.json({ success: true, slug: newSlug });
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
return Response.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateThumbnailAction(
|
||||
draftContent: string,
|
||||
title?: string,
|
||||
instructions?: string,
|
||||
) {
|
||||
export const generateThumbnailEndpoint = async (req: PayloadRequest) => {
|
||||
try {
|
||||
const payload = await getPayloadHMR({ config: configPromise as any });
|
||||
const { draftContent, title, instructions } = (await req.json?.() || {}) as any;
|
||||
const OPENROUTER_KEY =
|
||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
|
||||
|
||||
if (!OPENROUTER_KEY) {
|
||||
throw new Error("Missing OPENROUTER_API_KEY in .env");
|
||||
}
|
||||
if (!REPLICATE_KEY) {
|
||||
throw new Error(
|
||||
"Missing REPLICATE_API_KEY in .env (Required for Thumbnails)",
|
||||
);
|
||||
}
|
||||
if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY in .env");
|
||||
if (!REPLICATE_KEY) throw new Error("Missing REPLICATE_API_KEY in .env");
|
||||
|
||||
const importDynamic = new Function(
|
||||
"modulePath",
|
||||
"return import(modulePath)",
|
||||
);
|
||||
const { AiBlogPostOrchestrator } = await importDynamic(
|
||||
"@mintel/content-engine",
|
||||
);
|
||||
const { ThumbnailGenerator } = await importDynamic(
|
||||
"@mintel/thumbnail-generator",
|
||||
);
|
||||
const importDynamic = new Function("modulePath", "return import(modulePath)");
|
||||
const { AiBlogPostOrchestrator } = await importDynamic("@mintel/content-engine");
|
||||
const { ThumbnailGenerator } = await importDynamic("@mintel/thumbnail-generator");
|
||||
|
||||
const orchestrator = new AiBlogPostOrchestrator({
|
||||
apiKey: OPENROUTER_KEY,
|
||||
@@ -111,8 +86,8 @@ export async function generateThumbnailAction(
|
||||
const stat = await fs.stat(tmpPath);
|
||||
const fileName = path.basename(tmpPath);
|
||||
|
||||
const newMedia = await payload.create({
|
||||
collection: "media",
|
||||
const newMedia = await req.payload.create({
|
||||
collection: "media" as any,
|
||||
data: {
|
||||
alt: title ? `Thumbnail for ${title}` : "AI Generated Thumbnail",
|
||||
},
|
||||
@@ -124,31 +99,24 @@ export async function generateThumbnailAction(
|
||||
},
|
||||
});
|
||||
|
||||
// Cleanup temp file
|
||||
await fs.unlink(tmpPath).catch(() => { });
|
||||
|
||||
return { success: true, mediaId: newMedia.id };
|
||||
return Response.json({ success: true, mediaId: newMedia.id });
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
return Response.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
export async function generateSingleFieldAction(
|
||||
documentTitle: string,
|
||||
documentContent: string,
|
||||
fieldName: string,
|
||||
fieldDescription: string,
|
||||
instructions?: string,
|
||||
) {
|
||||
|
||||
export const generateSingleFieldEndpoint = async (req: PayloadRequest) => {
|
||||
try {
|
||||
const { documentTitle, documentContent, fieldName, fieldDescription, instructions } = (await req.json?.() || {}) as any;
|
||||
|
||||
const OPENROUTER_KEY =
|
||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||
if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY");
|
||||
|
||||
const payload = await getPayloadHMR({ config: configPromise as any });
|
||||
|
||||
// Fetch context documents from DB
|
||||
const contextDocsData = await payload.find({
|
||||
collection: "context-files",
|
||||
const contextDocsData = await req.payload.find({
|
||||
collection: "context-files" as any,
|
||||
limit: 100,
|
||||
});
|
||||
const projectContext = contextDocsData.docs
|
||||
@@ -183,8 +151,8 @@ CRITICAL RULES:
|
||||
});
|
||||
const data = await res.json();
|
||||
const text = data.choices?.[0]?.message?.content?.trim() || "";
|
||||
return { success: true, text };
|
||||
return Response.json({ success: true, text });
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
return Response.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
"use server";
|
||||
import { PayloadRequest } from 'payload'
|
||||
import { parseMarkdownToLexical } from "../utils/lexicalParser.js";
|
||||
|
||||
import { parseMarkdownToLexical } from "../utils/lexicalParser";
|
||||
import { getPayloadHMR } from "@payloadcms/next/utilities";
|
||||
import configPromise from "@payload-config";
|
||||
|
||||
export async function optimizePostText(
|
||||
draftContent: string,
|
||||
instructions?: string,
|
||||
) {
|
||||
export const optimizePostEndpoint = async (req: PayloadRequest) => {
|
||||
try {
|
||||
const payload = await getPayloadHMR({ config: configPromise as any });
|
||||
const globalAiSettings = (await payload.findGlobal({ slug: "ai-settings" })) as any;
|
||||
const { draftContent, instructions } = (await req.json?.() || {}) as { draftContent: string; instructions?: string };
|
||||
|
||||
if (!draftContent) {
|
||||
return Response.json({ error: 'Missing draftContent' }, { status: 400 })
|
||||
}
|
||||
|
||||
const globalAiSettings = (await req.payload.findGlobal({ slug: "ai-settings" })) as any;
|
||||
const customSources =
|
||||
globalAiSettings?.customSources?.map((s: any) => s.sourceName) || [];
|
||||
|
||||
@@ -19,18 +18,12 @@ export async function optimizePostText(
|
||||
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
|
||||
|
||||
if (!OPENROUTER_KEY) {
|
||||
throw new Error(
|
||||
"OPENROUTER_KEY or OPENROUTER_API_KEY not found in environment.",
|
||||
);
|
||||
return Response.json({ error: "OPENROUTER_KEY not found in environment." }, { status: 500 })
|
||||
}
|
||||
|
||||
const importDynamic = new Function(
|
||||
"modulePath",
|
||||
"return import(modulePath)",
|
||||
);
|
||||
const { AiBlogPostOrchestrator } = await importDynamic(
|
||||
"@mintel/content-engine",
|
||||
);
|
||||
// Dynamically import to avoid bundling it into client components that might accidentally import this file
|
||||
const importDynamic = new Function("modulePath", "return import(modulePath)");
|
||||
const { AiBlogPostOrchestrator } = await importDynamic("@mintel/content-engine");
|
||||
|
||||
const orchestrator = new AiBlogPostOrchestrator({
|
||||
apiKey: OPENROUTER_KEY,
|
||||
@@ -38,9 +31,8 @@ export async function optimizePostText(
|
||||
model: "google/gemini-3-flash-preview",
|
||||
});
|
||||
|
||||
// Fetch context documents purely from DB
|
||||
const contextDocsData = await payload.find({
|
||||
collection: "context-files",
|
||||
const contextDocsData = await req.payload.find({
|
||||
collection: "context-files" as any,
|
||||
limit: 100,
|
||||
});
|
||||
const projectContext = contextDocsData.docs.map((doc: any) => doc.content);
|
||||
@@ -48,19 +40,19 @@ export async function optimizePostText(
|
||||
const optimizedMarkdown = await orchestrator.optimizeDocument({
|
||||
content: draftContent,
|
||||
projectContext,
|
||||
availableComponents: [], // Removed hardcoded config.components dependency
|
||||
availableComponents: [],
|
||||
instructions,
|
||||
internalLinks: [],
|
||||
customSources,
|
||||
});
|
||||
|
||||
if (!optimizedMarkdown || typeof optimizedMarkdown !== "string") {
|
||||
throw new Error("AI returned invalid markup.");
|
||||
return Response.json({ error: "AI returned invalid markup." }, { status: 500 })
|
||||
}
|
||||
|
||||
const blocks = parseMarkdownToLexical(optimizedMarkdown);
|
||||
|
||||
return {
|
||||
return Response.json({
|
||||
success: true,
|
||||
lexicalAST: {
|
||||
root: {
|
||||
@@ -72,12 +64,12 @@ export async function optimizePostText(
|
||||
direction: "ltr",
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error("Failed to optimize post:", error);
|
||||
return {
|
||||
console.error("Failed to optimize post in endpoint:", error);
|
||||
return Response.json({
|
||||
success: false,
|
||||
error: error.message || "An unknown error occurred during optimization.",
|
||||
};
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,17 @@
|
||||
* Primary entry point for reusing Mintel AI extensions in Payload CMS.
|
||||
*/
|
||||
|
||||
export * from './globals/AiSettings';
|
||||
export * from './actions/generateField';
|
||||
export * from './actions/optimizePost';
|
||||
export * from './components/FieldGenerators/AiFieldButton';
|
||||
export * from './components/AiMediaButtons';
|
||||
export * from './components/OptimizeButton';
|
||||
export * from './components/FieldGenerators/GenerateThumbnailButton';
|
||||
export * from './components/FieldGenerators/GenerateSlugButton';
|
||||
export * from './utils/lexicalParser';
|
||||
export * from './endpoints/replicateMediaEndpoint';
|
||||
export * from './globals/AiSettings.js';
|
||||
export * from './components/FieldGenerators/AiFieldButton.js';
|
||||
export * from './components/AiMediaButtons.js';
|
||||
export * from './components/OptimizeButton.js';
|
||||
export * from './components/FieldGenerators/GenerateThumbnailButton.js';
|
||||
export * from './components/FieldGenerators/GenerateSlugButton.js';
|
||||
export * from './utils/lexicalParser.js';
|
||||
export * from './endpoints/replicateMediaEndpoint.js';
|
||||
export * from './chatPlugin.js';
|
||||
export * from './types.js';
|
||||
export * from './endpoints/chatEndpoint.js';
|
||||
export * from './tools/mcpAdapter.js';
|
||||
export * from './tools/memoryDb.js';
|
||||
export * from './tools/payloadLocal.js';
|
||||
|
||||
65
packages/payload-ai/src/tools/mcpAdapter.ts
Normal file
65
packages/payload-ai/src/tools/mcpAdapter.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Connects to an external MCP Server and maps its tools to Vercel AI SDK Tools.
|
||||
*/
|
||||
export async function createMcpTools(mcpConfig: { name: string, url?: string, command?: string, args?: string[] }) {
|
||||
let transport
|
||||
|
||||
// Support both HTTP/SSE and STDIO transports
|
||||
if (mcpConfig.url) {
|
||||
transport = new SSEClientTransport(new URL(mcpConfig.url))
|
||||
} else if (mcpConfig.command) {
|
||||
transport = new StdioClientTransport({
|
||||
command: mcpConfig.command,
|
||||
args: mcpConfig.args || [],
|
||||
})
|
||||
} else {
|
||||
throw new Error('Invalid MCP config: Must provide either URL or Command.')
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
{ name: `payload-ai-client-${mcpConfig.name}`, version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
)
|
||||
|
||||
await client.connect(transport)
|
||||
|
||||
// Fetch available tools from the external MCP server
|
||||
const toolListResult = await client.listTools()
|
||||
const externalTools = toolListResult.tools || []
|
||||
|
||||
const aiSdkTools: Record<string, any> = {}
|
||||
|
||||
// Map each external tool to a Vercel AI SDK Tool
|
||||
for (const extTool of externalTools) {
|
||||
// Basic conversion of JSON Schema to Zod for the AI SDK
|
||||
// Note: For a production ready adapter, you might need a more robust jsonSchemaToZod converter
|
||||
// or use AI SDK's new experimental generateSchema feature if available.
|
||||
// Here we use a generic `z.any()` as a fallback since AI SDK requires a Zod schema.
|
||||
const toolSchema = extTool.inputSchema as Record<string, any>
|
||||
|
||||
// We create a simplified parameter parser.
|
||||
// An ideal approach uses `jsonSchemaToZod` library or native AI SDK JSON schema support
|
||||
// (introduced recently in `ai` package).
|
||||
|
||||
aiSdkTools[`${mcpConfig.name}_${extTool.name}`] = tool({
|
||||
description: `[From ${mcpConfig.name}] ${extTool.description || extTool.name}`,
|
||||
parameters: z.any().describe('JSON matching the original MCP input_schema'), // Simplify for prototype
|
||||
// @ts-ignore - AI strict mode overload bug with implicit zod inferences
|
||||
execute: async (args: any) => {
|
||||
const result = await client.callTool({
|
||||
name: extTool.name,
|
||||
arguments: args
|
||||
})
|
||||
return result
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { tools: aiSdkTools, client }
|
||||
}
|
||||
115
packages/payload-ai/src/tools/memoryDb.ts
Normal file
115
packages/payload-ai/src/tools/memoryDb.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import { QdrantClient } from '@qdrant/js-client-rest'
|
||||
|
||||
// Qdrant initialization
|
||||
// This requires the user to have Qdrant running and QDRANT_URL/QDRANT_API_KEY environment variables set
|
||||
const qdrantClient = new QdrantClient({
|
||||
url: process.env.QDRANT_URL || 'http://localhost:6333',
|
||||
apiKey: process.env.QDRANT_API_KEY,
|
||||
})
|
||||
|
||||
const MEMORY_COLLECTION = 'mintel_ai_memory'
|
||||
|
||||
// Ensure collection exists on load
|
||||
async function initQdrant() {
|
||||
try {
|
||||
const res = await qdrantClient.getCollections()
|
||||
const exists = res.collections.find((c: any) => c.name === MEMORY_COLLECTION)
|
||||
if (!exists) {
|
||||
await qdrantClient.createCollection(MEMORY_COLLECTION, {
|
||||
vectors: {
|
||||
size: 1536, // typical embedding size, adjust based on the embedding model used
|
||||
distance: 'Cosine',
|
||||
},
|
||||
})
|
||||
console.log(`Qdrant collection '${MEMORY_COLLECTION}' created.`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Qdrant memory collection:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Call init, but don't block
|
||||
initQdrant()
|
||||
|
||||
/**
|
||||
* Returns memory tools for the AI SDK.
|
||||
* Note: A real implementation would require an embedding step before inserting into Qdrant.
|
||||
* For this implementation, we use a placeholder or assume the embeddings are handled
|
||||
* by a utility function, or we use Qdrant's FastEmbed (if running their specialized container).
|
||||
*/
|
||||
export const generateMemoryTools = (userId: string | number) => {
|
||||
return {
|
||||
save_memory: tool({
|
||||
description: 'Save an important preference, fact, or instruction about the user to long-term memory. Only use this when explicitly asked or when it is clearly a long-term preference.',
|
||||
parameters: z.object({
|
||||
fact: z.string().describe('The fact or instruction to remember.'),
|
||||
category: z.string().optional().describe('An optional category like "preference", "rule", or "project_detail".'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode bug
|
||||
execute: async ({ fact, category }: { fact: string; category?: string }) => {
|
||||
// In a real scenario, you MUST generate embeddings for the 'fact' string here
|
||||
// using OpenAI or another embedding provider before inserting into Qdrant.
|
||||
// const embedding = await generateEmbedding(fact)
|
||||
|
||||
try {
|
||||
// Mock embedding payload for demonstration
|
||||
const mockEmbedding = new Array(1536).fill(0).map(() => Math.random())
|
||||
|
||||
await qdrantClient.upsert(MEMORY_COLLECTION, {
|
||||
wait: true,
|
||||
points: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
vector: mockEmbedding,
|
||||
payload: {
|
||||
userId: String(userId), // Partition memory by user
|
||||
fact,
|
||||
category,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
return { success: true, message: `Successfully remembered: "${fact}"` }
|
||||
} catch (error) {
|
||||
console.error("Qdrant save error:", error)
|
||||
return { success: false, error: 'Failed to save to memory database.' }
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
search_memory: tool({
|
||||
description: 'Search the user\'s long-term memory for past factual context, preferences, or rules.',
|
||||
parameters: z.object({
|
||||
query: z.string().describe('The search string to find in memory.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode bug
|
||||
execute: async ({ query }: { query: string }) => {
|
||||
// Generate embedding for query
|
||||
const mockQueryEmbedding = new Array(1536).fill(0).map(() => Math.random())
|
||||
|
||||
try {
|
||||
const results = await qdrantClient.search(MEMORY_COLLECTION, {
|
||||
vector: mockQueryEmbedding,
|
||||
limit: 5,
|
||||
filter: {
|
||||
must: [
|
||||
{
|
||||
key: 'userId',
|
||||
match: { value: String(userId) }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
return results.map((r: any) => r.payload?.fact || '')
|
||||
} catch (error) {
|
||||
console.error("Qdrant search error:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
107
packages/payload-ai/src/tools/payloadLocal.ts
Normal file
107
packages/payload-ai/src/tools/payloadLocal.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import type { Payload, PayloadRequest, User } from 'payload'
|
||||
|
||||
export const generatePayloadLocalTools = (
|
||||
payload: Payload,
|
||||
req: PayloadRequest,
|
||||
allowedCollections: string[]
|
||||
) => {
|
||||
const tools: Record<string, any> = {}
|
||||
|
||||
for (const collectionSlug of allowedCollections) {
|
||||
const slugKey = collectionSlug.replace(/-/g, '_')
|
||||
|
||||
// 1. Read (Find) Tool
|
||||
tools[`read_${slugKey}`] = tool({
|
||||
description: `Read/Find documents from the Payload CMS collection: ${collectionSlug}`,
|
||||
parameters: z.object({
|
||||
limit: z.number().optional().describe('Number of documents to return, max 100.'),
|
||||
page: z.number().optional().describe('Page number for pagination.'),
|
||||
// Simple string-based query for demo purposes. For a robust implementation,
|
||||
// we'd map this to Payload's where query logic using a structured Zod schema.
|
||||
query: z.string().optional().describe('Optional text to search within the collection.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode type inference bug
|
||||
execute: async ({ limit = 10, page = 1, query }: { limit?: number; page?: number; query?: string }) => {
|
||||
const where = query ? { id: { equals: query } } : undefined // Placeholder logic
|
||||
|
||||
return await payload.find({
|
||||
collection: collectionSlug as any,
|
||||
limit: Math.min(limit, 100),
|
||||
page,
|
||||
where,
|
||||
req, // Crucial for passing the user context and respecting access control!
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// 2. Read by ID Tool
|
||||
tools[`read_${slugKey}_by_id`] = tool({
|
||||
description: `Get a specific document by its ID from the ${collectionSlug} collection.`,
|
||||
parameters: z.object({
|
||||
id: z.union([z.string(), z.number()]).describe('The ID of the document.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode type inference bug
|
||||
execute: async ({ id }: { id: string | number }) => {
|
||||
return await payload.findByID({
|
||||
collection: collectionSlug as any,
|
||||
id,
|
||||
req, // Enforce access control
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// 3. Create Tool
|
||||
tools[`create_${slugKey}`] = tool({
|
||||
description: `Create a new document in the ${collectionSlug} collection.`,
|
||||
parameters: z.object({
|
||||
data: z.record(z.any()).describe('A JSON object containing the data to insert.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode type inference bug
|
||||
execute: async ({ data }: { data: Record<string, any> }) => {
|
||||
return await payload.create({
|
||||
collection: collectionSlug as any,
|
||||
data,
|
||||
req, // Enforce access control
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// 4. Update Tool
|
||||
tools[`update_${slugKey}`] = tool({
|
||||
description: `Update an existing document in the ${collectionSlug} collection.`,
|
||||
parameters: z.object({
|
||||
id: z.union([z.string(), z.number()]).describe('The ID of the document to update.'),
|
||||
data: z.record(z.any()).describe('A JSON object containing the fields to update.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode type inference bug
|
||||
execute: async ({ id, data }: { id: string | number; data: Record<string, any> }) => {
|
||||
return await payload.update({
|
||||
collection: collectionSlug as any,
|
||||
id,
|
||||
data,
|
||||
req, // Enforce access control
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// 5. Delete Tool
|
||||
tools[`delete_${slugKey}`] = tool({
|
||||
description: `Delete a document from the ${collectionSlug} collection by ID.`,
|
||||
parameters: z.object({
|
||||
id: z.union([z.string(), z.number()]).describe('The ID of the document to delete.'),
|
||||
}),
|
||||
// @ts-ignore - AI SDK strict mode type inference bug
|
||||
execute: async ({ id }: { id: string | number }) => {
|
||||
return await payload.delete({
|
||||
collection: collectionSlug as any,
|
||||
id,
|
||||
req, // Enforce access control
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
11
packages/payload-ai/src/types.d.ts
vendored
11
packages/payload-ai/src/types.d.ts
vendored
@@ -1,5 +1,8 @@
|
||||
declare module "@payload-config" {
|
||||
import { Config } from "payload";
|
||||
const configPromise: Promise<Config>;
|
||||
export default configPromise;
|
||||
export type PayloadChatPluginConfig = {
|
||||
enabled?: boolean
|
||||
/** Render the chat bubble on the bottom right? Defaults to true */
|
||||
renderChatBubble?: boolean
|
||||
allowedCollections?: string[]
|
||||
mcpServers?: any[]
|
||||
}
|
||||
|
||||
|
||||
18
packages/payload-ai/src/types.ts
Normal file
18
packages/payload-ai/src/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Plugin } from 'payload'
|
||||
|
||||
export interface PayloadChatPluginConfig {
|
||||
enabled?: boolean
|
||||
/**
|
||||
* Defines whether to render the floating chat bubble in the admin panel automatically.
|
||||
* Defaults to true.
|
||||
*/
|
||||
renderChatBubble?: boolean
|
||||
/**
|
||||
* Used to register external MCP servers that the AI can explicitly connect to if the admin permits it.
|
||||
*/
|
||||
mcpServers?: {
|
||||
name: string
|
||||
url?: string
|
||||
// Command based STDIO later via configuration
|
||||
}[]
|
||||
}
|
||||
@@ -12,15 +12,24 @@
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"paths": {
|
||||
"@payload-config": [
|
||||
"../../apps/mintel.me/payload.config.ts",
|
||||
"../../apps/web/payload.config.ts",
|
||||
"./node_modules/@payloadcms/next/dist/index.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
"src/**/*",
|
||||
"src/types.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/pdf",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/seo-engine",
|
||||
"version": "1.9.8",
|
||||
"private": true,
|
||||
"version": "1.9.10",
|
||||
"description": "AI-powered SEO keyword and topic cluster evaluation engine",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/thumbnail-generator",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/tsconfig",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
628
pnpm-lock.yaml
generated
628
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user