feat: content engine
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m12s
Monorepo Pipeline / 🧪 Test (push) Successful in 2m59s
Monorepo Pipeline / 🏗️ Build (push) Successful in 6m52s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 4s
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m12s
Monorepo Pipeline / 🧪 Test (push) Successful in 2m59s
Monorepo Pipeline / 🏗️ Build (push) Successful in 6m52s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 4s
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"dependencies": {
|
||||
"@mintel/journaling": "workspace:*",
|
||||
"@mintel/meme-generator": "workspace:*",
|
||||
"@mintel/thumbnail-generator": "workspace:*",
|
||||
"dotenv": "^17.3.1",
|
||||
"openai": "^4.82.0"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenAI from "openai";
|
||||
import { ResearchAgent, Fact, SocialPost } from "@mintel/journaling";
|
||||
import { ResearchAgent, type Fact, type SocialPost } from "@mintel/journaling";
|
||||
import { MemeGenerator, MemeSuggestion } from "@mintel/meme-generator";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
@@ -237,11 +237,21 @@ REGELN:
|
||||
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),
|
||||
);
|
||||
// ----- STEP 1.5: Social Media Extraction (no LLM — regex only) -----
|
||||
console.log("📱 Extracting existing social media embeds...");
|
||||
const socialPosts = this.researchAgent.extractSocialPosts(content);
|
||||
|
||||
// If none exist, fetch real ones via Serper API
|
||||
if (socialPosts.length === 0) {
|
||||
console.log(
|
||||
" → None found. Fetching real social posts via Serper API...",
|
||||
);
|
||||
const newPosts = await this.researchAgent.fetchRealSocialPosts(
|
||||
content.slice(0, 500),
|
||||
);
|
||||
socialPosts.push(...newPosts);
|
||||
}
|
||||
|
||||
if (socialPosts.length > 0) {
|
||||
console.log(
|
||||
`📝 Planning placement for ${socialPosts.length} social media posts...`,
|
||||
@@ -593,7 +603,7 @@ RULES:
|
||||
- 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.
|
||||
- Add a 1-sentence intro paragraph above the embed to contextualize it naturally in the flow of the text (e.g. "Wie Experte XY im folgenden Video detailliert erklärt:"). This context is MANDATORY. Do not just drop the Component without text reference.
|
||||
|
||||
CONTEXT:
|
||||
${context.slice(0, 3000)}
|
||||
@@ -842,6 +852,11 @@ Tone: ${tone}.
|
||||
Facts: ${factsContext}
|
||||
${componentsContext}
|
||||
|
||||
BLOG POST BEST PRACTICES (MANDATORY):
|
||||
- DEVIL'S ADVOCATE: Füge zwingend eine kurze kritische Sektion ein (z.B. mit \`<ComparisonRow>\` oder \`<IconList>\`), in der du offen die Nachteile/Kosten/Haken deiner eigenen Lösung ansprichst ("Der Haken an der Sache...").
|
||||
- FAQ GENERATOR: Am absoluten Ende des Artikels erstellst du zwingend eine Markdown-Liste mit den 3 wichtigsten Fragen (FAQ) und Antworten (jeweils 2 Sätze) für Google Rich Snippets.
|
||||
- Nutze wo passend die obigen React-Komponenten für ein hochwertiges Layout.
|
||||
|
||||
Format as Markdown. Start with # H1.
|
||||
For places where a diagram would help, insert: <!-- DIAGRAM_PLACEHOLDER: Concept Name -->
|
||||
Return ONLY raw content.`,
|
||||
@@ -891,6 +906,7 @@ RULES:
|
||||
- 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 curly braces '{}' or brackets '[]' inside labels unless they are wrapped in double quotes (e.g. A["Text {with braces}"]).
|
||||
- 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.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import OpenAI from "openai";
|
||||
import { ResearchAgent, Fact, SocialPost } from "@mintel/journaling";
|
||||
import { ResearchAgent, type Fact, type SocialPost } from "@mintel/journaling";
|
||||
import { ThumbnailGenerator } from "@mintel/thumbnail-generator";
|
||||
import { ComponentDefinition } from "./generator";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface OrchestratorConfig {
|
||||
apiKey: string;
|
||||
replicateApiKey?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
@@ -14,6 +16,7 @@ export interface OptimizationTask {
|
||||
projectContext: string;
|
||||
availableComponents?: ComponentDefinition[];
|
||||
instructions?: string;
|
||||
internalLinks?: { title: string; slug: string }[];
|
||||
}
|
||||
|
||||
export interface OptimizeFileOptions {
|
||||
@@ -24,6 +27,7 @@ export interface OptimizeFileOptions {
|
||||
export class AiBlogPostOrchestrator {
|
||||
private openai: OpenAI;
|
||||
private researchAgent: ResearchAgent;
|
||||
private thumbnailGenerator?: ThumbnailGenerator;
|
||||
private model: string;
|
||||
|
||||
constructor(config: OrchestratorConfig) {
|
||||
@@ -37,6 +41,11 @@ export class AiBlogPostOrchestrator {
|
||||
},
|
||||
});
|
||||
this.researchAgent = new ResearchAgent(config.apiKey);
|
||||
if (config.replicateApiKey) {
|
||||
this.thumbnailGenerator = new ThumbnailGenerator({
|
||||
replicateApiKey: config.replicateApiKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,9 +85,15 @@ export class AiBlogPostOrchestrator {
|
||||
|
||||
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;
|
||||
// Idea 4: We no longer split frontmatter and body. We pass the whole file
|
||||
// to the LLM so it can optimize the SEO title and description.
|
||||
|
||||
// Idea 1: Build Internal Link Graph
|
||||
const blogDir = path.dirname(absPath);
|
||||
const internalLinks = await this.buildInternalLinkGraph(
|
||||
blogDir,
|
||||
path.basename(absPath),
|
||||
);
|
||||
|
||||
console.log(`📖 Loading context from: ${options.contextDir}`);
|
||||
const projectContext = await this.loadContext(options.contextDir);
|
||||
@@ -89,50 +104,199 @@ export class AiBlogPostOrchestrator {
|
||||
}
|
||||
|
||||
const optimizedContent = await this.optimizeDocument({
|
||||
content: body,
|
||||
content: content,
|
||||
projectContext,
|
||||
availableComponents: options.availableComponents,
|
||||
internalLinks: internalLinks, // pass to orchestrator
|
||||
});
|
||||
|
||||
const finalOutput = frontmatter
|
||||
? `${frontmatter}\n\n${optimizedContent}`
|
||||
: optimizedContent;
|
||||
// Idea 4b: Extract the potentially updated title to rename the file (SEO Slug)
|
||||
const newFmMatch = optimizedContent.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
let finalPath = absPath;
|
||||
let finalSlug = path.basename(absPath, ".mdx");
|
||||
|
||||
await fs.writeFile(`${absPath}.bak`, content); // Keep simple backup
|
||||
await fs.writeFile(absPath, finalOutput);
|
||||
console.log(`✅ Saved optimized file to: ${absPath}`);
|
||||
if (newFmMatch && newFmMatch[1]) {
|
||||
const titleMatch = newFmMatch[1].match(/title:\s*["']([^"']+)["']/);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
const newTitle = titleMatch[1];
|
||||
// Generate SEO Slug
|
||||
finalSlug = newTitle
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
const newAbsPath = path.join(path.dirname(absPath), `${finalSlug}.mdx`);
|
||||
if (newAbsPath !== absPath) {
|
||||
console.log(
|
||||
`🔄 SEO Title changed! Renaming file to: ${finalSlug}.mdx`,
|
||||
);
|
||||
// Delete old file if the title changed significantly
|
||||
try {
|
||||
await fs.unlink(absPath);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
finalPath = newAbsPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Idea 5: Automatic Thumbnails
|
||||
let finalContent = optimizedContent;
|
||||
|
||||
// Skip if thumbnail already exists in frontmatter
|
||||
const hasExistingThumbnail = /thumbnail:\s*["'][^"']+["']/.test(
|
||||
finalContent,
|
||||
);
|
||||
|
||||
if (this.thumbnailGenerator && !hasExistingThumbnail) {
|
||||
console.log("🎨 Phase 5: Generating visual thumbnail...");
|
||||
try {
|
||||
const visualPrompt = await this.generateVisualPrompt(finalContent);
|
||||
// We assume public dir is relative to where this runs, usually monorepo root or apps/web
|
||||
const webPublicDir = path.resolve(process.cwd(), "apps/web/public");
|
||||
const thumbnailRelPath = `/blog/${finalSlug}.png`;
|
||||
const thumbnailAbsPath = path.join(
|
||||
webPublicDir,
|
||||
"blog",
|
||||
`${finalSlug}.png`,
|
||||
);
|
||||
|
||||
await this.thumbnailGenerator.generateImage(
|
||||
visualPrompt,
|
||||
thumbnailAbsPath,
|
||||
);
|
||||
|
||||
// Update frontmatter with thumbnail (SEO: we also want it as a hero)
|
||||
if (finalContent.includes("thumbnail:")) {
|
||||
finalContent = finalContent.replace(
|
||||
/thumbnail:\s*["'].*?["']/,
|
||||
`thumbnail: "${thumbnailRelPath}"`,
|
||||
);
|
||||
} else {
|
||||
finalContent = finalContent.replace(
|
||||
/(title:\s*["'].*?["'])/,
|
||||
`$1\nthumbnail: "${thumbnailRelPath}"`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("⚠️ Thumbnail generation failed, skipping:", e);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(finalPath, finalContent);
|
||||
console.log(`✅ Saved optimized file to: ${finalPath}`);
|
||||
}
|
||||
|
||||
private async generateVisualPrompt(content: string): Promise<string> {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a Visual Discovery Agent for an architectural design system.
|
||||
Review the provided blog post and create a 1-sentence abstract visual description for an image generator (like Flux).
|
||||
|
||||
THEME: Technical blueprint / structural illustration.
|
||||
STYLE: Clean lines, geometric shapes, monochrome base with one highlighter accent color (green, pink, or yellow).
|
||||
NO TEXT. NO PEOPLE. NO REALISTIC PHOTOS.
|
||||
FOCUS: The core metaphor or technical concept of the article.
|
||||
|
||||
Example output: "A complex network of glowing fiber optic nodes forming a recursive pyramid structure, technical blue lineart style."`,
|
||||
},
|
||||
{ role: "user", content: content.slice(0, 5000) },
|
||||
],
|
||||
max_tokens: 100,
|
||||
});
|
||||
return (
|
||||
response.choices[0].message.content ||
|
||||
"Technical architectural blueprint of a digital system"
|
||||
);
|
||||
}
|
||||
|
||||
private async buildInternalLinkGraph(
|
||||
blogDir: string,
|
||||
currentFile: string,
|
||||
): Promise<{ title: string; slug: string }[]> {
|
||||
try {
|
||||
const files = await fs.readdir(blogDir);
|
||||
const mdxFiles = files.filter(
|
||||
(f) => f.endsWith(".mdx") && f !== currentFile,
|
||||
);
|
||||
const graph: { title: string; slug: string }[] = [];
|
||||
|
||||
for (const file of mdxFiles) {
|
||||
const fileContent = await fs.readFile(path.join(blogDir, file), "utf8");
|
||||
const titleMatch = fileContent.match(/title:\s*["']([^"']+)["']/);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
graph.push({
|
||||
title: titleMatch[1],
|
||||
slug: `/blog/${file.replace(".mdx", "")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return graph;
|
||||
} catch (e) {
|
||||
console.warn("Could not build internal link graph", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the 3-step optimization pipeline:
|
||||
* 1. Fakten recherchieren
|
||||
* 2. Social Posts recherchieren
|
||||
* 2. Bestehende Social Posts extrahieren (kein LLM — nur Regex)
|
||||
* 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...");
|
||||
// 1. Fakten & Konkurrenz recherchieren
|
||||
console.log("1️⃣ Recherchiere Fakten und analysiere Konkurrenz...");
|
||||
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);
|
||||
}
|
||||
const competitorInsights: string[] = [];
|
||||
|
||||
// 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),
|
||||
// Paralellize competitor research and fact research
|
||||
await Promise.all(
|
||||
researchTopics.map(async (topic) => {
|
||||
const [topicFacts, insights] = await Promise.all([
|
||||
this.researchAgent.researchTopic(topic),
|
||||
this.researchAgent.researchCompetitors(topic),
|
||||
]);
|
||||
facts.push(...topicFacts);
|
||||
competitorInsights.push(...insights);
|
||||
}),
|
||||
);
|
||||
|
||||
// 2. Bestehende Social Posts aus dem Content extrahieren (deterministisch, kein LLM)
|
||||
console.log("2️⃣ Extrahiere bestehende Social Media Embeds aus Content...");
|
||||
const socialPosts = this.researchAgent.extractSocialPosts(task.content);
|
||||
|
||||
// Wenn keine vorhanden sind, besorge echte von der Serper API
|
||||
if (socialPosts.length === 0) {
|
||||
console.log(
|
||||
" → Keine bestehenden Posts gefunden. Suche neue über Serper API...",
|
||||
);
|
||||
const realPosts = await this.researchAgent.fetchRealSocialPosts(
|
||||
task.content.slice(0, 500),
|
||||
);
|
||||
socialPosts.push(...realPosts);
|
||||
}
|
||||
|
||||
// 3. AI anweisen daraus Artikel zu erstellen
|
||||
console.log("3️⃣ Erstelle optimierten Artikel (Agentic Rewrite)...");
|
||||
return await this.compileArticle(task, facts, socialPosts);
|
||||
return await this.compileArticle(
|
||||
task,
|
||||
facts,
|
||||
competitorInsights,
|
||||
socialPosts,
|
||||
task.internalLinks || [],
|
||||
);
|
||||
}
|
||||
|
||||
private async identifyTopics(content: string): Promise<string[]> {
|
||||
@@ -170,22 +334,55 @@ Return ONLY the JSON.`,
|
||||
private async compileArticle(
|
||||
task: OptimizationTask,
|
||||
facts: Fact[],
|
||||
competitorInsights: string[],
|
||||
socialPosts: SocialPost[],
|
||||
internalLinks: { title: string; slug: string }[],
|
||||
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");
|
||||
let socialText = `CRITICAL RULE: NO VERIFIED SOCIAL MEDIA POSTS FOUND. You MUST NOT use <YouTubeEmbed />, <TwitterEmbed />, or <LinkedInEmbed /> under ANY circumstances in this article. DO NOT hallucinate IDs.`;
|
||||
|
||||
if (socialPosts.length > 0) {
|
||||
const allowedTags: string[] = [];
|
||||
if (socialPosts.some((p) => p.platform === "youtube"))
|
||||
allowedTags.push('<YouTubeEmbed videoId="..." />');
|
||||
if (socialPosts.some((p) => p.platform === "twitter"))
|
||||
allowedTags.push('<TwitterEmbed tweetId="..." />');
|
||||
if (socialPosts.some((p) => p.platform === "linkedin"))
|
||||
allowedTags.push('<LinkedInEmbed url="..." />');
|
||||
|
||||
socialText = `Social Media Posts to embed (use ONLY these tags, do not use others: ${allowedTags.join(", ")}):\n${socialPosts.map((p) => `Platform: ${p.platform}, ID: ${p.embedId} (${p.description})`).join("\n")}\nCRITICAL: Do not invent any IDs that are not explicitly listed in the list above.`;
|
||||
}
|
||||
|
||||
const componentsText = (task.availableComponents || [])
|
||||
.map((c) => `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`)
|
||||
.filter((c) => {
|
||||
if (
|
||||
c.name === "YouTubeEmbed" &&
|
||||
!socialPosts.some((p) => p.platform === "youtube")
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
c.name === "TwitterEmbed" &&
|
||||
!socialPosts.some((p) => p.platform === "twitter")
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
c.name === "LinkedInEmbed" &&
|
||||
!socialPosts.some((p) => p.platform === "linkedin")
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
})
|
||||
.map((c) => {
|
||||
// Ensure LinkedInEmbed usage example consistently uses 'url'
|
||||
if (c.name === "LinkedInEmbed") {
|
||||
return `<${c.name}>: ${c.description}\n Example: <LinkedInEmbed url="https://www.linkedin.com/posts/..." />`;
|
||||
}
|
||||
return `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
@@ -202,30 +399,47 @@ CONTEXT & RULES:
|
||||
Project Context / Tone:
|
||||
${task.projectContext}
|
||||
|
||||
Facts to weave in:
|
||||
${factsText || "None"}
|
||||
FACTS TO INTEGRATE:
|
||||
${factsText || "No new facts needed."}
|
||||
|
||||
Social Media Posts to embed (use <YouTubeEmbed videoId="..." />, <TwitterEmbed tweetId="..." />, or <LinkedInEmbed url="..." />):
|
||||
${socialText || "None"}
|
||||
COMPETITOR BENCHMARK (TOP RANKING ARTICLES):
|
||||
Here are snippets from the top 5 ranking Google articles for this topic. Read them carefully and ensure our article covers these topics but is fundamentally BETTER, deeper, and more authoritative:
|
||||
${competitorInsights.length > 0 ? competitorInsights.join("\n") : "No competitor insights found."}
|
||||
|
||||
Available MDX Components you can use contextually:
|
||||
${componentsText || "None"}
|
||||
AVAILABLE UI COMPONENTS:
|
||||
${componentsText}
|
||||
|
||||
SOCIAL MEDIA POSTS:
|
||||
${socialText}
|
||||
|
||||
INTERNAL LINKING GRAPH:
|
||||
Hier sind unsere existierenden Blog-Posts (Titel und URL-Slug). Finde 2-3 passende Stellen im Text, um organisch mit regulärem Markdown (\`[passender Text]([slug])\`) auf diese Posts zu verlinken. Nutze KEIN <ExternalLink> für B2B-interne Links.
|
||||
${internalLinks.length > 0 ? internalLinks.map((l) => `- "${l.title}" -> ${l.slug}`).join("\n") : "Keine internen Links verfügbar."}
|
||||
|
||||
Special Instructions from User:
|
||||
${task.instructions || "None"}
|
||||
|
||||
BLOG POST BEST PRACTICES (MANDATORY):
|
||||
- DEVIL'S ADVOCATE: Füge zwingend eine kurze kritische Sektion ein (z.B. mit \`<ComparisonRow>\` oder \`<IconList>\`), in der du offen die Nachteile/Kosten/Haken deiner eigenen Lösung ansprichst ("Der Haken an der Sache..."). Das baut Vertrauen bei B2B Entscheidenden auf.
|
||||
- FAQ GENERATOR: Am absoluten Ende des Artikels erstellst du zwingend eine Markdown-Liste mit den 3 wichtigsten Fragen (FAQ) und Antworten (jeweils 2 Sätze) für Google Rich Snippets. Nutze dazu das \`<FAQSection>\` Component oder normales Markdown.
|
||||
- SUBTLE CTAs: Webe 1-2 subtile CTAs für High-End Website Entwicklung ein (Beispiel: \`<Button href="/contact" variant="outline" size="normal">Performance-Check anfragen</Button>\` oder \`<Button href="/contact">Digitale Architektur anfragen</Button>\`). Platziere diese zwingend organisch nach Abschnitten mit hohem Mehrwert.
|
||||
- Zitat-Varianten: Wenn du Organisationen oder Studien zitierst, nutze \`<ArticleQuote isCompany={true} ... />\`. Für Personen lass \`isCompany\` weg.
|
||||
- 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).
|
||||
- Fazit: Schließe JEDEN Artikel ZWINGEND mit einem starken, klaren 'Fazit' ab.
|
||||
|
||||
STRICT MDX OUTPUT RULES:
|
||||
1. ONLY use the exact components defined above.
|
||||
2. For Social Media Embeds, you MUST ONLY use the EXACT IDs provided in the list above. Do NOT invent IDs.
|
||||
3. If ANY verified social media posts are provided, you MUST integrate at least one naturally with a contextual sentence.
|
||||
4. Keep the original content blocks and headings as much as possible, just improve flow.
|
||||
5. FRONTMATTER SEO (Idea 4): Ich übergebe dir die KOMPLETTE Datei inklusive Markdown-Frontmatter (--- ... ---). Du MUSST das Frontmatter ebenfalls zurückgeben! Optimiere darin den \`title\` und die \`description\` maximal für B2B SEO. Lasse die anderen Keys im Frontmatter (date, tags) unangetastet.
|
||||
|
||||
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 ... \`\`\`).
|
||||
1. THE OUTPUT MUST START WITH YAML FRONTMATTER AND END WITH THE MDX BODY.
|
||||
2. 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.
|
||||
@@ -239,7 +453,7 @@ CRITICAL GUIDELINES (NEVER BREAK THESE):
|
||||
});
|
||||
|
||||
let rawContent = response.choices[0].message.content || task.content;
|
||||
rawContent = this.cleanResponse(rawContent);
|
||||
rawContent = this.cleanResponse(rawContent, socialPosts);
|
||||
|
||||
// Validation Layer: Check Mermaid syntax
|
||||
if (retryCount < 2 && rawContent.includes("<Mermaid>")) {
|
||||
@@ -266,7 +480,9 @@ CRITICAL GUIDELINES (NEVER BREAK THESE):
|
||||
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,
|
||||
competitorInsights,
|
||||
socialPosts,
|
||||
internalLinks,
|
||||
retryCount + 1,
|
||||
);
|
||||
}
|
||||
@@ -320,11 +536,7 @@ CRITICAL GUIDELINES (NEVER BREAK THESE):
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-processing to ensure the AI didn't include "help" text,
|
||||
* duplicate frontmatter, or markdown wrappers.
|
||||
*/
|
||||
private cleanResponse(content: string): string {
|
||||
private cleanResponse(content: string, socialPosts: SocialPost[]): string {
|
||||
let cleaned = content.trim();
|
||||
|
||||
// 1. Strip Markdown Wrappers (e.g. ```mdx ... ```)
|
||||
@@ -334,16 +546,52 @@ CRITICAL GUIDELINES (NEVER BREAK THESE):
|
||||
.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();
|
||||
}
|
||||
// 2. We NO LONGER strip redundant frontmatter, because we requested the LLM to output it.
|
||||
// Ensure the output actually has frontmatter, if not, something went wrong, but we just pass it along.
|
||||
|
||||
// 3. Strip any social embeds the AI hallucinated (IDs not in our extracted set)
|
||||
const knownYtIds = new Set(
|
||||
socialPosts.filter((p) => p.platform === "youtube").map((p) => p.embedId),
|
||||
);
|
||||
const knownTwIds = new Set(
|
||||
socialPosts.filter((p) => p.platform === "twitter").map((p) => p.embedId),
|
||||
);
|
||||
const knownLiIds = new Set(
|
||||
socialPosts
|
||||
.filter((p) => p.platform === "linkedin")
|
||||
.map((p) => p.embedId),
|
||||
);
|
||||
|
||||
cleaned = cleaned.replace(
|
||||
/<YouTubeEmbed[^>]*videoId="([^"]+)"[^>]*\/>/gi,
|
||||
(tag, id) => {
|
||||
if (knownYtIds.has(id)) return tag;
|
||||
console.log(
|
||||
`🛑 Stripped hallucinated YouTubeEmbed with videoId="${id}"`,
|
||||
);
|
||||
return "";
|
||||
},
|
||||
);
|
||||
|
||||
cleaned = cleaned.replace(
|
||||
/<TwitterEmbed[^>]*tweetId="([^"]+)"[^>]*\/>/gi,
|
||||
(tag, id) => {
|
||||
if (knownTwIds.has(id)) return tag;
|
||||
console.log(
|
||||
`🛑 Stripped hallucinated TwitterEmbed with tweetId="${id}"`,
|
||||
);
|
||||
return "";
|
||||
},
|
||||
);
|
||||
|
||||
cleaned = cleaned.replace(
|
||||
/<LinkedInEmbed[^>]*(?:url|urn)="([^"]+)"[^>]*\/>/gi,
|
||||
(tag, id) => {
|
||||
if (knownLiIds.has(id)) return tag;
|
||||
console.log(`🛑 Stripped hallucinated LinkedInEmbed with id="${id}"`);
|
||||
return "";
|
||||
},
|
||||
);
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import OpenAI from "openai";
|
||||
import { DataCommonsClient } from "./clients/data-commons";
|
||||
import { TrendsClient } from "./clients/trends";
|
||||
import { SerperClient, type SerperVideoResult } from "./clients/serper";
|
||||
|
||||
export interface Fact {
|
||||
statement: string;
|
||||
@@ -20,6 +21,7 @@ export class ResearchAgent {
|
||||
private openai: OpenAI;
|
||||
private dcClient: DataCommonsClient;
|
||||
private trendsClient: TrendsClient;
|
||||
private serperClient: SerperClient;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.openai = new OpenAI({
|
||||
@@ -31,7 +33,8 @@ export class ResearchAgent {
|
||||
},
|
||||
});
|
||||
this.dcClient = new DataCommonsClient();
|
||||
this.trendsClient = new TrendsClient();
|
||||
this.trendsClient = new TrendsClient(apiKey);
|
||||
this.serperClient = new SerperClient(process.env.SERPER_API_KEY);
|
||||
}
|
||||
|
||||
async researchTopic(topic: string): Promise<Fact[]> {
|
||||
@@ -107,120 +110,151 @@ Return JSON: { "facts": [ { "statement": "...", "source": "Organization Name Onl
|
||||
return result.facts || [];
|
||||
}
|
||||
|
||||
async findSocialPosts(
|
||||
/**
|
||||
* Extracts existing social media embeds from MDX content via regex.
|
||||
* No LLM involved — purely deterministic parsing.
|
||||
* Only returns posts that are already present in the article.
|
||||
*/
|
||||
extractSocialPosts(content: string): SocialPost[] {
|
||||
const posts: SocialPost[] = [];
|
||||
|
||||
// YouTube: <YouTubeEmbed videoId="..." />
|
||||
const ytMatches = [
|
||||
...content.matchAll(/<YouTubeEmbed[^>]*videoId="([^"]+)"[^>]*\/>/gi),
|
||||
];
|
||||
for (const match of ytMatches) {
|
||||
if (!posts.some((p) => p.embedId === match[1])) {
|
||||
posts.push({
|
||||
platform: "youtube",
|
||||
embedId: match[1],
|
||||
description: "Existing YouTube embed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Twitter/X: <TwitterEmbed tweetId="..." />
|
||||
const twMatches = [
|
||||
...content.matchAll(/<TwitterEmbed[^>]*tweetId="([^"]+)"[^>]*\/>/gi),
|
||||
];
|
||||
for (const match of twMatches) {
|
||||
if (!posts.some((p) => p.embedId === match[1])) {
|
||||
posts.push({
|
||||
platform: "twitter",
|
||||
embedId: match[1],
|
||||
description: "Existing Twitter/X embed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// LinkedIn: <LinkedInEmbed url="..." /> or <LinkedInEmbed urn="..." />
|
||||
const liMatches = [
|
||||
...content.matchAll(/<LinkedInEmbed[^>]*(?:url|urn)="([^"]+)"[^>]*\/>/gi),
|
||||
];
|
||||
for (const match of liMatches) {
|
||||
if (!posts.some((p) => p.embedId === match[1])) {
|
||||
posts.push({
|
||||
platform: "linkedin",
|
||||
embedId: match[1],
|
||||
description: "Existing LinkedIn embed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (posts.length > 0) {
|
||||
console.log(
|
||||
`📱 Extracted ${posts.length} existing social media embed(s) from content`,
|
||||
);
|
||||
} else {
|
||||
console.log(`📱 No existing social media embeds found in content`);
|
||||
}
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches real, verified social media posts using the Serper API (Google Video Search).
|
||||
* This completely prevents hallucinations as it relies on actual search results.
|
||||
*/
|
||||
async fetchRealSocialPosts(
|
||||
topic: string,
|
||||
retries = 2,
|
||||
previousFailures: string[] = [],
|
||||
retries = 1,
|
||||
): Promise<SocialPost[]> {
|
||||
console.log(
|
||||
`📱 Searching for relevant Social Media Posts: "${topic}"${retries < 2 ? ` (Retry ${2 - retries}/2)` : ""}`,
|
||||
`🌐 [Serper] Fetching real social media posts for topic: "${topic}"...`,
|
||||
);
|
||||
|
||||
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",
|
||||
// Step 1: Ask the LLM to generate a highly specific YouTube search query
|
||||
// We want tutorials, explanations, or deep dives.
|
||||
const queryGen = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
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.`,
|
||||
content: `Generate a YouTube search query to find a high-quality, professional educational video about: "${topic}".
|
||||
Prefer official tech channels or well-known developers (e.g., Google Chrome Developers, Vercel, Theo - t3.gg, Fireship, etc.).
|
||||
Return a JSON object with a single string field "query". Example: {"query": "core web vitals explanation google developers"}.
|
||||
DO NOT USE QUOTES IN THE QUERY ITSELF.`,
|
||||
},
|
||||
],
|
||||
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}"`);
|
||||
try {
|
||||
let queryStr = "";
|
||||
const parsed = JSON.parse(
|
||||
queryGen.choices[0].message.content || '{"query": ""}',
|
||||
);
|
||||
queryStr = parsed.query || `${topic} tutorial explanation`;
|
||||
|
||||
// Step 2: Search via Serper Video Search
|
||||
const videos = await this.serperClient.searchVideos(queryStr);
|
||||
|
||||
if (!videos || videos.length === 0) {
|
||||
console.warn(`⚠️ [Serper] No videos found for query: "${queryStr}"`);
|
||||
if (retries > 0) return this.fetchRealSocialPosts(topic, retries - 1);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter for youtube results
|
||||
const ytVideos = videos.filter(
|
||||
(v) => v.link && v.link.includes("youtube.com/watch"),
|
||||
);
|
||||
|
||||
if (ytVideos.length === 0) {
|
||||
console.warn(`⚠️ [Serper] No YouTube videos in search results.`);
|
||||
if (retries > 0) return this.fetchRealSocialPosts(topic, retries - 1);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Pick the best one (usually the first result)
|
||||
const bestVideo = ytVideos[0];
|
||||
|
||||
// Extract the 11-char video ID from the link (e.g., https://www.youtube.com/watch?v=dQw4w9WgXcQ)
|
||||
const urlObj = new URL(bestVideo.link);
|
||||
const videoId = urlObj.searchParams.get("v");
|
||||
|
||||
if (!videoId) {
|
||||
console.warn(
|
||||
`⚠️ [Serper] Could not extract video ID from: ${bestVideo.link}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ [Serper] Found valid YouTube Video: ${videoId} ("${bestVideo.title}")`,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
platform: "youtube",
|
||||
embedId: videoId,
|
||||
description: bestVideo.title || "YouTube Video",
|
||||
},
|
||||
];
|
||||
} catch (e) {
|
||||
console.error("❌ Failed to fetch real social posts:", e);
|
||||
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(
|
||||
@@ -273,4 +307,60 @@ CRITICAL: Do NOT provide more than 2 trendsKeywords. Keep it extremely focused.`
|
||||
return { trendsKeywords: [], dcVariables: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Researches the top-ranking competitors on Google for a given topic.
|
||||
* Extracts their titles and snippets to guide the LLM to write better content.
|
||||
*/
|
||||
async researchCompetitors(topic: string, retries = 1): Promise<string[]> {
|
||||
console.log(
|
||||
`🔍 [Competitor Research] Fetching top ranking web pages for topic: "${topic.slice(0, 50)}..."`,
|
||||
);
|
||||
|
||||
// Step 1: LLM generates the optimal Google Search query
|
||||
const queryGen = await this.openai.chat.completions.create({
|
||||
model: "google/gemini-2.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `Generate a Google Search query that a B2B decision maker would use to research the following topic: "${topic}".
|
||||
Focus on intent-driven keywords.
|
||||
Return a JSON object with a single string field "query". Example: {"query": "Next.js performance optimization agency"}.
|
||||
DO NOT USE QUOTES IN THE QUERY ITSELF.`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
queryGen.choices[0].message.content || '{"query": ""}',
|
||||
);
|
||||
const queryStr = parsed.query || topic;
|
||||
|
||||
// Step 2: Search via Serper Web Search
|
||||
const organicResults = await this.serperClient.searchWeb(queryStr, 5);
|
||||
|
||||
if (!organicResults || organicResults.length === 0) {
|
||||
console.warn(
|
||||
`⚠️ [Competitor Research] No web results found for query: "${queryStr}"`,
|
||||
);
|
||||
if (retries > 0) return this.researchCompetitors(topic, retries - 1);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Map to structured insights string
|
||||
const insights = organicResults.map((result, i) => {
|
||||
return `[Rank #${i + 1}] Title: "${result.title}" | Snippet: "${result.snippet}"`;
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ [Competitor Research] Analyzed top ${insights.length} competitor articles.`,
|
||||
);
|
||||
return insights;
|
||||
} catch (e) {
|
||||
console.error("❌ Failed to fetch competitor research:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
128
packages/journaling/src/clients/serper.ts
Normal file
128
packages/journaling/src/clients/serper.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export interface SerperVideoResult {
|
||||
title: string;
|
||||
link: string;
|
||||
snippet?: string;
|
||||
date?: string;
|
||||
duration?: string;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export interface SerperVideoResponse {
|
||||
searchParameters: any;
|
||||
videos: SerperVideoResult[];
|
||||
}
|
||||
|
||||
export interface SerperWebResult {
|
||||
title: string;
|
||||
link: string;
|
||||
snippet: string;
|
||||
date?: string;
|
||||
sitelinks?: any[];
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface SerperWebResponse {
|
||||
searchParameters: any;
|
||||
organic: SerperWebResult[];
|
||||
}
|
||||
|
||||
export class SerperClient {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
const key = apiKey || process.env.SERPER_API_KEY;
|
||||
if (!key) {
|
||||
console.warn("⚠️ SERPER_API_KEY is not defined. SerperClient will fail.");
|
||||
}
|
||||
this.apiKey = key || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a video search via Serper (Google Video Search).
|
||||
* Great for finding relevant YouTube videos.
|
||||
*/
|
||||
async searchVideos(
|
||||
query: string,
|
||||
num: number = 5,
|
||||
): Promise<SerperVideoResult[]> {
|
||||
if (!this.apiKey) {
|
||||
console.error("❌ SERPER_API_KEY missing - cannot execute search.");
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🔍 [Serper] Searching videos for: "${query}"`);
|
||||
const response = await fetch("https://google.serper.dev/videos", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-KEY": this.apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
q: query,
|
||||
num: num,
|
||||
gl: "de", // Germany for localized results
|
||||
hl: "de", // German language
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`❌ [Serper] API Error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
const text = await response.text();
|
||||
console.error(text);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SerperVideoResponse;
|
||||
return data.videos || [];
|
||||
} catch (e) {
|
||||
console.error("❌ [Serper] Request failed", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a standard web search via Serper.
|
||||
* Crucial for B2B competitor analysis and context gathering.
|
||||
*/
|
||||
async searchWeb(query: string, num: number = 5): Promise<SerperWebResult[]> {
|
||||
if (!this.apiKey) {
|
||||
console.error("❌ SERPER_API_KEY missing - cannot execute web search.");
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🔍 [Serper] Web Search for Competitor Insights: "${query}"`);
|
||||
const response = await fetch("https://google.serper.dev/search", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-KEY": this.apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
q: query,
|
||||
num: num,
|
||||
gl: "de", // Germany for localized results
|
||||
hl: "de", // German language
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`❌ [Serper] API Error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
const text = await response.text();
|
||||
console.error(text);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SerperWebResponse;
|
||||
return data.organic || [];
|
||||
} catch (e) {
|
||||
console.error("❌ [Serper] Web Request failed", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./clients/data-commons";
|
||||
export * from "./clients/trends";
|
||||
export * from "./clients/serper";
|
||||
export * from "./agent";
|
||||
|
||||
30
packages/thumbnail-generator/package.json
Normal file
30
packages/thumbnail-generator/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@mintel/thumbnail-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": {
|
||||
"replicate": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
84
packages/thumbnail-generator/src/generator.ts
Normal file
84
packages/thumbnail-generator/src/generator.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import Replicate from "replicate";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface ThumbnailGeneratorConfig {
|
||||
replicateApiKey: string;
|
||||
}
|
||||
|
||||
export class ThumbnailGenerator {
|
||||
private replicate: Replicate;
|
||||
|
||||
constructor(config: ThumbnailGeneratorConfig) {
|
||||
this.replicate = new Replicate({
|
||||
auth: config.replicateApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
public async generateImage(
|
||||
topic: string,
|
||||
outputPath: string,
|
||||
): Promise<string> {
|
||||
const systemPrompt = `Technical blueprint / architectural illustration — clean lines, monochrome base with one highlighter accent color (yellow, pink, or green). Abstract, geometric, or diagrammatic illustrations only. 'Engineering notebook sketch' — precise, minimal, professional. No text in images. No people or realistic photos.`;
|
||||
|
||||
const prompt = `${systemPrompt}\n\nTopic to illustrate abstractly: ${topic}`;
|
||||
|
||||
console.log(`🎨 Generating thumbnail for topic: "${topic}"...`);
|
||||
|
||||
const output = await this.replicate.run("black-forest-labs/flux-1.1-pro", {
|
||||
input: {
|
||||
prompt,
|
||||
aspect_ratio: "16:9",
|
||||
output_format: "png",
|
||||
output_quality: 90,
|
||||
prompt_upsampling: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Replicate returns a ReadableStream for the output of flux-1.1-pro in newer Node SDKs
|
||||
// Or a string URL in older ones. We handle both.
|
||||
let buffer: Buffer;
|
||||
|
||||
if (output instanceof ReadableStream) {
|
||||
console.log(`⬇️ Downloading generated stream from Replicate...`);
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = output.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(value);
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else if (
|
||||
typeof output === "string" ||
|
||||
(Array.isArray(output) && typeof output[0] === "string")
|
||||
) {
|
||||
const imageUrl = Array.isArray(output) ? output[0] : output;
|
||||
console.log(
|
||||
`⬇️ Downloading generated image from URL: ${imageUrl.substring(0, 50)}...`,
|
||||
);
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.statusText}`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
buffer = Buffer.from(arrayBuffer);
|
||||
} else if (Buffer.isBuffer(output)) {
|
||||
buffer = output;
|
||||
} else if (typeof output === "object") {
|
||||
console.log("Raw output object:", output);
|
||||
throw new Error("Unexpected output format from Replicate.");
|
||||
} else {
|
||||
throw new Error("Unknown output format from Replicate.");
|
||||
}
|
||||
|
||||
const absPath = path.isAbsolute(outputPath)
|
||||
? outputPath
|
||||
: path.resolve(process.cwd(), outputPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, buffer);
|
||||
|
||||
console.log(`✅ Saved thumbnail to: ${absPath}`);
|
||||
return absPath;
|
||||
}
|
||||
}
|
||||
1
packages/thumbnail-generator/src/index.ts
Normal file
1
packages/thumbnail-generator/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./generator";
|
||||
8
packages/thumbnail-generator/tsconfig.json
Normal file
8
packages/thumbnail-generator/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
173
pnpm-lock.yaml
generated
173
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.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)
|
||||
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)
|
||||
'@mintel/mail':
|
||||
specifier: workspace:*
|
||||
version: link:../mail
|
||||
@@ -270,6 +270,9 @@ importers:
|
||||
'@mintel/meme-generator':
|
||||
specifier: workspace:*
|
||||
version: link:../meme-generator
|
||||
'@mintel/thumbnail-generator':
|
||||
specifier: workspace:*
|
||||
version: link:../thumbnail-generator
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.3.1
|
||||
@@ -502,7 +505,7 @@ 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(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)
|
||||
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)
|
||||
|
||||
packages/meme-generator:
|
||||
dependencies:
|
||||
@@ -683,7 +686,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(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)
|
||||
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)
|
||||
|
||||
packages/pdf-library:
|
||||
dependencies:
|
||||
@@ -732,6 +735,28 @@ importers:
|
||||
specifier: ^3.4.0
|
||||
version: 3.5.28(typescript@5.9.3)
|
||||
|
||||
packages/thumbnail-generator:
|
||||
dependencies:
|
||||
replicate:
|
||||
specifier: ^1.0.1
|
||||
version: 1.4.0
|
||||
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/tsconfig: {}
|
||||
|
||||
packages/unified-dashboard:
|
||||
@@ -6903,6 +6928,10 @@ packages:
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
process@0.11.10:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
progress@2.0.3:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -6991,6 +7020,10 @@ packages:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readable-stream@4.7.0:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
@@ -7019,6 +7052,10 @@ packages:
|
||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
replicate@1.4.0:
|
||||
resolution: {integrity: sha512-1ufKejfUVz/azy+5TnzQP7U1+MHVWZ6psnQ06az8byUUnRhT+DZ/MvewzB1NQYBVMgNKR7xPDtTwlcP5nv/5+w==}
|
||||
engines: {git: '>=2.11.0', node: '>=18.0.0', npm: '>=7.19.0', yarn: '>=1.7.0'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -8963,6 +9000,57 @@ 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))
|
||||
@@ -9014,6 +9102,32 @@ 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
|
||||
@@ -9057,6 +9171,17 @@ 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))
|
||||
@@ -11157,6 +11282,14 @@ 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
|
||||
@@ -14631,6 +14764,16 @@ 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
|
||||
@@ -14962,6 +15105,9 @@ snapshots:
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
process@0.11.10:
|
||||
optional: true
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
@@ -15057,6 +15203,15 @@ snapshots:
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@4.7.0:
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
buffer: 6.0.3
|
||||
events: 3.3.0
|
||||
process: 0.11.10
|
||||
string_decoder: 1.3.0
|
||||
optional: true
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
@@ -15094,6 +15249,10 @@ snapshots:
|
||||
gopd: 1.2.0
|
||||
set-function-name: 2.0.2
|
||||
|
||||
replicate@1.4.0:
|
||||
optionalDependencies:
|
||||
readable-stream: 4.7.0
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
@@ -16113,7 +16272,7 @@ snapshots:
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
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):
|
||||
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):
|
||||
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))
|
||||
@@ -16151,7 +16310,7 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
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):
|
||||
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):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
@@ -16234,6 +16393,10 @@ 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)
|
||||
|
||||
Reference in New Issue
Block a user