feat: content engine
This commit is contained in:
1
.env
1
.env
@@ -3,6 +3,7 @@ IMAGE_TAG=v1.8.10
|
||||
PROJECT_NAME=at-mintel
|
||||
PROJECT_COLOR=#82ed20
|
||||
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
||||
OPENROUTER_API_KEY=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
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=mintel
|
||||
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
|
||||
# Run the prune script on the host
|
||||
# We transfer the script and execute it to ensure it matches the repo version
|
||||
scp packages/infra/scripts/prune-registry.sh root@${{ secrets.SSH_HOST }}:/tmp/prune-registry.sh
|
||||
ssh root@${{ secrets.SSH_HOST }} "bash /tmp/prune-registry.sh && rm /tmp/prune-registry.sh"
|
||||
scp packages/infra/scripts/mintel-optimizer.sh root@${{ secrets.SSH_HOST }}:/tmp/mintel-optimizer.sh
|
||||
ssh root@${{ secrets.SSH_HOST }} "bash /tmp/mintel-optimizer.sh && rm /tmp/mintel-optimizer.sh"
|
||||
|
||||
- name: 🔔 Notification - Success
|
||||
if: success()
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -40,3 +40,5 @@ Thumbs.db
|
||||
directus/extensions/
|
||||
packages/cms-infra/extensions/
|
||||
packages/cms-infra/uploads/
|
||||
|
||||
directus/uploads/directus-health-file
|
||||
Binary file not shown.
@@ -25,6 +25,8 @@ services:
|
||||
LOG_LEVEL: "debug"
|
||||
SERVE_APP: "true"
|
||||
EXTENSIONS_AUTO_RELOAD: "true"
|
||||
EXTENSIONS_SANDBOX: "false"
|
||||
CONTENT_SECURITY_POLICY: "false"
|
||||
volumes:
|
||||
- ./database:/directus/database
|
||||
- ./uploads:/directus/uploads
|
||||
@@ -37,11 +39,12 @@ services:
|
||||
retries: 5
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.at-mintel-infra-cms.rule=Host(`cms.localhost`)"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=cms.localhost"
|
||||
- "caddy.reverse_proxy={{upstreams 8055}}"
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.at-mintel-infra-cms.rule: "Host(`cms.localhost`)"
|
||||
traefik.docker.network: "infra"
|
||||
caddy: "http://cms.localhost"
|
||||
caddy.reverse_proxy: "{{upstreams 8055}}"
|
||||
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate, max-age=0"
|
||||
|
||||
networks:
|
||||
default:
|
||||
|
||||
48
packages/content-engine/examples/generate-post.ts
Normal file
48
packages/content-engine/examples/generate-post.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ContentGenerator } from "../src/index";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Load .env from mintel.me (since that's where the key is)
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ OPENROUTER_API_KEY not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generator = new ContentGenerator(apiKey);
|
||||
|
||||
const topic = "Why traditional CMSs are dead for developers";
|
||||
console.log(`🚀 Generating post for: "${topic}"`);
|
||||
|
||||
try {
|
||||
const post = await generator.generatePost({
|
||||
topic,
|
||||
includeResearch: true,
|
||||
includeDiagrams: true,
|
||||
includeMemes: true,
|
||||
});
|
||||
|
||||
console.log("\n\n✅ GENERATION COMPLETE");
|
||||
console.log("--------------------------------------------------");
|
||||
console.log(`Title: ${post.title}`);
|
||||
console.log(`Research Points: ${post.research.length}`);
|
||||
console.log(`Memes Generated: ${post.memes.length}`);
|
||||
console.log(`Diagrams Generated: ${post.diagrams.length}`);
|
||||
console.log("--------------------------------------------------");
|
||||
|
||||
// Save to file
|
||||
const outputPath = path.join(__dirname, "output.md");
|
||||
fs.writeFileSync(outputPath, post.content);
|
||||
console.log(`📄 Saved output to: ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Generation failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
58
packages/content-engine/examples/optimize-post.ts
Normal file
58
packages/content-engine/examples/optimize-post.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ContentGenerator } from "../src/index";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Fix __dirname for ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load .env from mintel.me (since that's where the key is)
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ OPENROUTER_API_KEY not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generator = new ContentGenerator(apiKey);
|
||||
|
||||
const draftContent = `# The Case for Static Sites
|
||||
|
||||
Static sites are faster and more secure. They don't have a database to hack.
|
||||
They are also cheaper to host. You can use a CDN to serve them globally.
|
||||
Dynamic sites are complex and prone to errors.`;
|
||||
|
||||
console.log("📄 Original Content:");
|
||||
console.log(draftContent);
|
||||
console.log("\n🚀 Optimizing content...\n");
|
||||
|
||||
try {
|
||||
const post = await generator.optimizePost(draftContent, {
|
||||
enhanceFacts: true,
|
||||
addDiagrams: true,
|
||||
addMemes: true,
|
||||
});
|
||||
|
||||
console.log("\n\n✅ OPTIMIZATION COMPLETE");
|
||||
console.log("--------------------------------------------------");
|
||||
console.log(`Research Points Added: ${post.research.length}`);
|
||||
console.log(`Memes Generated: ${post.memes.length}`);
|
||||
console.log(`Diagrams Generated: ${post.diagrams.length}`);
|
||||
console.log("--------------------------------------------------");
|
||||
|
||||
// Save to file
|
||||
const outputPath = path.join(__dirname, "optimized.md");
|
||||
fs.writeFileSync(outputPath, post.content);
|
||||
console.log(`📄 Saved output to: ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Optimization failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
132
packages/content-engine/examples/optimize-vendor-lockin.ts
Normal file
132
packages/content-engine/examples/optimize-vendor-lockin.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ContentGenerator, ComponentDefinition } from "../src/index";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Fix __dirname for ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load .env from mintel.me
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ OPENROUTER_API_KEY not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generator = new ContentGenerator(apiKey);
|
||||
|
||||
const contentToOptimize = `
|
||||
"Wir können nicht wechseln, das wäre zu teuer."
|
||||
In meiner Arbeit als Digital Architect ist das der Anfang vom Ende jeder technologischen Innovation.
|
||||
Vendor Lock-In ist die digitale Version einer Geiselnahme.
|
||||
Ich zeige Ihnen, wie wir Systeme bauen, die Ihnen jederzeit die volle Freiheit lassen – technologisch und wirtschaftlich.
|
||||
|
||||
Die unsichtbaren Ketten proprietärer Systeme
|
||||
Viele Unternehmen lassen sich von der Bequemlichkeit großer SaaS-Plattformen oder Baukästen blenden.
|
||||
Man bekommt ein schnelles Feature, gibt aber dafür die Kontrolle über seine Daten und seine Codebasis ab.
|
||||
Nach zwei Jahren sind Sie so tief im Ökosystem eines Anbieters verstrickt, dass ein Auszug unmöglich scheint.
|
||||
Der Anbieter weiß das – und diktiert fortan die Preise und das Tempo Ihrer Entwicklung.
|
||||
Ich nenne das technologische Erpressbarkeit.
|
||||
Wahre Unabhängigkeit beginnt bei der strategischen Wahl der Architektur.
|
||||
|
||||
Technologische Souveränität als Asset
|
||||
Software sollte für Sie arbeiten, nicht umgekehrt.
|
||||
Indem wir auf offene Standards und portable Architekturen setzen, verwandeln wir Code in ein echtes Firmen-Asset.
|
||||
Sie können den Cloud-Anbieter wechseln, die Agentur tauschen oder das Team skalieren – ohne jemals bei Null anfangen zu müssen.
|
||||
Das ist das Privileg der technologischen Elite.
|
||||
Portabilität ist kein technisches Gimmick, sondern eine unternehmerische Notwendigkeit.
|
||||
|
||||
Meine Architektur der Ungebundenheit
|
||||
Ich baue keine "Käfige" aus fertigen Plugins.
|
||||
Mein Framework basiert auf Modularität und Klarheit.
|
||||
|
||||
Standard-basiertes Engineering: Wir nutzen Technologien, die weltweit verstanden werden. Keine geheimen "Spezial-Module" eines einzelnen Anbieters.
|
||||
Daten-Portabilität: Ihre Daten gehören Ihnen. Zu jeder Zeit. Wir bauen Schnittstellen, die den Export so einfach machen wie den Import.
|
||||
Cloud-agnostisches Hosting: Wir nutzen Container-Technologie. Ob AWS, Azure oder lokale Anbieter – Ihr Code läuft überall gleich perfekt.
|
||||
|
||||
Der strategische Hebel für langfristige Rendite
|
||||
Systeme ohne Lock-In altern besser.
|
||||
Sie lassen sich schrittweise modernisieren, statt alle fünf Jahre komplett neu gebaut werden zu müssen.
|
||||
Das spart Millionen an Opportunitätskosten und Fehl-Investitionen.
|
||||
Seien Sie der Herr über Ihr digitales Schicksal.
|
||||
Investieren Sie in intelligente Unabhängigkeit.
|
||||
|
||||
Für wen ich 'Freiheits-Systeme' erstelle
|
||||
Ich arbeite für Gründer, die ihr Unternehmen langfristig wertvoll aufstellen wollen.
|
||||
Ist digitale Exzellenz Teil Ihrer Exit-Strategie oder Ihres Erbes? Dann brauchen Sie meine Architektur.
|
||||
Ich baue keine Provisorien, sondern nachhaltige Werte.
|
||||
|
||||
Fazit: Freiheit ist eine Wahl
|
||||
Technologie sollte Ihnen Flügel verleihen, keine Fesseln anlegen.
|
||||
Lassen Sie uns gemeinsam ein System schaffen, das so flexibel ist wie Ihr Business.
|
||||
Werden Sie unersetzbar durch Qualität, nicht durch Abhängigkeit. Ihr Erfolg verdient absolute Freiheit.
|
||||
`;
|
||||
|
||||
// Define components available in mintel.me
|
||||
const availableComponents: ComponentDefinition[] = [
|
||||
{
|
||||
name: "LeadParagraph",
|
||||
description: "Large, introductory text for the beginning of the article.",
|
||||
usageExample: "<LeadParagraph>First meaningful sentence.</LeadParagraph>",
|
||||
},
|
||||
{
|
||||
name: "H2",
|
||||
description: "Section heading.",
|
||||
usageExample: "<H2>Section Title</H2>",
|
||||
},
|
||||
{
|
||||
name: "H3",
|
||||
description: "Subsection heading.",
|
||||
usageExample: "<H3>Subtitle</H3>",
|
||||
},
|
||||
{
|
||||
name: "Paragraph",
|
||||
description: "Standard body text paragraph.",
|
||||
usageExample: "<Paragraph>Some text...</Paragraph>",
|
||||
},
|
||||
{
|
||||
name: "ArticleBlockquote",
|
||||
description: "A prominent quote block for key insights.",
|
||||
usageExample: "<ArticleBlockquote>Important quote</ArticleBlockquote>",
|
||||
},
|
||||
{
|
||||
name: "Marker",
|
||||
description: "Yellow highlighter effect for very important phrases.",
|
||||
usageExample: "<Marker>Highlighted Text</Marker>",
|
||||
},
|
||||
{
|
||||
name: "ComparisonRow",
|
||||
description: "A component comparing a negative vs positive scenario.",
|
||||
usageExample:
|
||||
'<ComparisonRow description="Cost Comparison" negativeLabel="Lock-In" negativeText="High costs" positiveLabel="Open" positiveText="Control" />',
|
||||
},
|
||||
];
|
||||
|
||||
console.log('🚀 Optimizing "Vendor Lock-In" post...');
|
||||
|
||||
try {
|
||||
const post = await generator.optimizePost(contentToOptimize, {
|
||||
enhanceFacts: true,
|
||||
addDiagrams: true,
|
||||
addMemes: true,
|
||||
availableComponents,
|
||||
});
|
||||
|
||||
console.log("\n\n✅ OPTIMIZATION COMPLETE");
|
||||
// Save to a file in the package dir
|
||||
const outputPath = path.join(__dirname, "VendorLockIn_OPTIMIZED.md");
|
||||
fs.writeFileSync(outputPath, post.content);
|
||||
console.log(`📄 Saved output to: ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Optimization failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
71
packages/content-engine/examples/optimize-with-components.ts
Normal file
71
packages/content-engine/examples/optimize-with-components.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ContentGenerator, ComponentDefinition } from "../src/index";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Fix __dirname for ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load .env from mintel.me (since that's where the key is)
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ OPENROUTER_API_KEY not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generator = new ContentGenerator(apiKey);
|
||||
|
||||
const draftContent = `# Improving User Retention
|
||||
|
||||
User retention is key. You need to keep users engaged.
|
||||
Offer them value and they will stay.
|
||||
If they have questions, they should contact support.`;
|
||||
|
||||
const availableComponents: ComponentDefinition[] = [
|
||||
{
|
||||
name: "InfoCard",
|
||||
description: "A colored box to highlight important tips or warnings.",
|
||||
usageExample:
|
||||
'<InfoCard variant="warning" title="Pro Tip">Always measure retention.</InfoCard>',
|
||||
},
|
||||
{
|
||||
name: "CallToAction",
|
||||
description: "A prominent button for conversion.",
|
||||
usageExample: '<CallToAction href="/contact">Get in Touch</CallToAction>',
|
||||
},
|
||||
];
|
||||
|
||||
console.log("📄 Original Content:");
|
||||
console.log(draftContent);
|
||||
console.log("\n🚀 Optimizing content with components...\n");
|
||||
|
||||
try {
|
||||
const post = await generator.optimizePost(draftContent, {
|
||||
enhanceFacts: true,
|
||||
addDiagrams: false, // Skip diagrams for this test to focus on components
|
||||
addMemes: false,
|
||||
availableComponents,
|
||||
});
|
||||
|
||||
console.log("\n\n✅ OPTIMIZATION COMPLETE");
|
||||
console.log("--------------------------------------------------");
|
||||
console.log(post.content);
|
||||
console.log("--------------------------------------------------");
|
||||
|
||||
// Save to file
|
||||
const outputPath = path.join(__dirname, "optimized-components.md");
|
||||
fs.writeFileSync(outputPath, post.content);
|
||||
console.log(`📄 Saved output to: ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Optimization failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
33
packages/content-engine/package.json
Normal file
33
packages/content-engine/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@mintel/content-engine",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/journaling": "workspace:*",
|
||||
"@mintel/meme-generator": "workspace:*",
|
||||
"dotenv": "^17.3.1",
|
||||
"openai": "^4.82.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
974
packages/content-engine/src/generator.ts
Normal file
974
packages/content-engine/src/generator.ts
Normal file
@@ -0,0 +1,974 @@
|
||||
import OpenAI from "openai";
|
||||
import { ResearchAgent, Fact, SocialPost } from "@mintel/journaling";
|
||||
import { MemeGenerator, MemeSuggestion } from "@mintel/meme-generator";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface ComponentDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
usageExample: string;
|
||||
}
|
||||
|
||||
export interface BlogPostOptions {
|
||||
topic: string;
|
||||
tone?: string;
|
||||
targetAudience?: string;
|
||||
includeMemes?: boolean;
|
||||
includeDiagrams?: boolean;
|
||||
includeResearch?: boolean;
|
||||
availableComponents?: ComponentDefinition[];
|
||||
}
|
||||
|
||||
export interface OptimizationOptions {
|
||||
enhanceFacts?: boolean;
|
||||
addMemes?: boolean;
|
||||
addDiagrams?: boolean;
|
||||
availableComponents?: ComponentDefinition[];
|
||||
projectContext?: string;
|
||||
/** Target audience description for all AI prompts */
|
||||
targetAudience?: string;
|
||||
/** Tone/persona description for all AI prompts */
|
||||
tone?: string;
|
||||
/** Prompt for DALL-E 3 style generation */
|
||||
memeStylePrompt?: string;
|
||||
/** Path to the docs folder (e.g. apps/web/docs) for full persona/tone context */
|
||||
docsPath?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedPost {
|
||||
title: string;
|
||||
content: string;
|
||||
research: Fact[];
|
||||
memes: MemeSuggestion[];
|
||||
diagrams: string[];
|
||||
}
|
||||
|
||||
interface Insertion {
|
||||
afterSection: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Model configuration: specialized models for different tasks
|
||||
const MODELS = {
|
||||
// Structured JSON output, research planning, diagram models: {
|
||||
STRUCTURED: "google/gemini-2.5-flash",
|
||||
ROUTING: "google/gemini-2.5-flash",
|
||||
CONTENT: "google/gemini-2.5-pro",
|
||||
// Mermaid diagram generation - User requested Pro
|
||||
DIAGRAM: "google/gemini-2.5-pro",
|
||||
} as const;
|
||||
|
||||
/** Strip markdown fences that some models wrap around JSON despite response_format */
|
||||
function safeParseJSON(raw: string, fallback: any = {}): any {
|
||||
let cleaned = raw.trim();
|
||||
// Remove ```json ... ``` or ``` ... ``` wrapping
|
||||
if (cleaned.startsWith("```")) {
|
||||
cleaned = cleaned
|
||||
.replace(/^```(?:json)?\s*\n?/, "")
|
||||
.replace(/\n?```\s*$/, "");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(cleaned);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"⚠️ Failed to parse JSON response, using fallback:",
|
||||
(e as Error).message,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export class ContentGenerator {
|
||||
private openai: OpenAI;
|
||||
private researchAgent: ResearchAgent;
|
||||
private memeGenerator: MemeGenerator;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.openai = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://mintel.me",
|
||||
"X-Title": "Mintel Content Engine",
|
||||
},
|
||||
});
|
||||
this.researchAgent = new ResearchAgent(apiKey);
|
||||
this.memeGenerator = new MemeGenerator(apiKey);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// generatePost — for new posts (unchanged from original)
|
||||
// =========================================================================
|
||||
async generatePost(options: BlogPostOptions): Promise<GeneratedPost> {
|
||||
const {
|
||||
topic,
|
||||
tone = "professional yet witty",
|
||||
includeResearch = true,
|
||||
availableComponents = [],
|
||||
} = options;
|
||||
console.log(`🚀 Starting content generation for: "${topic}"`);
|
||||
|
||||
let facts: Fact[] = [];
|
||||
if (includeResearch) {
|
||||
console.log("📚 Gathering research...");
|
||||
facts = await this.researchAgent.researchTopic(topic);
|
||||
}
|
||||
|
||||
console.log("📝 Creating outline...");
|
||||
const outline = await this.createOutline(topic, facts, tone);
|
||||
|
||||
console.log("✍️ Drafting content...");
|
||||
let content = await this.draftContent(
|
||||
topic,
|
||||
outline,
|
||||
facts,
|
||||
tone,
|
||||
availableComponents,
|
||||
);
|
||||
|
||||
const diagrams: string[] = [];
|
||||
if (options.includeDiagrams) {
|
||||
content = await this.processDiagramPlaceholders(content, diagrams);
|
||||
}
|
||||
|
||||
const memes: MemeSuggestion[] = [];
|
||||
if (options.includeMemes) {
|
||||
const memeIdeas = await this.memeGenerator.generateMemeIdeas(
|
||||
content.slice(0, 4000),
|
||||
);
|
||||
memes.push(...memeIdeas);
|
||||
}
|
||||
|
||||
return { title: outline.title, content, research: facts, memes, diagrams };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// generateTldr — Creates a TL;DR block for the given content
|
||||
// =========================================================================
|
||||
async generateTldr(content: string): Promise<string> {
|
||||
const context = content.slice(0, 3000);
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Du bist ein kompromissloser Digital Architect.
|
||||
Erstelle ein "TL;DR" für diesen Artikel.
|
||||
|
||||
REGELN:
|
||||
- 3 knackige Bulletpoints
|
||||
- TON: Sarkastisch, direkt, provokant ("Finger in die Wunde")
|
||||
- Fokussiere auf den wirtschaftlichen Schaden von schlechter Tech
|
||||
- Formatiere als MDX-Komponente:
|
||||
<div className="my-8 p-6 bg-slate-50 border-l-4 border-blue-600 rounded-r-xl">
|
||||
<H3>TL;DR: Warum Ihr Geld verbrennt</H3>
|
||||
<ul className="list-disc pl-5 space-y-2 mb-0">
|
||||
<li>Punkt 1</li>
|
||||
<li>Punkt 2</li>
|
||||
<li>Punkt 3</li>
|
||||
</ul>
|
||||
</div>`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: context,
|
||||
},
|
||||
],
|
||||
});
|
||||
return response.choices[0].message.content?.trim() ?? "";
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// optimizePost — ADDITIVE architecture (never rewrites original content)
|
||||
// =========================================================================
|
||||
async optimizePost(
|
||||
content: string,
|
||||
options: OptimizationOptions,
|
||||
): Promise<GeneratedPost> {
|
||||
console.log("🚀 Optimizing existing content (additive mode)...");
|
||||
|
||||
// Load docs context if provided
|
||||
let docsContext = "";
|
||||
if (options.docsPath) {
|
||||
docsContext = await this.loadDocsContext(options.docsPath);
|
||||
console.log(`📖 Loaded ${docsContext.length} chars of docs context`);
|
||||
}
|
||||
|
||||
const fullContext = [options.projectContext || "", docsContext]
|
||||
.filter(Boolean)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
// Split content into numbered sections for programmatic insertion
|
||||
const sections = this.splitIntoSections(content);
|
||||
console.log(`📋 Content has ${sections.length} sections`);
|
||||
|
||||
const insertions: Insertion[] = [];
|
||||
const facts: Fact[] = [];
|
||||
const diagrams: string[] = [];
|
||||
const memes: MemeSuggestion[] = [];
|
||||
|
||||
// Build a numbered content map for LLM reference (read-only)
|
||||
const sectionMap = this.buildSectionMap(sections);
|
||||
|
||||
// ----- STEP 1: Research -----
|
||||
if (options.enhanceFacts) {
|
||||
console.log("🔍 Identifying research topics...");
|
||||
const researchTopics = await this.identifyResearchTopics(
|
||||
content,
|
||||
fullContext,
|
||||
);
|
||||
console.log(`📚 Researching: ${researchTopics.join(", ")}`);
|
||||
|
||||
for (const topic of researchTopics) {
|
||||
const topicFacts = await this.researchAgent.researchTopic(topic);
|
||||
facts.push(...topicFacts);
|
||||
}
|
||||
|
||||
if (facts.length > 0) {
|
||||
console.log(`📝 Planning fact insertions for ${facts.length} facts...`);
|
||||
const factInsertions = await this.planFactInsertions(
|
||||
sectionMap,
|
||||
sections,
|
||||
facts,
|
||||
fullContext,
|
||||
);
|
||||
insertions.push(...factInsertions);
|
||||
console.log(` → ${factInsertions.length} fact enrichments planned`);
|
||||
}
|
||||
|
||||
// ----- STEP 1.5: Social Media Search -----
|
||||
console.log("📱 Identifying real social media posts...");
|
||||
const socialPosts = await this.researchAgent.findSocialPosts(
|
||||
content.substring(0, 200),
|
||||
);
|
||||
if (socialPosts.length > 0) {
|
||||
console.log(
|
||||
`📝 Planning placement for ${socialPosts.length} social media posts...`,
|
||||
);
|
||||
const socialInsertions = await this.planSocialMediaInsertions(
|
||||
sectionMap,
|
||||
sections,
|
||||
socialPosts,
|
||||
fullContext,
|
||||
);
|
||||
insertions.push(...socialInsertions);
|
||||
console.log(
|
||||
` → ${socialInsertions.length} social embeddings planned`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- STEP 2: Component suggestions -----
|
||||
if (options.availableComponents && options.availableComponents.length > 0) {
|
||||
console.log("🧩 Planning component additions...");
|
||||
const componentInsertions = await this.planComponentInsertions(
|
||||
sectionMap,
|
||||
sections,
|
||||
options.availableComponents,
|
||||
fullContext,
|
||||
);
|
||||
insertions.push(...componentInsertions);
|
||||
console.log(
|
||||
` → ${componentInsertions.length} component additions planned`,
|
||||
);
|
||||
}
|
||||
|
||||
// ----- STEP 3: Diagram generation -----
|
||||
if (options.addDiagrams) {
|
||||
console.log("📊 Planning diagrams...");
|
||||
const diagramPlans = await this.planDiagramInsertions(
|
||||
sectionMap,
|
||||
sections,
|
||||
fullContext,
|
||||
);
|
||||
|
||||
for (const plan of diagramPlans) {
|
||||
const mermaidCode = await this.generateMermaid(plan.concept);
|
||||
if (!mermaidCode) {
|
||||
console.warn(` ⏭️ Skipping invalid diagram for: "${plan.concept}"`);
|
||||
continue;
|
||||
}
|
||||
diagrams.push(mermaidCode);
|
||||
const diagramId = plan.concept
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.slice(0, 40);
|
||||
insertions.push({
|
||||
afterSection: plan.afterSection,
|
||||
content: `<div className="my-8">\n <Mermaid id="${diagramId}" title="${plan.concept}" showShare={true}>\n${mermaidCode}\n </Mermaid>\n</div>`,
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
` → ${diagramPlans.length} diagrams planned, ${diagrams.length} valid`,
|
||||
);
|
||||
}
|
||||
|
||||
// ----- STEP 4: Meme placement (memegen.link via ArticleMeme) -----
|
||||
if (options.addMemes) {
|
||||
console.log("✨ Generating meme ideas...");
|
||||
let memeIdeas = await this.memeGenerator.generateMemeIdeas(
|
||||
content.slice(0, 4000),
|
||||
);
|
||||
|
||||
// User requested to explicitly limit memes to max 1 per page to prevent duplication
|
||||
if (memeIdeas.length > 1) {
|
||||
memeIdeas = [memeIdeas[0]];
|
||||
}
|
||||
|
||||
memes.push(...memeIdeas);
|
||||
|
||||
if (memeIdeas.length > 0) {
|
||||
console.log(
|
||||
`🎨 Planning meme placement for ${memeIdeas.length} memes...`,
|
||||
);
|
||||
const memePlacements = await this.planMemePlacements(
|
||||
sectionMap,
|
||||
sections,
|
||||
memeIdeas,
|
||||
);
|
||||
|
||||
for (let i = 0; i < memeIdeas.length; i++) {
|
||||
const meme = memeIdeas[i];
|
||||
if (
|
||||
memePlacements[i] !== undefined &&
|
||||
memePlacements[i] >= 0 &&
|
||||
memePlacements[i] < sections.length
|
||||
) {
|
||||
const captionsStr = meme.captions.join("|");
|
||||
insertions.push({
|
||||
afterSection: memePlacements[i],
|
||||
content: `<div className="my-8">\n <ArticleMeme template="${meme.template}" captions="${captionsStr}" />\n</div>`,
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(` → ${memeIdeas.length} memes placed`);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Enforce visual spacing (no consecutive visualizations) -----
|
||||
this.enforceVisualSpacing(insertions, sections);
|
||||
|
||||
// ----- Apply all insertions to original content -----
|
||||
console.log(
|
||||
`\n🔧 Applying ${insertions.length} insertions to original content...`,
|
||||
);
|
||||
let optimizedContent = this.applyInsertions(sections, insertions);
|
||||
|
||||
// ----- FINAL AGENTIC REWRITE (Replaces dumb regex scripts) -----
|
||||
console.log(
|
||||
`\n🧠 Agentic Rewrite: Polishing MDX, fixing syntax, and deduplicating...`,
|
||||
);
|
||||
const finalRewrite = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an expert MDX Editor. Your task is to take a draft blog post and output the FINAL, error-free MDX code.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. DEDUPLICATION: Ensure there is MAX ONE <ArticleMeme> in the entire post. Remove any duplicates or outdated memes. Ensure there is MAX ONE TL;DR section. Ensure there are no duplicate components.
|
||||
2. TEXT-TO-COMPONENT RATIO: Ensure there are at least 3-4 paragraphs of normal text between any two visual components (<Mermaid>, <ArticleMeme>, <StatsGrid>, <BoldNumber>, etc.). If they are clumped together, spread them out or delete the less important ones.
|
||||
3. SYNTAX: Fix any broken Mermaid/MDX syntax (e.g. unclosed tags, bad quotes).
|
||||
4. FIDELITY: Preserve the author's original German text, meaning, and tone. Smooth out transitions into the components.
|
||||
5. NO HALLUCINATION: Do not invent new URLs or facts. Keep the data provided in the draft.
|
||||
6. OUTPUT: Return ONLY the raw MDX content. No markdown code blocks (\`\`\`mdx), no preamble. Just the raw code file.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: optimizedContent,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
optimizedContent =
|
||||
finalRewrite.choices[0].message.content?.trim() || optimizedContent;
|
||||
|
||||
// Strip any residual markdown formatting fences just in case
|
||||
if (optimizedContent.startsWith("```")) {
|
||||
optimizedContent = optimizedContent
|
||||
.replace(/^```[a-zA-Z]*\n/, "")
|
||||
.replace(/\n```$/, "");
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Optimized Content",
|
||||
content: optimizedContent,
|
||||
research: facts,
|
||||
memes,
|
||||
diagrams,
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ADDITIVE HELPERS — these return JSON instructions, never rewrite content
|
||||
// =========================================================================
|
||||
|
||||
private splitIntoSections(content: string): string[] {
|
||||
// Split on double newlines (paragraph/block boundaries in MDX)
|
||||
return content.split(/\n\n+/);
|
||||
}
|
||||
|
||||
private applyInsertions(sections: string[], insertions: Insertion[]): string {
|
||||
// Sort by section index DESCENDING to avoid index shifting
|
||||
const sorted = [...insertions].sort(
|
||||
(a, b) => b.afterSection - a.afterSection,
|
||||
);
|
||||
const result = [...sections];
|
||||
for (const ins of sorted) {
|
||||
const idx = Math.min(ins.afterSection + 1, result.length);
|
||||
result.splice(idx, 0, ins.content);
|
||||
}
|
||||
return result.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce visual spacing: visual components must have at least 2 text sections between them.
|
||||
* This prevents walls of visualizations and maintains reading flow.
|
||||
*/
|
||||
private enforceVisualSpacing(
|
||||
insertions: Insertion[],
|
||||
sections: string[],
|
||||
): void {
|
||||
const visualPatterns = [
|
||||
"<Mermaid",
|
||||
"<ArticleMeme",
|
||||
"<StatsGrid",
|
||||
"<StatsDisplay",
|
||||
"<BoldNumber",
|
||||
"<MetricBar",
|
||||
"<ComparisonRow",
|
||||
"<PremiumComparisonChart",
|
||||
"<DiagramFlow",
|
||||
"<DiagramPie",
|
||||
"<DiagramGantt",
|
||||
"<DiagramState",
|
||||
"<DiagramSequence",
|
||||
"<DiagramTimeline",
|
||||
"<Carousel",
|
||||
"<WebVitalsScore",
|
||||
"<WaterfallChart",
|
||||
];
|
||||
const isVisual = (content: string) =>
|
||||
visualPatterns.some((p) => content.includes(p));
|
||||
|
||||
// Sort by section ascending
|
||||
insertions.sort((a, b) => a.afterSection - b.afterSection);
|
||||
|
||||
// Minimum gap of 10 sections between visual components (= ~6-8 text paragraphs)
|
||||
// User requested a better text-to-component ratio (not 1:1)
|
||||
const MIN_VISUAL_GAP = 10;
|
||||
|
||||
for (let i = 1; i < insertions.length; i++) {
|
||||
if (
|
||||
isVisual(insertions[i].content) &&
|
||||
isVisual(insertions[i - 1].content)
|
||||
) {
|
||||
const gap = insertions[i].afterSection - insertions[i - 1].afterSection;
|
||||
if (gap < MIN_VISUAL_GAP) {
|
||||
const newPos = Math.min(
|
||||
insertions[i - 1].afterSection + MIN_VISUAL_GAP,
|
||||
sections.length - 1,
|
||||
);
|
||||
insertions[i].afterSection = newPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildSectionMap(sections: string[]): string {
|
||||
return sections
|
||||
.map((s, i) => {
|
||||
const preview = s.trim().replace(/\n/g, " ").slice(0, 120);
|
||||
return `[${i}] ${preview}${s.length > 120 ? "…" : ""}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
private async loadDocsContext(docsPath: string): Promise<string> {
|
||||
try {
|
||||
const files = await fs.readdir(docsPath);
|
||||
const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
|
||||
const contents: string[] = [];
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const filePath = path.join(docsPath, file);
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
contents.push(`=== ${file} ===\n${text.trim()}`);
|
||||
}
|
||||
|
||||
return contents.join("\n\n");
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Could not load docs from ${docsPath}: ${e}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fact insertion planning (Claude Sonnet — precise content understanding) ---
|
||||
private async planFactInsertions(
|
||||
sectionMap: string,
|
||||
sections: string[],
|
||||
facts: Fact[],
|
||||
context: string,
|
||||
): Promise<Insertion[]> {
|
||||
const factsText = facts
|
||||
.map((f, i) => `${i + 1}. ${f.statement} [Source: ${f.source}]`)
|
||||
.join("\n");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You enrich a German blog post by ADDING new paragraphs with researched facts.
|
||||
|
||||
RULES:
|
||||
- Do NOT rewrite or modify any existing content
|
||||
- Only produce NEW <Paragraph> blocks to INSERT after a specific section number
|
||||
- Maximum 5 insertions (only the most impactful facts)
|
||||
- Match the post's tone and style (see context below)
|
||||
- Use the post's JSX components: <Paragraph>, <Marker> for emphasis
|
||||
- Cite sources using ExternalLink: <ExternalLink href="URL">Source: Name</ExternalLink>
|
||||
- Write in German, active voice, Ich-Form where appropriate
|
||||
|
||||
CONTEXT (tone, style, persona):
|
||||
${context.slice(0, 3000)}
|
||||
|
||||
EXISTING SECTIONS (read-only — do NOT modify these):
|
||||
${sectionMap}
|
||||
|
||||
FACTS TO INTEGRATE:
|
||||
${factsText}
|
||||
|
||||
Return JSON:
|
||||
{ "insertions": [{ "afterSection": 3, "content": "<Paragraph>\\n Fact-enriched paragraph text. [Source: Name]\\n</Paragraph>" }] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const result = safeParseJSON(
|
||||
response.choices[0].message.content || '{"insertions": []}',
|
||||
{ insertions: [] },
|
||||
);
|
||||
return (result.insertions || []).filter(
|
||||
(i: any) =>
|
||||
typeof i.afterSection === "number" &&
|
||||
i.afterSection >= 0 &&
|
||||
i.afterSection < sections.length &&
|
||||
typeof i.content === "string",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Social Media insertion planning ---
|
||||
private async planSocialMediaInsertions(
|
||||
sectionMap: string,
|
||||
sections: string[],
|
||||
posts: SocialPost[],
|
||||
context: string,
|
||||
): Promise<Insertion[]> {
|
||||
if (!posts || posts.length === 0) return [];
|
||||
|
||||
const postsText = posts
|
||||
.map(
|
||||
(p, i) =>
|
||||
`[${i}] Platform: ${p.platform}, ID: ${p.embedId} (${p.description})`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You enhance a German blog post by embedding relevant social media posts (YouTube, Twitter, LinkedIn).
|
||||
|
||||
RULES:
|
||||
- Do NOT rewrite any existing content
|
||||
- Return exactly 1 or 2 high-impact insertions
|
||||
- Choose the best fitting post(s) from the provided list
|
||||
- Use the correct component based on the platform:
|
||||
- youtube -> <YouTubeEmbed videoId="ID" />
|
||||
- twitter -> <TwitterEmbed tweetId="ID" theme="light" />
|
||||
- linkedin -> <LinkedInEmbed urn="ID" />
|
||||
- Add a 1-sentence intro paragraph above the embed to contextualize it.
|
||||
|
||||
CONTEXT:
|
||||
${context.slice(0, 3000)}
|
||||
|
||||
SOCIAL POSTS AVAILABLE TO EMBED:
|
||||
${postsText}
|
||||
|
||||
EXISTING SECTIONS:
|
||||
${sectionMap}
|
||||
|
||||
Return JSON:
|
||||
{ "insertions": [{ "afterSection": 4, "content": "<Paragraph>Wie Experten passend bemerken:</Paragraph>\\n\\n<TwitterEmbed tweetId=\\"123456\\" theme=\\"light\\" />" }] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const result = safeParseJSON(
|
||||
response.choices[0].message.content || '{"insertions": []}',
|
||||
{ insertions: [] },
|
||||
);
|
||||
return (result.insertions || []).filter(
|
||||
(i: any) =>
|
||||
typeof i.afterSection === "number" &&
|
||||
i.afterSection >= 0 &&
|
||||
i.afterSection < sections.length &&
|
||||
typeof i.content === "string",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Component insertion planning (Claude Sonnet — understands JSX context) ---
|
||||
private async planComponentInsertions(
|
||||
sectionMap: string,
|
||||
sections: string[],
|
||||
components: ComponentDefinition[],
|
||||
context: string,
|
||||
): Promise<Insertion[]> {
|
||||
const fullContent = sections.join("\n\n");
|
||||
const componentsText = components
|
||||
.map((c) => `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`)
|
||||
.join("\n\n");
|
||||
const usedComponents = components
|
||||
.filter((c) => fullContent.includes(`<${c.name}`))
|
||||
.map((c) => c.name);
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You enhance a German blog post by ADDING interactive UI components.
|
||||
|
||||
STRICT BALANCE RULES:
|
||||
- Maximum 3–4 component additions total
|
||||
- There MUST be at least 3–4 text paragraphs between any two visual components
|
||||
- Visual components MUST NEVER appear directly after each other
|
||||
- Each unique component type should only appear ONCE (e.g., only one WebVitalsScore, one WaterfallChart)
|
||||
- Multiple MetricBar or ComparisonRow in sequence are OK (they form a group)
|
||||
|
||||
CONTENT RULES:
|
||||
- Do NOT rewrite any existing content — only ADD new component blocks
|
||||
- Do NOT add components already present: ${usedComponents.join(", ") || "none"}
|
||||
- Statistics MUST have comparison context (before/after, competitor vs us) — never standalone numbers
|
||||
- All BoldNumber components MUST include source and sourceUrl props
|
||||
- All ArticleQuote components MUST include source and sourceUrl; add "(übersetzt)" if translated
|
||||
- MetricBar value must be a real number > 0, not placeholder zeros
|
||||
- Carousel items array must have at least 2 items with substantive content
|
||||
- Use exact JSX syntax from the examples
|
||||
|
||||
CONTEXT:
|
||||
${context.slice(0, 3000)}
|
||||
|
||||
EXISTING SECTIONS (read-only):
|
||||
${sectionMap}
|
||||
|
||||
AVAILABLE COMPONENTS:
|
||||
${componentsText}
|
||||
|
||||
Return JSON:
|
||||
{ "insertions": [{ "afterSection": 5, "content": "<StatsDisplay value=\\"100\\" label=\\"PageSpeed Score\\" subtext=\\"Kein Kompromiss.\\" />" }] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const result = safeParseJSON(
|
||||
response.choices[0].message.content || '{"insertions": []}',
|
||||
{ insertions: [] },
|
||||
);
|
||||
return (result.insertions || []).filter(
|
||||
(i: any) =>
|
||||
typeof i.afterSection === "number" &&
|
||||
i.afterSection >= 0 &&
|
||||
i.afterSection < sections.length &&
|
||||
typeof i.content === "string",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Diagram planning (Gemini Flash — structured output) ---
|
||||
private async planDiagramInsertions(
|
||||
sectionMap: string,
|
||||
sections: string[],
|
||||
context: string,
|
||||
): Promise<{ afterSection: number; concept: string }[]> {
|
||||
const fullContent = sections.join("\n\n");
|
||||
const hasDiagrams =
|
||||
fullContent.includes("<Mermaid") || fullContent.includes("<Diagram");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.STRUCTURED,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Analyze this German blog post and suggest 1-2 Mermaid diagrams.
|
||||
${hasDiagrams ? "The post already has diagrams. Only suggest NEW concepts not already visualized." : ""}
|
||||
${context.slice(0, 1500)}
|
||||
|
||||
SECTIONS:
|
||||
${sectionMap}
|
||||
|
||||
Return JSON:
|
||||
{ "diagrams": [{ "afterSection": 5, "concept": "Descriptive concept name" }] }
|
||||
Maximum 2 diagrams. Return ONLY the JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const result = safeParseJSON(
|
||||
response.choices[0].message.content || '{"diagrams": []}',
|
||||
{ diagrams: [] },
|
||||
);
|
||||
return (result.diagrams || []).filter(
|
||||
(d: any) =>
|
||||
typeof d.afterSection === "number" &&
|
||||
d.afterSection >= 0 &&
|
||||
d.afterSection < sections.length,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Meme placement planning (Gemini Flash — structural positioning) ---
|
||||
private async planMemePlacements(
|
||||
sectionMap: string,
|
||||
sections: string[],
|
||||
memes: MemeSuggestion[],
|
||||
): Promise<number[]> {
|
||||
const memesText = memes
|
||||
.map((m, i) => `${i}: "${m.template}" — ${m.captions.join(" / ")}`)
|
||||
.join("\n");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.STRUCTURED,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Place ${memes.length} memes at appropriate positions in this blog post.
|
||||
Rules: Space them out evenly, place between thematic sections, never at position 0 (the very start).
|
||||
|
||||
SECTIONS:
|
||||
${sectionMap}
|
||||
|
||||
MEMES:
|
||||
${memesText}
|
||||
|
||||
Return JSON: { "placements": [sectionNumber, sectionNumber, ...] }
|
||||
One section number per meme, in the same order as the memes list. Return ONLY JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const result = safeParseJSON(
|
||||
response.choices[0].message.content || '{"placements": []}',
|
||||
{ placements: [] },
|
||||
);
|
||||
return result.placements || [];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SHARED HELPERS
|
||||
// =========================================================================
|
||||
|
||||
private async createOutline(
|
||||
topic: string,
|
||||
facts: Fact[],
|
||||
tone: string,
|
||||
): Promise<{ title: string; sections: string[] }> {
|
||||
const factsContext = facts
|
||||
.map((f) => `- ${f.statement} (${f.source})`)
|
||||
.join("\n");
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.STRUCTURED,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Create a blog post outline on "${topic}".
|
||||
Tone: ${tone}.
|
||||
Incorporating these facts:
|
||||
${factsContext}
|
||||
|
||||
Return JSON: { "title": "Catchy Title", "sections": ["Introduction", "Section 1", "Conclusion"] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
return safeParseJSON(
|
||||
response.choices[0].message.content || '{"title": "", "sections": []}',
|
||||
{ title: "", sections: [] },
|
||||
);
|
||||
}
|
||||
|
||||
private async draftContent(
|
||||
topic: string,
|
||||
outline: { title: string; sections: string[] },
|
||||
facts: Fact[],
|
||||
tone: string,
|
||||
components: ComponentDefinition[],
|
||||
): Promise<string> {
|
||||
const factsContext = facts
|
||||
.map((f) => `- ${f.statement} (Source: ${f.source})`)
|
||||
.join("\n");
|
||||
const componentsContext =
|
||||
components.length > 0
|
||||
? `\n\nAvailable Components:\n` +
|
||||
components
|
||||
.map(
|
||||
(c) =>
|
||||
`- <${c.name}>: ${c.description}\n Example: ${c.usageExample}`,
|
||||
)
|
||||
.join("\n")
|
||||
: "";
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.CONTENT,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Write a blog post based on this outline:
|
||||
Title: ${outline.title}
|
||||
Sections: ${outline.sections.join(", ")}
|
||||
|
||||
Tone: ${tone}.
|
||||
Facts: ${factsContext}
|
||||
${componentsContext}
|
||||
|
||||
Format as Markdown. Start with # H1.
|
||||
For places where a diagram would help, insert: <!-- DIAGRAM_PLACEHOLDER: Concept Name -->
|
||||
Return ONLY raw content.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
return response.choices[0].message.content || "";
|
||||
}
|
||||
|
||||
private async processDiagramPlaceholders(
|
||||
content: string,
|
||||
diagrams: string[],
|
||||
): Promise<string> {
|
||||
const matches = content.matchAll(/<!-- DIAGRAM_PLACEHOLDER: (.+?) -->/g);
|
||||
let processedContent = content;
|
||||
|
||||
for (const match of Array.from(matches)) {
|
||||
const concept = match[1];
|
||||
const diagram = await this.generateMermaid(concept);
|
||||
diagrams.push(diagram);
|
||||
const diagramId = concept
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.slice(0, 40);
|
||||
const mermaidJsx = `\n<div className="my-8">\n <Mermaid id="${diagramId}" title="${concept}" showShare={true}>\n${diagram}\n </Mermaid>\n</div>\n`;
|
||||
processedContent = processedContent.replace(
|
||||
`<!-- DIAGRAM_PLACEHOLDER: ${concept} -->`,
|
||||
mermaidJsx,
|
||||
);
|
||||
}
|
||||
return processedContent;
|
||||
}
|
||||
|
||||
private async generateMermaid(concept: string): Promise<string> {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.DIAGRAM,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Generate a Mermaid.js diagram for: "${concept}".
|
||||
|
||||
RULES:
|
||||
- Use clear labels in German where appropriate
|
||||
- Keep it EXTREMELY SIMPLE AND COMPACT: strictly max 3-4 nodes for a tiny visual footprint.
|
||||
- Prefer vertical layouts (TD) over horizontal (LR) to prevent wide overflowing graphs.
|
||||
- CRITICAL: Generate ONLY ONE single connected graph. Do NOT generate multiple independent graphs or isolated subgraphs in the same Mermaid block.
|
||||
- No nested subgraphs. Keep instructions short.
|
||||
- Use double-quoted labels for nodes: A["Label"]
|
||||
- VERY CRITICAL: DO NOT use any HTML tags (no <br>, no <br/>, no <b>, etc).
|
||||
- VERY CRITICAL: DO NOT use special characters like '&', '<', '>', or double-quotes inside the label strings. They break the mermaid parser in our environment.
|
||||
- Return ONLY the raw mermaid code. No markdown blocks, no backticks.
|
||||
- The first line MUST be a valid mermaid diagram type: graph, flowchart, sequenceDiagram, pie, gantt, stateDiagram, timeline`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const code =
|
||||
response.choices[0].message.content
|
||||
?.replace(/```mermaid/g, "")
|
||||
.replace(/```/g, "")
|
||||
.trim() || "";
|
||||
|
||||
// Validate: must start with a valid mermaid keyword
|
||||
const validStarts = [
|
||||
"graph",
|
||||
"flowchart",
|
||||
"sequenceDiagram",
|
||||
"pie",
|
||||
"gantt",
|
||||
"stateDiagram",
|
||||
"timeline",
|
||||
"classDiagram",
|
||||
"erDiagram",
|
||||
];
|
||||
const firstLine = code.split("\n")[0]?.trim().toLowerCase() || "";
|
||||
const isValid = validStarts.some((keyword) =>
|
||||
firstLine.startsWith(keyword),
|
||||
);
|
||||
|
||||
if (!isValid || code.length < 10) {
|
||||
console.warn(
|
||||
`⚠️ Mermaid: Invalid diagram generated for "${concept}", skipping`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
private async identifyResearchTopics(
|
||||
content: string,
|
||||
context: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
console.log("Sending request to OpenRouter...");
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: MODELS.STRUCTURED,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Analyze the following blog post and identify 3 key topics or claims that would benefit from statistical data or external verification.
|
||||
Return relevant, specific research queries (not too broad).
|
||||
|
||||
Context: ${context.slice(0, 1500)}
|
||||
|
||||
Return JSON: { "topics": ["topic 1", "topic 2", "topic 3"] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: content.slice(0, 4000),
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
console.log("Got response from OpenRouter");
|
||||
const parsed = safeParseJSON(
|
||||
response.choices[0].message.content || '{"topics": []}',
|
||||
{ topics: [] },
|
||||
);
|
||||
return (parsed.topics || []).map((t: any) =>
|
||||
typeof t === "string" ? t : JSON.stringify(t),
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error("Error in identifyResearchTopics:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/content-engine/src/index.ts
Normal file
2
packages/content-engine/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./generator";
|
||||
export * from "./orchestrator";
|
||||
350
packages/content-engine/src/orchestrator.ts
Normal file
350
packages/content-engine/src/orchestrator.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import OpenAI from "openai";
|
||||
import { ResearchAgent, Fact, SocialPost } from "@mintel/journaling";
|
||||
import { ComponentDefinition } from "./generator";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface OrchestratorConfig {
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface OptimizationTask {
|
||||
content: string;
|
||||
projectContext: string;
|
||||
availableComponents?: ComponentDefinition[];
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
export interface OptimizeFileOptions {
|
||||
contextDir: string;
|
||||
availableComponents?: ComponentDefinition[];
|
||||
}
|
||||
|
||||
export class AiBlogPostOrchestrator {
|
||||
private openai: OpenAI;
|
||||
private researchAgent: ResearchAgent;
|
||||
private model: string;
|
||||
|
||||
constructor(config: OrchestratorConfig) {
|
||||
this.model = config.model || "google/gemini-3-flash-preview";
|
||||
this.openai = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://mintel.me",
|
||||
"X-Title": "Mintel AI Blog Post Orchestrator",
|
||||
},
|
||||
});
|
||||
this.researchAgent = new ResearchAgent(config.apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable context loader. Loads all .md and .txt files from a directory into a single string.
|
||||
*/
|
||||
async loadContext(dirPath: string): Promise<string> {
|
||||
try {
|
||||
const resolvedDir = path.resolve(process.cwd(), dirPath);
|
||||
const files = await fs.readdir(resolvedDir);
|
||||
const textFiles = files.filter((f) => /\.(md|txt)$/i.test(f)).sort();
|
||||
const contents: string[] = [];
|
||||
|
||||
for (const file of textFiles) {
|
||||
const filePath = path.join(resolvedDir, file);
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
contents.push(`=== ${file} ===\n${text.trim()}`);
|
||||
}
|
||||
|
||||
return contents.join("\n\n");
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Could not load context from ${dirPath}: ${e}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file, extracts frontmatter, loads context, optimizes body, and writes it back.
|
||||
*/
|
||||
async optimizeFile(
|
||||
targetFile: string,
|
||||
options: OptimizeFileOptions,
|
||||
): Promise<void> {
|
||||
const absPath = path.isAbsolute(targetFile)
|
||||
? targetFile
|
||||
: path.resolve(process.cwd(), targetFile);
|
||||
console.log(`📄 Processing File: ${path.basename(absPath)}`);
|
||||
|
||||
const content = await fs.readFile(absPath, "utf8");
|
||||
|
||||
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
const frontmatter = fmMatch ? fmMatch[0] : "";
|
||||
const body = fmMatch ? content.slice(frontmatter.length).trim() : content;
|
||||
|
||||
console.log(`📖 Loading context from: ${options.contextDir}`);
|
||||
const projectContext = await this.loadContext(options.contextDir);
|
||||
if (!projectContext) {
|
||||
console.warn(
|
||||
"⚠️ No project context loaded. AI might miss specific guidelines.",
|
||||
);
|
||||
}
|
||||
|
||||
const optimizedContent = await this.optimizeDocument({
|
||||
content: body,
|
||||
projectContext,
|
||||
availableComponents: options.availableComponents,
|
||||
});
|
||||
|
||||
const finalOutput = frontmatter
|
||||
? `${frontmatter}\n\n${optimizedContent}`
|
||||
: optimizedContent;
|
||||
|
||||
await fs.writeFile(`${absPath}.bak`, content); // Keep simple backup
|
||||
await fs.writeFile(absPath, finalOutput);
|
||||
console.log(`✅ Saved optimized file to: ${absPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the 3-step optimization pipeline:
|
||||
* 1. Fakten recherchieren
|
||||
* 2. Social Posts recherchieren
|
||||
* 3. AI anweisen daraus Artikel zu erstellen
|
||||
*/
|
||||
async optimizeDocument(task: OptimizationTask): Promise<string> {
|
||||
console.log(`🚀 Starting AI Orchestration Pipeline (${this.model})...`);
|
||||
|
||||
// 1. Fakten recherchieren
|
||||
console.log("1️⃣ Recherchiere Fakten...");
|
||||
const researchTopics = await this.identifyTopics(task.content);
|
||||
const facts: Fact[] = [];
|
||||
for (const topic of researchTopics) {
|
||||
const topicFacts = await this.researchAgent.researchTopic(topic);
|
||||
facts.push(...topicFacts);
|
||||
}
|
||||
|
||||
// 2. Social Posts recherchieren
|
||||
console.log(
|
||||
"2️⃣ Recherchiere Social Media Posts (YouTube, Twitter, LinkedIn)...",
|
||||
);
|
||||
// Use the first 2000 chars to find relevant social posts
|
||||
const socialPosts = await this.researchAgent.findSocialPosts(
|
||||
task.content.substring(0, 2000),
|
||||
);
|
||||
|
||||
// 3. AI anweisen daraus Artikel zu erstellen
|
||||
console.log("3️⃣ Erstelle optimierten Artikel (Agentic Rewrite)...");
|
||||
return await this.compileArticle(task, facts, socialPosts);
|
||||
}
|
||||
|
||||
private async identifyTopics(content: string): Promise<string[]> {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash", // fast structured model for topic extraction
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Analyze the following blog post and identify 1 to 2 key topics or claims that would benefit from statistical data or external verification.
|
||||
Return JSON: { "topics": ["topic 1", "topic 2"] }
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: content.slice(0, 4000),
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
try {
|
||||
const raw = response.choices[0].message.content || '{"topics": []}';
|
||||
const cleaned = raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*\n?/, "")
|
||||
.replace(/\n?```\s*$/, "");
|
||||
const parsed = JSON.parse(cleaned);
|
||||
return parsed.topics || [];
|
||||
} catch (e) {
|
||||
console.warn("⚠️ Failed to parse research topics", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async compileArticle(
|
||||
task: OptimizationTask,
|
||||
facts: Fact[],
|
||||
socialPosts: SocialPost[],
|
||||
retryCount = 0,
|
||||
): Promise<string> {
|
||||
const factsText = facts
|
||||
.map((f, i) => `${i + 1}. ${f.statement} [Source: ${f.source}]`)
|
||||
.join("\n");
|
||||
|
||||
const socialText = socialPosts
|
||||
.map(
|
||||
(p, i) =>
|
||||
`Platform: ${p.platform}, ID: ${p.embedId} (${p.description})`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const componentsText = (task.availableComponents || [])
|
||||
.map((c) => `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`)
|
||||
.join("\n\n");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an expert MDX Editor and Digital Architect.
|
||||
|
||||
YOUR TASK:
|
||||
Take the given draft blog post and rewrite/enhance it into a final, error-free MDX file. Maintain the author's original German text, meaning, and tone, but enrich it gracefully.
|
||||
|
||||
CONTEXT & RULES:
|
||||
Project Context / Tone:
|
||||
${task.projectContext}
|
||||
|
||||
Facts to weave in:
|
||||
${factsText || "None"}
|
||||
|
||||
Social Media Posts to embed (use <YouTubeEmbed videoId="..." />, <TwitterEmbed tweetId="..." />, or <LinkedInEmbed url="..." />):
|
||||
${socialText || "None"}
|
||||
|
||||
Available MDX Components you can use contextually:
|
||||
${componentsText || "None"}
|
||||
|
||||
Special Instructions from User:
|
||||
${task.instructions || "None"}
|
||||
|
||||
BLOG POST BEST PRACTICES (MANDATORY):
|
||||
- Füge zwingend ein prägnantes 'TL;DR' ganz am Anfang ein.
|
||||
- Füge ein sauberes '<TableOfContents />' ein.
|
||||
- Verwende unsere Komponenten stilvoll für Visualisierungen.
|
||||
- Agiere als hochprofessioneller Digital Architect und entferne alte MDX-Metadaten im Body.
|
||||
- Fazit: Schließe JEDEN Artikel ZWINGEND mit einem starken, klaren 'Fazit' ab (z.B. als <H2>Fazit: ...</H2> gefolgt von deinen Empfehlungen).
|
||||
|
||||
CRITICAL GUIDELINES (NEVER BREAK THESE):
|
||||
1. ONLY return the content for the BODY of the MDX file.
|
||||
2. DO NOT INCLUDE FRONTMATTER (blocks starting and ending with ---). I ALREADY HAVE THE FRONTMATTER.
|
||||
3. DO NOT REPEAT METADATA IN THE BODY. Do not output lines like "title: ...", "description: ...", "date: ..." inside the text.
|
||||
4. DO NOT INCLUDE MARKDOWN WRAPPERS (do not wrap in \`\`\`mdx ... \`\`\`).
|
||||
5. Be clean. Do NOT clump all components together. Provide 3-4 paragraphs of normal text between visual items.
|
||||
6. If you insert components, ensure their syntax is 100% valid JSX/MDX.
|
||||
7. CRITICAL MERMAID RULE: If you use <Mermaid>, the inner content MUST be 100% valid Mermaid.js syntax. NO HTML inside labels. NO quotes inside brackets without valid syntax.
|
||||
8. Do NOT hallucinate links or facts. Use only what is provided.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: task.content,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let rawContent = response.choices[0].message.content || task.content;
|
||||
rawContent = this.cleanResponse(rawContent);
|
||||
|
||||
// Validation Layer: Check Mermaid syntax
|
||||
if (retryCount < 2 && rawContent.includes("<Mermaid>")) {
|
||||
console.log("🔍 Validating Mermaid syntax in AI response...");
|
||||
const mermaidBlocks = this.extractMermaidBlocks(rawContent);
|
||||
let hasError = false;
|
||||
let errorFeedback = "";
|
||||
|
||||
for (const block of mermaidBlocks) {
|
||||
const validationResult = await this.validateMermaidSyntax(block);
|
||||
if (!validationResult.valid) {
|
||||
hasError = true;
|
||||
errorFeedback += `\nInvalid Mermaid block:\n${block}\nError context: ${validationResult.error}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
console.log(
|
||||
`❌ Invalid Mermaid syntax detected. Retrying compilation (Attempt ${retryCount + 1}/2)...`,
|
||||
);
|
||||
return this.compileArticle(
|
||||
{
|
||||
...task,
|
||||
content: `The previous attempt failed because you generated invalid Mermaid.js syntax. Please rewrite the MDX and FIX the following Mermaid errors. \n\nErrors:\n${errorFeedback}\n\nOriginal Draft:\n${task.content}`,
|
||||
},
|
||||
facts,
|
||||
socialPosts,
|
||||
retryCount + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return rawContent;
|
||||
}
|
||||
|
||||
private extractMermaidBlocks(content: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
// Regex to match <Mermaid>...</Mermaid> blocks across multiple lines
|
||||
const regex = /<Mermaid>([\s\S]*?)<\/Mermaid>/g;
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
if (match[1]) {
|
||||
blocks.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private async validateMermaidSyntax(
|
||||
graph: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
// Fast LLM validation to catch common syntax errors like unbalanced quotes or HTML entities
|
||||
try {
|
||||
const validationResponse = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-3-flash-preview", // Switch from gpt-4o-mini to user requested model
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
'You are a strict Mermaid.js compiler. Analyze the given Mermaid syntax. If it is 100% valid and will render without exceptions, reply ONLY with "VALID". If it has syntax errors (e.g., HTML inside labels, unescaped quotes, unclosed brackets), reply ONLY with "INVALID" followed by a short explanation of the exact error.',
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: graph,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const reply =
|
||||
validationResponse.choices[0].message.content?.trim() || "VALID";
|
||||
if (reply.startsWith("INVALID")) {
|
||||
return { valid: false, error: reply };
|
||||
}
|
||||
return { valid: true };
|
||||
} catch (e) {
|
||||
console.error("Syntax validation LLM call failed, passing through:", e);
|
||||
return { valid: true }; // Fallback to passing if validator fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-processing to ensure the AI didn't include "help" text,
|
||||
* duplicate frontmatter, or markdown wrappers.
|
||||
*/
|
||||
private cleanResponse(content: string): string {
|
||||
let cleaned = content.trim();
|
||||
|
||||
// 1. Strip Markdown Wrappers (e.g. ```mdx ... ```)
|
||||
if (cleaned.startsWith("```")) {
|
||||
cleaned = cleaned
|
||||
.replace(/^```[a-zA-Z]*\n?/, "")
|
||||
.replace(/\n?```\s*$/, "");
|
||||
}
|
||||
|
||||
// 2. Strip redundant frontmatter (the AI sometimes helpfully repeats it)
|
||||
// Look for the --- delimiters and remove the block if it exists
|
||||
const fmRegex = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
|
||||
const match = cleaned.match(fmRegex);
|
||||
if (match) {
|
||||
console.log(
|
||||
"♻️ Stripping redundant frontmatter detected in AI response...",
|
||||
);
|
||||
cleaned = cleaned.replace(fmRegex, "").trim();
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
11
packages/content-engine/tsconfig.json
Normal file
11
packages/content-engine/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -45,14 +45,19 @@
|
||||
<v-button secondary rounded icon v-tooltip.bottom="'Kunden-Verlinkung bearbeiten'" @click="openEditDrawer">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<v-button primary @click="openCreateClientUser">
|
||||
Portal-Nutzer hinzufügen
|
||||
</v-button>
|
||||
<div @click="onDebugClick" style="display: inline-block; border: 2px solid lime;">
|
||||
<v-button primary @click="openCreateClientUser">
|
||||
Portal-Nutzer hinzufügen
|
||||
</v-button>
|
||||
</div>
|
||||
<button style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG</button>
|
||||
<button style="background: blue; color: white; padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="openCreateClientUser">NATIVE: Portal-Nutzer</button>
|
||||
</template>
|
||||
|
||||
<template #empty-state>
|
||||
Wähle einen Kunden aus der Liste oder
|
||||
<v-button x-small @click="openCreateDrawer">verlinke eine neue Firma</v-button>.
|
||||
<button id="debug-click-test" style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG CLICK</button>
|
||||
</template>
|
||||
|
||||
<!-- Main Content: Client Users Table -->
|
||||
@@ -257,6 +262,11 @@ import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-to
|
||||
const api = useApi();
|
||||
const route = useRoute();
|
||||
|
||||
function onDebugClick() {
|
||||
console.log("=== [Customer Manager] DEBUG CLICK TRAPPED ===");
|
||||
alert("Interactivity OK!");
|
||||
}
|
||||
|
||||
const items = ref<any[]>([]);
|
||||
const selectedItem = ref<any>(null);
|
||||
const clientUsers = ref<any[]>([]);
|
||||
|
||||
50
packages/infra/scripts/mintel-optimizer.sh
Normal file
50
packages/infra/scripts/mintel-optimizer.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY_DATA="/mnt/HC_Volume_104575103/registry-data/docker/registry/v2"
|
||||
KEEP_TAGS=3
|
||||
|
||||
echo "🏥 Starting Aggressive Mintel Infrastructure Optimization..."
|
||||
|
||||
# 1. Prune Registry Tags (Filesystem level)
|
||||
if [ -d "$REGISTRY_DATA" ]; then
|
||||
echo "🔍 Processing Registry tags..."
|
||||
for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
|
||||
[ -e "$repo_dir" ] || continue
|
||||
repo_name=$(basename "$repo_dir")
|
||||
|
||||
# EXCLUDE base images from pruning to prevent breaking downstream builds
|
||||
if [[ "$repo_name" == "runtime" || "$repo_name" == "nextjs" || "$repo_name" == "gatekeeper" ]]; then
|
||||
echo " 🛡️ Skipping protected repository: mintel/$repo_name"
|
||||
continue
|
||||
fi
|
||||
|
||||
tags_dir="$repo_dir/_manifests/tags"
|
||||
|
||||
if [ -d "$tags_dir" ]; then
|
||||
echo " 📦 Pruning mintel/$repo_name..."
|
||||
# Note: keeping latest and up to KEEP_TAGS of each pattern
|
||||
PATTERNS=("main-*" "testing-*" "branch-*" "v*" "rc*" "[0-9a-f]*")
|
||||
for pattern in "${PATTERNS[@]}"; do
|
||||
find "$tags_dir" -maxdepth 1 -name "$pattern" -print0 2>/dev/null | xargs -0 ls -dt 2>/dev/null | tail -n +$((KEEP_TAGS + 1)) | xargs rm -rf 2>/dev/null || true
|
||||
done
|
||||
rm -rf "$tags_dir/buildcache"* 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 2. Registry Garbage Collection
|
||||
REGISTRY_CONTAINER=$(docker ps --format "{{.Names}}" | grep registry | head -1 || true)
|
||||
if [ -n "$REGISTRY_CONTAINER" ]; then
|
||||
echo "♻️ Running Registry GC..."
|
||||
docker exec "$REGISTRY_CONTAINER" bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged || true
|
||||
fi
|
||||
|
||||
# 3. Global Docker Pruning
|
||||
echo "🧹 Pruning Docker resources..."
|
||||
docker system prune -af --filter "until=24h"
|
||||
docker volume prune -f
|
||||
|
||||
echo "✅ Optimization complete!"
|
||||
df -h /mnt/HC_Volume_104575103
|
||||
32
packages/journaling/package.json
Normal file
32
packages/journaling/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@mintel/journaling",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"google-trends-api": "^4.9.2",
|
||||
"openai": "^4.82.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
276
packages/journaling/src/agent.ts
Normal file
276
packages/journaling/src/agent.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import OpenAI from "openai";
|
||||
import { DataCommonsClient } from "./clients/data-commons";
|
||||
import { TrendsClient } from "./clients/trends";
|
||||
|
||||
export interface Fact {
|
||||
statement: string;
|
||||
source: string;
|
||||
url?: string;
|
||||
confidence: "high" | "medium" | "low";
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface SocialPost {
|
||||
platform: "youtube" | "twitter" | "linkedin";
|
||||
embedId: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class ResearchAgent {
|
||||
private openai: OpenAI;
|
||||
private dcClient: DataCommonsClient;
|
||||
private trendsClient: TrendsClient;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.openai = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://mintel.me",
|
||||
"X-Title": "Mintel Journaling Agent",
|
||||
},
|
||||
});
|
||||
this.dcClient = new DataCommonsClient();
|
||||
this.trendsClient = new TrendsClient();
|
||||
}
|
||||
|
||||
async researchTopic(topic: string): Promise<Fact[]> {
|
||||
console.log(`🔎 Researching: ${topic}`);
|
||||
|
||||
// 1. Plan Research
|
||||
const plan = await this.planResearch(topic);
|
||||
console.log(`📋 Research Plan:`, plan);
|
||||
|
||||
const facts: Fact[] = [];
|
||||
|
||||
// 2. Execute Plan
|
||||
// Google Trends
|
||||
for (const kw of plan.trendsKeywords) {
|
||||
try {
|
||||
const data = await this.trendsClient.getInterestOverTime(kw);
|
||||
if (data.length > 0) {
|
||||
// Analyze trend
|
||||
const latest = data[data.length - 1];
|
||||
const max = Math.max(...data.map((d) => d.value));
|
||||
facts.push({
|
||||
statement: `Interest in "${kw}" is currently at ${latest.value}% of peak popularity.`,
|
||||
source: "Google Trends",
|
||||
confidence: "high",
|
||||
data: data.slice(-5), // Last 5 points
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error fetching trends for ${kw}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Data Commons
|
||||
// We need DCIDs. LLM should have provided them or we need a search.
|
||||
// For this POC, let's assume the LLM provides plausible DCIDs or we skip deep DC integration for now
|
||||
// and rely on the LLM's own knowledge + the verified trends.
|
||||
// However, if the plan has dcVariables, let's try.
|
||||
|
||||
// 3. Synthesize & Verify
|
||||
// Ask LLM to verify its own knowledge against the data we found (if any) or just use its training data
|
||||
// but formatted as "facts".
|
||||
|
||||
const synthesis = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.0-flash-001",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a professional digital researcher and fact-checker.
|
||||
Topic: "${topic}"
|
||||
|
||||
Your Goal: Provide 5-7 concrete, verifiable, statistical facts.
|
||||
Constraint 1: Cite real sources (e.g. "Google Developers", "HTTP Archive", "Deloitte", "Nielsen Norman Group").
|
||||
Constraint 2: DO NOT cite "General Knowledge".
|
||||
Constraint 3: CRITICAL MANDATE - NEVER generate or guess URLs. You must hallucinate NO links. Use ONLY the Organization's Name as the "source" field.
|
||||
|
||||
Return JSON: { "facts": [ { "statement": "...", "source": "Organization Name Only", "confidence": "high" } ] }`,
|
||||
},
|
||||
{ role: "user", content: "Extract facts." },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
if (
|
||||
!synthesis.choices ||
|
||||
synthesis.choices.length === 0 ||
|
||||
!synthesis.choices[0].message
|
||||
) {
|
||||
console.warn(`⚠️ Research synthesis failed for concept: "${topic}"`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = JSON.parse(synthesis.choices[0].message.content || "{}");
|
||||
return result.facts || [];
|
||||
}
|
||||
|
||||
async findSocialPosts(
|
||||
topic: string,
|
||||
retries = 2,
|
||||
previousFailures: string[] = [],
|
||||
): Promise<SocialPost[]> {
|
||||
console.log(
|
||||
`📱 Searching for relevant Social Media Posts: "${topic}"${retries < 2 ? ` (Retry ${2 - retries}/2)` : ""}`,
|
||||
);
|
||||
|
||||
const failureContext =
|
||||
previousFailures.length > 0
|
||||
? `\nCRITICAL FAILURE WARNING: The following IDs you generated previously returned 404 Not Found and were Hallucinations: ${previousFailures.join(", ")}. You MUST provide REAL, verifiable IDs. If you cannot 100% guarantee an ID exists, return an empty array instead of guessing.`
|
||||
: "";
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-pro",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a social media researcher finding high-value, real expert posts and videos to embed in a B2B Tech Blog post about: "${topic}".
|
||||
|
||||
Your Goal: Identify 1-3 REAL, highly relevant social media posts (YouTube, Twitter/X, LinkedIn) that provide social proof, expert opinions, or deep dives.${failureContext}
|
||||
|
||||
Constraint: You MUST provide the exact mathematical or alphanumeric ID for the embed.
|
||||
- YouTube: The 11-character video ID (e.g. "dQw4w9WgXcQ")
|
||||
- Twitter: The numerical tweet ID (e.g. "1753464161943834945")
|
||||
- LinkedIn: The activity URN (e.g. "urn:li:activity:7153664326573674496" or just the numerical 19-digit ID)
|
||||
|
||||
Return JSON exactly as follows:
|
||||
{
|
||||
"posts": [
|
||||
{ "platform": "youtube", "embedId": "dQw4w9WgXcQ", "description": "Google Web Dev explaining Core Web Vitals" }
|
||||
]
|
||||
}
|
||||
Return ONLY the JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
if (
|
||||
!response.choices ||
|
||||
response.choices.length === 0 ||
|
||||
!response.choices[0].message
|
||||
) {
|
||||
console.warn(`⚠️ Social post search failed for concept: "${topic}"`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = JSON.parse(response.choices[0].message.content || "{}");
|
||||
const rawPosts: SocialPost[] = result.posts || [];
|
||||
|
||||
// CRITICAL WORKFLOW FIX: Absolutely forbid hallucinations by verifying via oEmbed APIs
|
||||
const verifiedPosts: SocialPost[] = [];
|
||||
if (rawPosts.length > 0) {
|
||||
console.log(
|
||||
`🛡️ Verifying ${rawPosts.length} generated social ID(s) against network...`,
|
||||
);
|
||||
}
|
||||
|
||||
const failedIdsForThisRun: string[] = [];
|
||||
|
||||
for (const post of rawPosts) {
|
||||
let isValid = false;
|
||||
try {
|
||||
if (post.platform === "youtube") {
|
||||
const res = await fetch(
|
||||
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${post.embedId}`,
|
||||
);
|
||||
isValid = res.ok;
|
||||
} else if (post.platform === "twitter") {
|
||||
const res = await fetch(
|
||||
`https://publish.twitter.com/oembed?url=https://twitter.com/x/status/${post.embedId}`,
|
||||
);
|
||||
isValid = res.ok;
|
||||
} else if (post.platform === "linkedin") {
|
||||
// LinkedIn doesn't have an unauthenticated oEmbed, so we use heuristic URL/URN format validation
|
||||
if (
|
||||
post.embedId.includes("urn:li:") ||
|
||||
post.embedId.includes("linkedin.com") ||
|
||||
/^\d{19}$/.test(post.embedId)
|
||||
) {
|
||||
isValid = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
verifiedPosts.push(post);
|
||||
console.log(
|
||||
`✅ Verified real post ID: ${post.embedId} (${post.platform})`,
|
||||
);
|
||||
} else {
|
||||
failedIdsForThisRun.push(post.embedId);
|
||||
console.warn(
|
||||
`🛑 Dropped hallucinated or dead post ID: ${post.embedId} (${post.platform})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// AGENT SELF-HEALING: If all found posts were hallucinations and we have retries, challenge the LLM to try again
|
||||
if (verifiedPosts.length === 0 && rawPosts.length > 0 && retries > 0) {
|
||||
console.warn(
|
||||
`🔄 Self-Healing triggered: All IDs were hallucinations. Challenging agent to find real IDs...`,
|
||||
);
|
||||
return this.findSocialPosts(topic, retries - 1, [
|
||||
...previousFailures,
|
||||
...failedIdsForThisRun,
|
||||
]);
|
||||
}
|
||||
|
||||
return verifiedPosts;
|
||||
}
|
||||
|
||||
private async planResearch(
|
||||
topic: string,
|
||||
): Promise<{ trendsKeywords: string[]; dcVariables: string[] }> {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.0-flash-001",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Plan research for: "${topic}".
|
||||
Return JSON:
|
||||
{
|
||||
"trendsKeywords": ["list", "of", "max", "2", "keywords"],
|
||||
"dcVariables": ["StatisticalVariables", "if", "known", "otherwise", "empty"]
|
||||
}
|
||||
CRITICAL: Do NOT provide more than 2 trendsKeywords. Keep it extremely focused.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
if (
|
||||
!response.choices ||
|
||||
response.choices.length === 0 ||
|
||||
!response.choices[0].message
|
||||
) {
|
||||
console.warn(`⚠️ Research planning failed for concept: "${topic}"`);
|
||||
return { trendsKeywords: [], dcVariables: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
let parsed = JSON.parse(
|
||||
response.choices[0].message.content ||
|
||||
'{"trendsKeywords": [], "dcVariables": []}',
|
||||
);
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed = parsed[0] || { trendsKeywords: [], dcVariables: [] };
|
||||
}
|
||||
return {
|
||||
trendsKeywords: Array.isArray(parsed.trendsKeywords)
|
||||
? parsed.trendsKeywords
|
||||
: [],
|
||||
dcVariables: Array.isArray(parsed.dcVariables)
|
||||
? parsed.dcVariables
|
||||
: [],
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to parse research plan JSON", e);
|
||||
return { trendsKeywords: [], dcVariables: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
52
packages/journaling/src/clients/data-commons.ts
Normal file
52
packages/journaling/src/clients/data-commons.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface DataPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class DataCommonsClient {
|
||||
private baseUrl = "https://api.datacommons.org";
|
||||
|
||||
/**
|
||||
* Fetches statistical series for a specific variable and place.
|
||||
* @param placeId DCID of the place (e.g., 'country/DEU' for Germany)
|
||||
* @param variable DCID of the statistical variable (e.g., 'Count_Person')
|
||||
*/
|
||||
async getStatSeries(placeId: string, variable: string): Promise<DataPoint[]> {
|
||||
try {
|
||||
// https://docs.datacommons.org/api/rest/v2/stat_series
|
||||
const response = await axios.get(`${this.baseUrl}/v2/stat/series`, {
|
||||
params: {
|
||||
place: placeId,
|
||||
stat_var: variable,
|
||||
},
|
||||
});
|
||||
|
||||
// Response format: { "series": { "country/DEU": { "Count_Person": { "val": { "2020": 83166711, ... } } } } }
|
||||
const seriesData = response.data?.series?.[placeId]?.[variable]?.val;
|
||||
|
||||
if (!seriesData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(seriesData)
|
||||
.map(([date, value]) => ({ date, value: Number(value) }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
} catch (error) {
|
||||
console.error(`DataCommons Error (${placeId}, ${variable}):`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for entities (places, etc.)
|
||||
*/
|
||||
async resolveEntity(name: string): Promise<string | null> {
|
||||
// Search API or simple mapping for now.
|
||||
// DC doesn't have a simple "search" endpoint in v2 public API easily accessible without key sometimes?
|
||||
// Let's rely on LLM to provide DCIDs for now, or implement a naive search if needed.
|
||||
// For now, return null to force LLM to guess/know DCIDs.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
79
packages/journaling/src/clients/trends.ts
Normal file
79
packages/journaling/src/clients/trends.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import OpenAI from "openai";
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class TrendsClient {
|
||||
private openai: OpenAI;
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
// Use environment key if available, otherwise expect it passed
|
||||
const key = apiKey || process.env.OPENROUTER_KEY || "dummy";
|
||||
this.openai = new OpenAI({
|
||||
apiKey: key,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://mintel.me",
|
||||
"X-Title": "Mintel Trends Engine",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates interest over time using LLM knowledge to avoid flaky scraping.
|
||||
* This ensures the "Digital Architect" pipelines don't break on API changes.
|
||||
*/
|
||||
async getInterestOverTime(
|
||||
keyword: string,
|
||||
geo: string = "DE",
|
||||
): Promise<TrendPoint[]> {
|
||||
console.log(
|
||||
`📈 Simuliere Suchvolumen-Trend (AI-basiert) für: "${keyword}" (Region: ${geo})...`,
|
||||
);
|
||||
try {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a data simulator. Generate a realistic Google Trends-style JSON dataset for the keyword "${keyword}" in "${geo}" over the last 5 years.
|
||||
Rules:
|
||||
- 12 data points (approx one every 6 months or represent key moments).
|
||||
- Values between 0-100.
|
||||
- JSON format: { "timeline": [{ "date": "YYYY-MM", "value": 50 }] }
|
||||
- Return ONLY JSON.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const body = response.choices[0].message.content || "{}";
|
||||
const parsed = JSON.parse(body);
|
||||
return parsed.timeline || [];
|
||||
} catch (error) {
|
||||
console.warn(`Simulated Trend Error (${keyword}):`, error);
|
||||
// Fallback mock data
|
||||
return [
|
||||
{ date: "2020-01", value: 20 },
|
||||
{ date: "2021-01", value: 35 },
|
||||
{ date: "2022-01", value: 50 },
|
||||
{ date: "2023-01", value: 75 },
|
||||
{ date: "2024-01", value: 95 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async getRelatedQueries(
|
||||
keyword: string,
|
||||
geo: string = "DE",
|
||||
): Promise<string[]> {
|
||||
// Simple mock to avoid API calls
|
||||
return [
|
||||
`${keyword} optimization`,
|
||||
`${keyword} tutorial`,
|
||||
`${keyword} best practices`,
|
||||
];
|
||||
}
|
||||
}
|
||||
3
packages/journaling/src/index.ts
Normal file
3
packages/journaling/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./clients/data-commons";
|
||||
export * from "./clients/trends";
|
||||
export * from "./agent";
|
||||
17
packages/journaling/src/types/google-trends-api.d.ts
vendored
Normal file
17
packages/journaling/src/types/google-trends-api.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
declare module "google-trends-api" {
|
||||
export function interestOverTime(options: {
|
||||
keyword: string | string[];
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
geo?: string;
|
||||
hl?: string;
|
||||
timezone?: number;
|
||||
category?: number;
|
||||
}): Promise<string>;
|
||||
|
||||
export function interestByRegion(options: any): Promise<string>;
|
||||
export function relatedQueries(options: any): Promise<string>;
|
||||
export function relatedTopics(options: any): Promise<string>;
|
||||
export function dailyTrends(options: any): Promise<string>;
|
||||
export function realTimeTrends(options: any): Promise<string>;
|
||||
}
|
||||
11
packages/journaling/tsconfig.json
Normal file
11
packages/journaling/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
29
packages/meme-generator/package.json
Normal file
29
packages/meme-generator/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@mintel/meme-generator",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"openai": "^4.82.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
141
packages/meme-generator/src/index.ts
Normal file
141
packages/meme-generator/src/index.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import OpenAI from "openai";
|
||||
|
||||
export interface MemeSuggestion {
|
||||
template: string;
|
||||
captions: string[];
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping of common meme names to memegen.link template IDs.
|
||||
* See https://api.memegen.link/templates for the full list.
|
||||
*/
|
||||
export const MEMEGEN_TEMPLATES: Record<string, string> = {
|
||||
drake: "drake",
|
||||
"drake hotline bling": "drake",
|
||||
"distracted boyfriend": "db",
|
||||
distracted: "db",
|
||||
"expanding brain": "brain",
|
||||
expanding: "brain",
|
||||
"this is fine": "fine",
|
||||
fine: "fine",
|
||||
clown: "clown-applying-makeup",
|
||||
"clown applying makeup": "clown-applying-makeup",
|
||||
"two buttons": "daily-struggle",
|
||||
"daily struggle": "daily-struggle",
|
||||
ds: "daily-struggle",
|
||||
gru: "gru",
|
||||
"change my mind": "cmm",
|
||||
"always has been": "ahb",
|
||||
"uno reverse": "uno",
|
||||
"disaster girl": "disastergirl",
|
||||
"is this a pigeon": "pigeon",
|
||||
"roll safe": "rollsafe",
|
||||
rollsafe: "rollsafe",
|
||||
"surprised pikachu": "pikachu",
|
||||
"batman slapping robin": "slap",
|
||||
"left exit 12": "exit",
|
||||
"one does not simply": "mordor",
|
||||
"panik kalm panik": "panik",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a human-readable meme name to a memegen.link template ID.
|
||||
* Falls back to slugified version of the name.
|
||||
*/
|
||||
export function resolveTemplateId(name: string): string {
|
||||
if (!name) return "drake";
|
||||
const normalized = name.toLowerCase().trim();
|
||||
|
||||
// Check if it's already a valid memegen ID
|
||||
const validIds = new Set(Object.values(MEMEGEN_TEMPLATES));
|
||||
if (validIds.has(normalized)) return normalized;
|
||||
|
||||
// Check mapping
|
||||
if (MEMEGEN_TEMPLATES[normalized]) return MEMEGEN_TEMPLATES[normalized];
|
||||
|
||||
// STRICT FALLBACK: Prevent 404 image errors on the frontend
|
||||
return "drake";
|
||||
}
|
||||
|
||||
export class MemeGenerator {
|
||||
private openai: OpenAI;
|
||||
|
||||
constructor(
|
||||
apiKey: string,
|
||||
baseUrl: string = "https://openrouter.ai/api/v1",
|
||||
) {
|
||||
this.openai = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://mintel.me",
|
||||
"X-Title": "Mintel AI Meme Generator",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async generateMemeIdeas(content: string): Promise<MemeSuggestion[]> {
|
||||
const templateList = Object.keys(MEMEGEN_TEMPLATES)
|
||||
.filter((k, i, arr) => arr.indexOf(k) === i)
|
||||
.slice(0, 20)
|
||||
.join(", ");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a high-end Meme Architect for "Mintel.me", a boutique digital architecture studio.
|
||||
Your persona is Marc Mintel: a technical expert, performance-obsessed, and "no-BS" digital architect.
|
||||
|
||||
Your Goal: Analyze the blog post content and suggest 3 high-fidelity, highly sarcastic, and provocative technical memes that would appeal to (and trigger) CEOs, CTOs, and high-level marketing engineers.
|
||||
|
||||
Meme Guidelines:
|
||||
1. Tone: Extremely sarcastic, provocative, and "triggering". It must mock typical B2B SaaS/Agency mediocrity. Pure sarcasm that forces people to share it because it hurts (e.g. throwing 20k ads at an 8-second loading page, blaming weather for bounce rates).
|
||||
2. Language: Use German for the captions. Use biting technical/business terms (e.g., "ROI-Killer", "Tracking-Müll", "WordPress-Hölle", "Marketing-Budget verbrennen").
|
||||
3. Quality: Must be ruthless. Avoid generic "Low Effort" memes. The humor should stem from the painful reality of bad tech decisions.
|
||||
|
||||
IMPORTANT: Use ONLY template IDs from this list for the "template" field:
|
||||
${templateList}
|
||||
|
||||
Return ONLY a JSON object:
|
||||
{
|
||||
"memes": [
|
||||
{
|
||||
"template": "memegen_template_id",
|
||||
"captions": ["Top caption", "Bottom caption"],
|
||||
"explanation": "Brief context on why this fits the strategy"
|
||||
}
|
||||
]
|
||||
}
|
||||
IMPORTANT: Return ONLY the JSON object. No markdown wrappers.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
const body = response.choices[0].message.content || '{"memes": []}';
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(body);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse AI response", body);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Normalize template IDs
|
||||
const memes: MemeSuggestion[] = (result.memes || []).map(
|
||||
(m: MemeSuggestion) => ({
|
||||
...m,
|
||||
template: resolveTemplateId(m.template),
|
||||
}),
|
||||
);
|
||||
|
||||
return memes;
|
||||
}
|
||||
}
|
||||
14
packages/meme-generator/src/placeholder.ts
Normal file
14
packages/meme-generator/src/placeholder.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function getPlaceholderImage(
|
||||
width: number,
|
||||
height: number,
|
||||
text: string,
|
||||
): string {
|
||||
// Generate a simple SVG placeholder as base64
|
||||
const svg = `
|
||||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="#1e293b"/>
|
||||
<text x="50%" y="50%" font-family="monospace" font-size="24" fill="#64748b" text-anchor="middle" dy=".3em">${text}</text>
|
||||
</svg>
|
||||
`.trim();
|
||||
return Buffer.from(svg).toString("base64");
|
||||
}
|
||||
11
packages/meme-generator/tsconfig.json
Normal file
11
packages/meme-generator/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -88,7 +88,8 @@ async function fetchStats() {
|
||||
}
|
||||
|
||||
function navigateTo(id: string, query?: any) {
|
||||
router.push({ name: `module-${id}`, query });
|
||||
console.log(`[Unified Dashboard] Navigating to ${id}...`);
|
||||
router.push({ name: id, query });
|
||||
}
|
||||
|
||||
onMounted(fetchStats);
|
||||
|
||||
312
pnpm-lock.yaml
generated
312
pnpm-lock.yaml
generated
@@ -164,7 +164,7 @@ importers:
|
||||
devDependencies:
|
||||
'@directus/extensions-sdk':
|
||||
specifier: 11.0.2
|
||||
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
|
||||
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
|
||||
'@mintel/mail':
|
||||
specifier: workspace:*
|
||||
version: link:../mail
|
||||
@@ -262,6 +262,37 @@ importers:
|
||||
specifier: ^3.4.0
|
||||
version: 3.5.28(typescript@5.9.3)
|
||||
|
||||
packages/content-engine:
|
||||
dependencies:
|
||||
'@mintel/journaling':
|
||||
specifier: workspace:*
|
||||
version: link:../journaling
|
||||
'@mintel/meme-generator':
|
||||
specifier: workspace:*
|
||||
version: link:../meme-generator
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.3.1
|
||||
openai:
|
||||
specifier: ^4.82.0
|
||||
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
|
||||
devDependencies:
|
||||
'@mintel/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../eslint-config
|
||||
'@mintel/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../tsconfig
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.33
|
||||
tsup:
|
||||
specifier: ^8.3.5
|
||||
version: 8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/customer-manager:
|
||||
dependencies:
|
||||
'@mintel/directus-extension-toolkit':
|
||||
@@ -408,6 +439,34 @@ importers:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/journaling:
|
||||
dependencies:
|
||||
axios:
|
||||
specifier: ^1.6.0
|
||||
version: 1.13.5
|
||||
google-trends-api:
|
||||
specifier: ^4.9.2
|
||||
version: 4.9.2
|
||||
openai:
|
||||
specifier: ^4.82.0
|
||||
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
|
||||
devDependencies:
|
||||
'@mintel/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../eslint-config
|
||||
'@mintel/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../tsconfig
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.33
|
||||
tsup:
|
||||
specifier: ^8.3.5
|
||||
version: 8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/mail:
|
||||
dependencies:
|
||||
'@react-email/components':
|
||||
@@ -443,7 +502,26 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.4
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
|
||||
packages/meme-generator:
|
||||
dependencies:
|
||||
openai:
|
||||
specifier: ^4.82.0
|
||||
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
|
||||
devDependencies:
|
||||
'@mintel/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../eslint-config
|
||||
'@mintel/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../tsconfig
|
||||
tsup:
|
||||
specifier: ^8.3.5
|
||||
version: 8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/next-config:
|
||||
dependencies:
|
||||
@@ -605,7 +683,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
|
||||
packages/pdf-library:
|
||||
dependencies:
|
||||
@@ -3265,9 +3343,15 @@ packages:
|
||||
'@types/mysql@2.15.27':
|
||||
resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==}
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
|
||||
|
||||
'@types/node@12.20.55':
|
||||
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@20.19.33':
|
||||
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
|
||||
|
||||
@@ -3720,6 +3804,10 @@ packages:
|
||||
'@xtuc/long@4.2.2':
|
||||
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
abs-svg-path@0.1.1:
|
||||
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
||||
|
||||
@@ -3756,6 +3844,10 @@ packages:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
agentkeepalive@4.6.0:
|
||||
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
|
||||
ajv-formats@2.1.1:
|
||||
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
||||
peerDependencies:
|
||||
@@ -4532,6 +4624,10 @@ packages:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dotenv@17.3.1:
|
||||
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4814,6 +4910,10 @@ packages:
|
||||
event-stream@3.3.4:
|
||||
resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==}
|
||||
|
||||
event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
@@ -4967,6 +5067,9 @@ packages:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
form-data-encoder@1.7.2:
|
||||
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
|
||||
|
||||
form-data-encoder@4.1.0:
|
||||
resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -4975,6 +5078,10 @@ packages:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
formdata-node@4.4.1:
|
||||
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
|
||||
engines: {node: '>= 12.20'}
|
||||
|
||||
forwarded-parse@2.1.2:
|
||||
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
|
||||
|
||||
@@ -5140,6 +5247,9 @@ packages:
|
||||
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
google-trends-api@4.9.2:
|
||||
resolution: {integrity: sha512-gjVSHCM8B7LyAAUpXb4B0/TfnmpwQ2z1w/mQ2bL0AKpr2j3gLS1j2YOnifpfsGJRxAGXB/NoC+nGwC5qSnZIiA==}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5267,6 +5377,9 @@ packages:
|
||||
resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
humanize-ms@1.2.1:
|
||||
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||
|
||||
husky@9.1.7:
|
||||
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6140,6 +6253,11 @@ packages:
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
@@ -6235,6 +6353,18 @@ packages:
|
||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
openai@4.104.0:
|
||||
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
zod: ^3.23.8
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -7621,6 +7751,9 @@ packages:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
@@ -7917,6 +8050,10 @@ packages:
|
||||
wcwidth@1.0.1:
|
||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||
|
||||
web-streams-polyfill@4.0.0-beta.3:
|
||||
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -8826,57 +8963,6 @@ snapshots:
|
||||
|
||||
'@directus/constants@11.0.3': {}
|
||||
|
||||
'@directus/extensions-sdk@11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@directus/composables': 10.1.12(vue@3.4.21(typescript@5.9.3))
|
||||
'@directus/constants': 11.0.3
|
||||
'@directus/extensions': 1.0.2(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))
|
||||
'@directus/themes': 0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))
|
||||
'@directus/types': 11.0.8(knex@3.1.0)(vue@3.4.21(typescript@5.9.3))
|
||||
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
|
||||
'@rollup/plugin-commonjs': 25.0.7(rollup@3.29.4)
|
||||
'@rollup/plugin-json': 6.1.0(rollup@3.29.4)
|
||||
'@rollup/plugin-node-resolve': 15.2.3(rollup@3.29.4)
|
||||
'@rollup/plugin-replace': 5.0.5(rollup@3.29.4)
|
||||
'@rollup/plugin-terser': 0.4.4(rollup@3.29.4)
|
||||
'@rollup/plugin-virtual': 3.0.2(rollup@3.29.4)
|
||||
'@vitejs/plugin-vue': 4.6.2(vite@4.5.2(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))(vue@3.4.21(typescript@5.9.3))
|
||||
chalk: 5.3.0
|
||||
commander: 10.0.1
|
||||
esbuild: 0.17.19
|
||||
execa: 7.2.0
|
||||
fs-extra: 11.2.0
|
||||
inquirer: 9.2.16
|
||||
ora: 6.3.1
|
||||
rollup: 3.29.4
|
||||
rollup-plugin-esbuild: 5.0.0(esbuild@0.17.19)(rollup@3.29.4)
|
||||
rollup-plugin-styles: 4.0.0(rollup@3.29.4)
|
||||
vite: 4.5.2(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
|
||||
vue: 3.4.21(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- '@unhead/vue'
|
||||
- better-sqlite3
|
||||
- debug
|
||||
- knex
|
||||
- less
|
||||
- lightningcss
|
||||
- mysql
|
||||
- mysql2
|
||||
- pg
|
||||
- pg-native
|
||||
- pinia
|
||||
- pino
|
||||
- sass
|
||||
- sqlite3
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- tedious
|
||||
- terser
|
||||
- typescript
|
||||
- vue-router
|
||||
|
||||
'@directus/extensions-sdk@11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@directus/composables': 10.1.12(vue@3.4.21(typescript@5.9.3))
|
||||
@@ -8928,32 +9014,6 @@ snapshots:
|
||||
- typescript
|
||||
- vue-router
|
||||
|
||||
'@directus/extensions@1.0.2(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@directus/constants': 11.0.3
|
||||
'@directus/themes': 0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))
|
||||
'@directus/types': 11.0.8(knex@3.1.0)(vue@3.4.21(typescript@5.9.3))
|
||||
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
|
||||
'@types/express': 4.17.21
|
||||
fs-extra: 11.2.0
|
||||
lodash-es: 4.17.21
|
||||
zod: 3.22.4
|
||||
optionalDependencies:
|
||||
knex: 3.1.0
|
||||
pino: 10.3.1
|
||||
vue: 3.4.21(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@unhead/vue'
|
||||
- better-sqlite3
|
||||
- mysql
|
||||
- mysql2
|
||||
- pg
|
||||
- pg-native
|
||||
- pinia
|
||||
- sqlite3
|
||||
- supports-color
|
||||
- tedious
|
||||
|
||||
'@directus/extensions@1.0.2(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@directus/constants': 11.0.3
|
||||
@@ -8997,17 +9057,6 @@ snapshots:
|
||||
|
||||
'@directus/system-data@1.0.2': {}
|
||||
|
||||
'@directus/themes@0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
|
||||
'@sinclair/typebox': 0.32.15
|
||||
'@unhead/vue': 1.11.20(vue@3.4.21(typescript@5.9.3))
|
||||
decamelize: 6.0.0
|
||||
flat: 6.0.1
|
||||
lodash-es: 4.17.21
|
||||
pinia: 2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3))
|
||||
vue: 3.4.21(typescript@5.9.3)
|
||||
|
||||
'@directus/themes@0.3.6(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
|
||||
@@ -10927,8 +10976,17 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.19.33
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 20.19.33
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node@12.20.55': {}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@20.19.33':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -11099,14 +11157,6 @@ snapshots:
|
||||
'@unhead/schema': 1.11.20
|
||||
packrup: 0.1.2
|
||||
|
||||
'@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@unhead/schema': 1.11.20
|
||||
'@unhead/shared': 1.11.20
|
||||
hookable: 5.5.3
|
||||
unhead: 1.11.20
|
||||
vue: 3.4.21(typescript@5.9.3)
|
||||
|
||||
'@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@unhead/schema': 1.11.20
|
||||
@@ -11521,6 +11571,10 @@ snapshots:
|
||||
|
||||
'@xtuc/long@4.2.2': {}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
abs-svg-path@0.1.1: {}
|
||||
|
||||
acorn-import-attributes@1.9.5(acorn@8.15.0):
|
||||
@@ -11547,6 +11601,10 @@ snapshots:
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
agentkeepalive@4.6.0:
|
||||
dependencies:
|
||||
humanize-ms: 1.2.1
|
||||
|
||||
ajv-formats@2.1.1(ajv@8.17.1):
|
||||
optionalDependencies:
|
||||
ajv: 8.17.1
|
||||
@@ -12367,6 +12425,8 @@ snapshots:
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
dotenv@17.3.1: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -12897,6 +12957,8 @@ snapshots:
|
||||
stream-combiner: 0.0.4
|
||||
through: 2.3.8
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
@@ -13053,6 +13115,8 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
form-data-encoder@1.7.2: {}
|
||||
|
||||
form-data-encoder@4.1.0: {}
|
||||
|
||||
form-data@4.0.5:
|
||||
@@ -13063,6 +13127,11 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
formdata-node@4.4.1:
|
||||
dependencies:
|
||||
node-domexception: 1.0.0
|
||||
web-streams-polyfill: 4.0.0-beta.3
|
||||
|
||||
forwarded-parse@2.1.2: {}
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
@@ -13238,6 +13307,8 @@ snapshots:
|
||||
merge2: 1.4.1
|
||||
slash: 3.0.0
|
||||
|
||||
google-trends-api@4.9.2: {}
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
got-scraping@4.1.3:
|
||||
@@ -13406,6 +13477,10 @@ snapshots:
|
||||
|
||||
human-signals@4.3.1: {}
|
||||
|
||||
humanize-ms@1.2.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
hyphen@1.14.1: {}
|
||||
@@ -14251,6 +14326,8 @@ snapshots:
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
@@ -14341,6 +14418,21 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-function: 5.0.1
|
||||
|
||||
openai@4.104.0(ws@8.19.0)(zod@3.25.76):
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
'@types/node-fetch': 2.6.13
|
||||
abort-controller: 3.0.0
|
||||
agentkeepalive: 4.6.0
|
||||
form-data-encoder: 1.7.2
|
||||
formdata-node: 4.4.1
|
||||
node-fetch: 2.7.0
|
||||
optionalDependencies:
|
||||
ws: 8.19.0
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -14539,16 +14631,6 @@ snapshots:
|
||||
|
||||
pify@4.0.1: {}
|
||||
|
||||
pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.4.21(typescript@5.9.3)
|
||||
vue-demi: 0.14.10(vue@3.4.21(typescript@5.9.3))
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
@@ -15861,6 +15943,8 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
which-boxed-primitive: 1.1.1
|
||||
|
||||
undici-types@5.26.5: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici@7.21.0: {}
|
||||
@@ -16029,7 +16113,7 @@ snapshots:
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
vitest@2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
|
||||
vitest@2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))
|
||||
@@ -16067,7 +16151,7 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
@@ -16150,10 +16234,6 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vue-demi@0.14.10(vue@3.4.21(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.4.21(typescript@5.9.3)
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.28(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.28(typescript@5.9.3)
|
||||
@@ -16191,6 +16271,8 @@ snapshots:
|
||||
dependencies:
|
||||
defaults: 1.0.4
|
||||
|
||||
web-streams-polyfill@4.0.0-beta.3: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@7.0.0: {}
|
||||
|
||||
@@ -2,65 +2,115 @@
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Define potential container names
|
||||
CONTAINERS=("cms-infra-infra-cms-1" "at-mintel-directus-1")
|
||||
|
||||
echo "🔧 Checking for Directus containers to patch..."
|
||||
|
||||
for CONTAINER in "${CONTAINERS[@]}"; do
|
||||
# Check if container exists and is running
|
||||
if [ "$(docker ps -q -f name=^/${CONTAINER}$)" ]; then
|
||||
echo "🔧 Applying core patch to Directus container: $CONTAINER..."
|
||||
docker exec "$CONTAINER" node -e '
|
||||
echo "🔧 Applying core patches to: $CONTAINER..."
|
||||
|
||||
# Capture output to determine if restart is needed
|
||||
OUTPUT=$(docker exec -i "$CONTAINER" node << 'EOF'
|
||||
const fs = require("node:fs");
|
||||
// Try multiple potential paths for the node_modules location
|
||||
const searchPaths = [
|
||||
"/directus/node_modules/.pnpm/@directus+extensions@file+packages+extensions_deep-diff@1.0.2_express@4.21.2_graphql@16_244b87fbecd929c2d2240e7b3abc1fe4/node_modules/@directus/extensions/dist/node.js",
|
||||
"/directus/node_modules/@directus/extensions/dist/node.js"
|
||||
];
|
||||
|
||||
let targetPath = null;
|
||||
for (const p of searchPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
targetPath = p;
|
||||
break;
|
||||
}
|
||||
const { execSync } = require("node:child_process");
|
||||
let patched = false;
|
||||
|
||||
try {
|
||||
// 1. Patch @directus/extensions node.js (Entrypoints)
|
||||
const findNodeCmd = "find /directus/node_modules -path \"*/@directus/extensions/dist/node.js\"";
|
||||
const nodePaths = execSync(findNodeCmd).toString().trim().split("\n").filter(Boolean);
|
||||
|
||||
nodePaths.forEach(targetPath => {
|
||||
let content = fs.readFileSync(targetPath, "utf8");
|
||||
let modified = false;
|
||||
|
||||
const filterPatch = 'extension.host === "app" && (extension.entrypoint.app || extension.entrypoint)';
|
||||
|
||||
// Only replace if the OLD pattern exists
|
||||
if (content.includes('extension.host === "app" && !!extension.entrypoint.app')) {
|
||||
content = content.replace(/extension\.host === "app" && !!extension\.entrypoint\.app/g, filterPatch);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Only replace if the OLD pattern exists for entrypoint
|
||||
// We check if "extension.entrypoint.app" is present but NOT part of our patch
|
||||
// This is a simple heuristic: if the patch string is NOT present, but the target IS.
|
||||
if (!content.includes("(extension.entrypoint.app || extension.entrypoint)")) {
|
||||
if (content.includes("extension.entrypoint.app")) {
|
||||
content = content.replace(/extension\.entrypoint\.app/g, "(extension.entrypoint.app || extension.entrypoint)");
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
fs.writeFileSync(targetPath, content);
|
||||
console.log(`✅ Entrypoint patched.`);
|
||||
patched = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Patch @directus/api manager.js (HTML Injection)
|
||||
const findManagerCmd = "find /directus/node_modules -path \"*/@directus/api/dist/extensions/manager.js\"";
|
||||
const managerPaths = execSync(findManagerCmd).toString().trim().split("\n").filter(Boolean);
|
||||
|
||||
managerPaths.forEach(targetPath => {
|
||||
let content = fs.readFileSync(targetPath, "utf8");
|
||||
|
||||
const original = "head: wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),";
|
||||
const injection = "head: '<script type=\"module\" src=\"/extensions/sources/index.js\"></script>\\n' + wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),";
|
||||
|
||||
if (content.includes(original) && !content.includes("/extensions/sources/index.js")) {
|
||||
content = content.replace(original, injection);
|
||||
fs.writeFileSync(targetPath, content);
|
||||
console.log(`✅ Injection patched.`);
|
||||
patched = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Patch @directus/api app.js (CSP for unsafe-inline)
|
||||
const findAppCmd = "find /directus/node_modules -path \"*/@directus/api/dist/app.js\"";
|
||||
const appPaths = execSync(findAppCmd).toString().trim().split("\n").filter(Boolean);
|
||||
|
||||
appPaths.forEach(targetPath => {
|
||||
let content = fs.readFileSync(targetPath, "utf8");
|
||||
let modified = false;
|
||||
|
||||
const original = "scriptSrc: [\"'self'\", \"'unsafe-eval'\"],";
|
||||
const patchedStr = "scriptSrc: [\"'self'\", \"'unsafe-eval'\", \"'unsafe-inline'\"],";
|
||||
|
||||
if (content.includes(original)) {
|
||||
content = content.replace(original, patchedStr);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
fs.writeFileSync(targetPath, content);
|
||||
console.log(`✅ CSP patched in app.js.`);
|
||||
patched = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (patched) process.exit(100); // Signal restart needed
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Error applying patch:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
EOF
|
||||
)
|
||||
EXIT_CODE=$?
|
||||
echo "$OUTPUT"
|
||||
|
||||
if (targetPath) {
|
||||
let content = fs.readFileSync(targetPath, "utf8");
|
||||
|
||||
// Patch the filter: allow string entrypoints for modules
|
||||
const filterPatch = "extension.host === \"app\" && (extension.entrypoint.app || extension.entrypoint)";
|
||||
if (!content.includes(filterPatch)) {
|
||||
content = content.replace(
|
||||
/extension\.host === \"app\" && !!extension\.entrypoint\.app/g,
|
||||
filterPatch
|
||||
);
|
||||
}
|
||||
|
||||
// Patch all imports: handle string entrypoints
|
||||
if (!content.includes("(extension.entrypoint.app || extension.entrypoint)")) {
|
||||
content = content.replace(
|
||||
/extension\.entrypoint\.app/g,
|
||||
"(extension.entrypoint.app || extension.entrypoint)"
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(targetPath, content);
|
||||
console.log(`✅ Core patched successfully at ${targetPath}.`);
|
||||
} else {
|
||||
console.error("⚠️ Could not find @directus/extensions node.js to patch!");
|
||||
}
|
||||
'
|
||||
|
||||
echo "🔄 Restarting Directus container: $CONTAINER..."
|
||||
docker restart "$CONTAINER"
|
||||
if [ $EXIT_CODE -eq 100 ]; then
|
||||
echo "🔄 Patches applied. Restarting Directus container: $CONTAINER..."
|
||||
docker restart "$CONTAINER"
|
||||
else
|
||||
echo "✅ Container $CONTAINER is already patched. No restart needed."
|
||||
fi
|
||||
else
|
||||
echo "ℹ️ Container $CONTAINER is not running or not found. Skipping patch."
|
||||
echo "ℹ️ Container $CONTAINER not found. Skipping."
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✨ Patching process finished."
|
||||
echo "✨ All patches check complete."
|
||||
|
||||
@@ -67,18 +67,10 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
|
||||
rm -rf "${FINAL_TARGET:?}"/*
|
||||
|
||||
# Copy build artifacts
|
||||
if [ "$LINK_MODE" = true ]; then
|
||||
if [ -f "$PKG_PATH/dist/index.js" ]; then
|
||||
ln -sf "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
|
||||
elif [ -f "$PKG_PATH/index.js" ]; then
|
||||
ln -sf "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
|
||||
fi
|
||||
else
|
||||
if [ -f "$PKG_PATH/dist/index.js" ]; then
|
||||
cp "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
|
||||
elif [ -f "$PKG_PATH/index.js" ]; then
|
||||
cp "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
|
||||
fi
|
||||
if [ -f "$PKG_PATH/dist/index.js" ]; then
|
||||
cp "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
|
||||
elif [ -f "$PKG_PATH/index.js" ]; then
|
||||
cp "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
|
||||
fi
|
||||
|
||||
if [ -f "$PKG_PATH/package.json" ]; then
|
||||
@@ -106,7 +98,8 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
|
||||
|
||||
if [ -d "$PKG_PATH/dist" ]; then
|
||||
if [ "$LINK_MODE" = true ]; then
|
||||
ln -sf "$PKG_PATH/dist" "$FINAL_TARGET/dist"
|
||||
REL_PATH=$(python3 -c "import os; print(os.path.relpath('$PKG_PATH/dist', '$FINAL_TARGET'))")
|
||||
ln -sf "$REL_PATH" "$FINAL_TARGET/dist"
|
||||
else
|
||||
cp -r "$PKG_PATH/dist" "$FINAL_TARGET/"
|
||||
fi
|
||||
@@ -120,7 +113,7 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
|
||||
done
|
||||
|
||||
# Cleanup: remove anything from extensions root that isn't in our whitelist
|
||||
WHITELIST=("${EXTENSION_PACKAGES[@]}" "endpoints" "hooks" "layouts" "modules" "operations" "panels" "displays" "interfaces")
|
||||
WHITELIST=("${EXTENSION_PACKAGES[@]}" "sources" "endpoints" "hooks" "layouts" "modules" "operations" "panels" "displays" "interfaces")
|
||||
|
||||
for TARGET_BASE in "${TARGET_DIRS[@]}"; do
|
||||
echo "🧹 Cleaning up $TARGET_BASE..."
|
||||
|
||||
91
scripts/validate-cms.sh
Executable file
91
scripts/validate-cms.sh
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
CONTAINER="cms-infra-infra-cms-1"
|
||||
|
||||
echo "🔍 Validating Directus Extension Stability..."
|
||||
|
||||
# 1. Verify Patches
|
||||
echo "🛠️ Checking Core Patches..."
|
||||
docker exec -i "$CONTAINER" node << 'EOF'
|
||||
const fs = require('node:fs');
|
||||
const { execSync } = require('node:child_process');
|
||||
|
||||
let failures = 0;
|
||||
|
||||
// Check Node.js patch
|
||||
const findNode = 'find /directus/node_modules -path "*/@directus/extensions/dist/node.js"';
|
||||
const nodePaths = execSync(findNode).toString().trim().split('\n').filter(Boolean);
|
||||
nodePaths.forEach(p => {
|
||||
const c = fs.readFileSync(p, 'utf8');
|
||||
if (!c.includes('(extension.entrypoint.app || extension.entrypoint)')) {
|
||||
console.error('❌ Missing node.js patch at ' + p);
|
||||
failures++;
|
||||
}
|
||||
});
|
||||
|
||||
// Check Manager.js patch
|
||||
const findManager = 'find /directus/node_modules -path "*/@directus/api/dist/extensions/manager.js"';
|
||||
const managerPaths = execSync(findManager).toString().trim().split('\n').filter(Boolean);
|
||||
managerPaths.forEach(p => {
|
||||
const c = fs.readFileSync(p, 'utf8');
|
||||
if (!c.includes('/extensions/sources/index.js')) {
|
||||
console.error('❌ Missing manager.js patch at ' + p);
|
||||
failures++;
|
||||
}
|
||||
});
|
||||
|
||||
if (failures === 0) {
|
||||
console.log('✅ Core patches are healthy.');
|
||||
}
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
EOF
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "⚠️ Core patches missing! Run 'bash scripts/patch-cms.sh' to fix."
|
||||
fi
|
||||
|
||||
# 2. Verify Module Bar
|
||||
echo "📋 Checking Sidebar Configuration..."
|
||||
docker exec -i "$CONTAINER" node << 'EOF'
|
||||
const sqlite3 = require('/directus/node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3');
|
||||
const db = new sqlite3.Database('/directus/database/data.db');
|
||||
const managerIds = ["unified-dashboard", "acquisition-manager", "company-manager", "customer-manager", "feedback-commander", "people-manager"];
|
||||
|
||||
db.get('SELECT module_bar FROM directus_settings WHERE id = 1', (err, row) => {
|
||||
if (err) { console.error('❌ DB Error:', err.message); process.exit(1); }
|
||||
|
||||
let mb = [];
|
||||
try { mb = JSON.parse(row.module_bar || '[]'); } catch(e) { mb = []; }
|
||||
|
||||
const existingIds = mb.map(m => m.id);
|
||||
const missing = managerIds.filter(id => !existingIds.includes(id));
|
||||
const disabled = mb.filter(m => managerIds.includes(m.id) && m.enabled === false);
|
||||
|
||||
if (missing.length === 0 && disabled.length === 0) {
|
||||
console.log('✅ Sidebar is healthy with all manager modules enabled.');
|
||||
process.exit(0);
|
||||
} else {
|
||||
if (missing.length > 0) console.log('⚠️ Missing modules:', missing.join(', '));
|
||||
if (disabled.length > 0) console.log('⚠️ Disabled modules:', disabled.map(m => m.id).join(', '));
|
||||
|
||||
console.log('🔧 Self-healing in progress...');
|
||||
|
||||
// Construct Golden State Module Bar
|
||||
const goldenMB = [
|
||||
{ type: 'module', id: 'content', enabled: true },
|
||||
{ type: 'module', id: 'users', enabled: true },
|
||||
{ type: 'module', id: 'files', enabled: true },
|
||||
{ type: 'module', id: 'insights', enabled: true },
|
||||
...managerIds.map(id => ({ type: 'module', id, enabled: true })),
|
||||
{ type: 'module', id: 'settings', enabled: true }
|
||||
];
|
||||
|
||||
db.run('UPDATE directus_settings SET module_bar = ? WHERE id = 1', [JSON.stringify(goldenMB)], function(err) {
|
||||
if (err) { console.error('❌ Repair failed:', err.message); process.exit(1); }
|
||||
console.log('✨ Sidebar repaired successfully!');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
EOF
|
||||
Reference in New Issue
Block a user