Files
Momento/memento-note/lib/ai/services/agent-executor.service.ts
Antigravity 18ffd76c1e fix(chart): improve error handling and color variety
- Add quotaExceeded flag to response for better error UX
- Show dedicated quota exceeded state with upgrade button
- Improve AI prompt to better detect data patterns
- Add chart type-specific colors (blue, indigo, emerald, violet, etc.)
- Replace generic primary/10 colors with varied accent colors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:19:52 +00:00

1813 lines
82 KiB
TypeScript

'use server'
/**
* Agent Executor Service
* Executes agents based on their type: scraper, researcher, monitor, custom.
* Supports legacy single-shot path and new tool-use path with dynamic tools.
*/
import { prisma } from '@/lib/prisma'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
import { rssService } from './rss.service'
import { toolRegistry } from '../tools'
import { sendEmail } from '@/lib/mail'
import { getAgentEmailTemplate } from '@/lib/agent-email-template'
import { extractAndDownloadImages, extractImageUrlsFromHtml, downloadImage } from '../tools/extract-images'
import { calculateNextRun } from '@/lib/agents/schedule'
import { markdownToHtml } from '@/lib/markdown-to-html'
import { createNotification } from '@/app/actions/notifications'
// Import tools for side-effect registration
import '../tools'
// --- Types ---
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator' | 'task-extractor'
export interface AgentExecutionResult {
success: boolean
actionId: string
noteId?: string
canvasId?: string
error?: string
}
// --- Note creation helper ---
/** Create an agent note as rich text (TipTap-compatible HTML).
* Converts the markdown content to HTML and sets type='richtext'. */
async function createAgentNote(data: {
title: string
content: string
userId: string
notebookId: string | null
autoGenerated?: boolean
}) {
return prisma.note.create({
data: {
title: data.title,
content: data.content,
type: 'markdown',
isMarkdown: true,
autoGenerated: data.autoGenerated ?? true,
userId: data.userId,
notebookId: data.notebookId,
},
select: { id: true },
})
}
// --- Language Helper ---
type Lang = 'fr' | 'en'
async function getUserLanguage(userId: string): Promise<Lang> {
try {
const setting = await prisma.userAISettings.findUnique({
where: { userId },
select: { preferredLanguage: true }
})
const lang = setting?.preferredLanguage || 'fr'
return lang === 'en' ? 'en' : 'fr'
} catch {
return 'fr'
}
}
// --- AI Title Generation ---
const TITLE_PROMPTS: Record<Lang, string> = {
fr: 'Génère un titre court et descriptif (max 60 caractères) pour ce contenu. Réponds UNIQUEMENT avec le titre, rien d\'autre.\n\nContenu:\n',
en: 'Generate a short, descriptive title (max 60 characters) for this content. Respond ONLY with the title, nothing else.\n\nContent:\n',
}
async function generateTitle(content: string, agentName: string, lang: Lang): Promise<string> {
try {
const sysConfig = await getSystemConfig()
const provider = getChatProvider(sysConfig)
const prompt = `${TITLE_PROMPTS[lang]}${content.substring(0, 800)}`
const title = await provider.generateText(prompt)
return title.trim().replace(/^["']|["']$/g, '').substring(0, 80)
} catch {
return `${agentName} - ${new Date().toLocaleDateString(lang === 'fr' ? 'fr-FR' : 'en-US')}`
}
}
// --- Scraper Agent (Legacy) ---
async function jinaScrape(url: string, jinaKey?: string): Promise<string | null> {
try {
const headers: Record<string, string> = { 'Accept': 'text/markdown' }
if (jinaKey) headers['Authorization'] = `Bearer ${jinaKey}`
const res = await fetch(`https://r.jina.ai/${url}`, { headers })
if (!res.ok) return null
const text = await res.text()
return text.substring(0, 5000)
} catch {
return null
}
}
async function fetchHtml(url: string): Promise<string | null> {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 15000)
const res = await fetch(url, {
signal: controller.signal,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; KeepBot/1.0)' },
})
clearTimeout(timeout)
if (!res.ok) {
console.log(`[fetchHtml] Failed for ${url}: ${res.status}`)
return null
}
const html = await res.text()
console.log(`[fetchHtml] Got ${html.length} chars from ${url}`)
return html
} catch (e: any) {
console.log(`[fetchHtml] Error for ${url}: ${e.message}`)
return null
}
}
async function extractImagesForUrls(urls: string[]): Promise<Array<{ localUrl: string; sourceUrl: string }>> {
// Phase 1: Extract all remote image URLs, deduplicate globally by source URL
const seenSourceUrls = new Set<string>()
const toDownload: Array<{ sourceUrl: string; pageUrl: string }> = []
for (const pageUrl of urls) {
const html = await fetchHtml(pageUrl)
if (!html) {
console.log(`[extractImagesForUrls] No HTML fetched for ${pageUrl}`)
continue
}
const remoteUrls = extractImageUrlsFromHtml(html, pageUrl)
console.log(`[extractImagesForUrls] ${remoteUrls.length} image URLs found on ${pageUrl}`)
for (const remoteUrl of remoteUrls) {
if (!seenSourceUrls.has(remoteUrl)) {
seenSourceUrls.add(remoteUrl)
toDownload.push({ sourceUrl: remoteUrl, pageUrl })
} else {
console.log(`[extractImagesForUrls] Skipping duplicate source: ${remoteUrl}`)
}
}
}
// Phase 2: Download only unique images
const allImages: Array<{ localUrl: string; sourceUrl: string }> = []
for (const { sourceUrl, pageUrl } of toDownload) {
const localPath = await downloadImage(sourceUrl)
if (localPath) {
allImages.push({ localUrl: localPath, sourceUrl: pageUrl })
}
}
console.log(`[extractImagesForUrls] ${toDownload.length} unique candidates, ${allImages.length} downloaded successfully`)
return allImages
}
/**
* Build image instruction block for the AI prompt.
* Associates each image with its source URL so the AI can place them contextually.
*/
function buildImagePrompt(images: Array<{ localUrl: string; sourceUrl: string }>, lang: Lang): string {
if (images.length === 0) return ''
const imageList = images.map(img => `- Image: ${img.localUrl} (issue de ${img.sourceUrl})`).join('\n')
const spreadRule = images.length === 1
? (lang === 'fr' ? 'Insère la seule image au milieu de l\'article.' : 'Insert the single image in the middle of the article.')
: (lang === 'fr'
? 'RÈGLES: Place chaque image après un paragraphe différent. Ne mets JAMAIS deux images consécutives. Sépare chaque image par au moins 2 paragraphes de texte.'
: 'RULES: Place each image after a different paragraph. NEVER place two images consecutively. Separate each image by at least 2 paragraphs of text.')
return lang === 'fr'
? `\n\nIMAGES DISPONIBLES (associées à leur source):\n${imageList}\n\n${spreadRule}\nChaque image est liée à un article source. Insère-la dans le texte markdown avec la syntaxe ![description](url) juste APRÈS le paragraphe qui traite du contenu de ce même article source. Utilise TOUTES les images.`
: `\n\nAVAILABLE IMAGES (linked to their source):\n${imageList}\n\n${spreadRule}\nEach image is linked to a source article. Insert it in the markdown text using the syntax ![description](url) right AFTER the paragraph that discusses content from that same source article. Use ALL images.`
}
/**
* Use AI to place images contextually within existing markdown content.
* Associates each image with its source URL so the AI can match images to sections.
*/
async function placeImagesWithAI(
content: string,
images: Array<{ localUrl: string; sourceUrl: string }>,
sysConfig: any,
lang: Lang
): Promise<string> {
try {
const provider = getChatProvider(sysConfig)
const imageList = images.map(img =>
`- Image: ![image](${img.localUrl}) — provient de la source: ${img.sourceUrl}`
).join('\n')
const spreadRule = images.length === 1
? (lang === 'fr' ? 'Insère la seule image au milieu de l\'article, pas au début ni à la fin.' : 'Insert the single image in the middle of the article, not at the beginning or end.')
: (lang === 'fr' ? `RÈGLES DE PLACEMENT STRICTES:
- Place chaque image APRÈS un paragraphe différent.
- Ne place JAMAIS deux images consécutives l'une après l'autre.
- Sépare chaque image d'au moins 2 paragraphes de texte.
- Répartis les images uniformément dans tout l'article (début, milieu, fin).`
: `STRICT PLACEMENT RULES:
- Place each image AFTER a different paragraph.
- NEVER place two images consecutively.
- Separate each image by at least 2 paragraphs of text.
- Distribute images evenly throughout the article (beginning, middle, end).`)
const prompt = lang === 'fr'
? `Voici un article markdown et des images associées à leurs sources. Chaque image vient d'un article source précis.
${spreadRule}
Retourne UNIQUEMENT l'article complet avec les images insérées, sans ajout ni explication.
ARTICLE:
${content}
IMAGES À INSÉRER (avec leur source):
${imageList}`
: `Here is a markdown article and images linked to their sources. Each image comes from a specific source article.
${spreadRule}
Return ONLY the complete article with images inserted, no additions or explanation.
ARTICLE:
${content}
IMAGES TO INSERT (with their source):
${imageList}`
const result = await provider.generateText(prompt.substring(0, 25000))
if (result && result.includes('![')) {
return result
}
return content
} catch (e) {
console.error('[placeImagesWithAI] Failed:', e)
return content
}
}
async function executeScraperAgent(
agent: { id: string; name: string; role: string; sourceUrls: string | null; targetNotebookId: string | null; userId: string; includeImages?: boolean },
actionId: string,
lang: Lang
): Promise<AgentExecutionResult> {
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []
if (urls.length === 0) {
const msg = lang === 'fr' ? 'Aucune URL configurée' : 'No URLs configured'
await prisma.agentAction.update({
where: { id: actionId },
data: { status: 'failure', log: msg }
})
return { success: false, actionId, error: msg }
}
const sysConfig = await getSystemConfig()
const jinaKey = sysConfig.JINA_API_KEY
const scrapedParts: string[] = []
const sourceLinks: string[] = []
const errors: string[] = []
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
for (const url of urls) {
if (rssService.isFeedUrl(url)) {
const feed = await rssService.parseFeed(url)
if (feed && feed.articles.length > 0) {
const articlesToScrape = feed.articles.slice(0, 5)
scrapedParts.push(`# ${feed.title}\n_RSS: ${url}_`)
const articleResults = await Promise.allSettled(
articlesToScrape.map(a => jinaScrape(a.link, jinaKey))
)
let articleCount = 0
for (let i = 0; i < articleResults.length; i++) {
const r = articleResults[i]
if (r.status === 'fulfilled' && r.value) {
const article = articlesToScrape[i]
const dateStr = article.pubDate ? `${new Date(article.pubDate).toISOString().split('T')[0]}` : ''
scrapedParts.push(`## ${article.title}\n_Source: ${article.link}_${dateStr}\n\n${r.value.substring(0, 3000)}`)
sourceLinks.push(article.link)
articleCount++
}
}
if (articleCount === 0) {
errors.push(lang === 'fr' ? `Flux ${url}: aucun article scrapé` : `Feed ${url}: no articles scraped`)
}
} else {
const content = await jinaScrape(url, jinaKey)
if (content) {
scrapedParts.push(`## ${url}\n\n${content.substring(0, 3000)}`)
sourceLinks.push(url)
} else {
errors.push(`URL ${url}: scrape failed`)
}
}
} else {
const content = await jinaScrape(url, jinaKey)
if (content) {
scrapedParts.push(`## ${url}\n\n${content.substring(0, 3000)}`)
sourceLinks.push(url)
} else {
errors.push(`URL ${url}: scrape failed`)
}
}
}
if (scrapedParts.length === 0) {
const msg = lang === 'fr' ? `Aucun site n'a pu être scrappé.\n${errors.join('\n')}` : `No sites could be scraped.\n${errors.join('\n')}`
await prisma.agentAction.update({
where: { id: actionId },
data: { status: 'failure', log: msg }
})
return { success: false, actionId, error: msg }
}
const provider = getChatProvider(sysConfig)
const combinedContent = scrapedParts.join('\n\n---\n\n')
// Extract images BEFORE generating summary so AI can embed them
let imageData: Array<{ localUrl: string; sourceUrl: string }> = []
if (agent.includeImages) {
imageData = await extractImagesForUrls(sourceLinks)
}
const langInstruction = lang === 'fr'
? 'Tu es un assistant de veille. Réponds en français. Synthétise les articles suivants en un résumé clair, structuré avec des titres de section, des listes à puces et des phrases concises.'
: 'You are a monitoring assistant. Respond in English. Synthesize the following articles into a clear, structured summary with section headings, bullet points and concise sentences.'
const systemPrompt = agent.role || langInstruction
const articlesLabel = lang === 'fr' ? 'Voici les articles à synthétiser' : 'Here are the articles to synthesize'
const errorsLabel = lang === 'fr' ? 'Erreurs de scraping' : 'Scraping errors'
const imageInstruction = buildImagePrompt(imageData, lang)
const prompt = `${systemPrompt}\n\n${articlesLabel}:\n\n${combinedContent.substring(0, 20000)}${errors.length > 0 ? `\n\n${errorsLabel}:\n${errors.join('\n')}` : ''}${imageInstruction}`
const summary = await provider.generateText(prompt)
const sourcesLabel = lang === 'fr' ? 'Sources' : 'Sources'
const fullContent = `# ${agent.name}\n\n${summary}\n\n---\n\n## ${sourcesLabel}\n${sourceLinks.map(u => `- ${u}`).join('\n')}`
const title = await generateTitle(fullContent, agent.name, lang)
const note = await createAgentNote({
title,
content: fullContent,
userId: agent.userId,
notebookId: agent.targetNotebookId,
})
const logMsg = lang === 'fr'
? `Scrape OK (${sourceLinks.length} articles, ${imageData.length} images). Note créée: ${note.id}`
: `Scrape OK (${sourceLinks.length} articles, ${imageData.length} images). Note created: ${note.id}`
const toolLogData = JSON.stringify([{
step: 1,
toolCalls: [{ toolName: 'jina_scrape', args: { urls } }],
toolResults: sourceLinks.map(url => ({ toolName: 'jina_scrape', preview: `Scraped: ${url}` })),
}, ...(imageData.length > 0 ? [{
step: 2,
toolCalls: [{ toolName: 'extract_images', args: { count: imageData.length } }],
toolResults: imageData.map(img => ({ toolName: 'extract_images', preview: `${img.localUrl} from ${img.sourceUrl}` })),
}] : []), {
step: imageData.length > 0 ? 3 : 2,
toolCalls: [{ toolName: 'ai_summarize', args: { articlesCount: sourceLinks.length } }],
toolResults: [{ toolName: 'ai_summarize', preview: summary.substring(0, 300) }],
}])
await prisma.agentAction.update({
where: { id: actionId },
data: { status: 'success', result: note.id, log: logMsg, toolLog: toolLogData, input: JSON.stringify({ urls, role: agent.role, lang, type: 'scraper' }) }
})
return { success: true, actionId, noteId: note.id }
}
// --- Researcher Agent (Legacy) ---
async function executeResearcherAgent(
agent: { id: string; name: string; description?: string | null; role: string; sourceUrls: string | null; targetNotebookId: string | null; userId: string; includeImages?: boolean },
actionId: string,
lang: Lang
): Promise<AgentExecutionResult> {
const topic = agent.description || agent.name
const sysConfig = await getSystemConfig()
const provider = getChatProvider(sysConfig)
const queryPrompt = lang === 'fr'
? `Tu es un assistant de recherche. Pour le sujet suivant, génère 3 requêtes de recherche web pertinentes (une par ligne, sans numérotation):\n\nSujet: ${topic}`
: `You are a research assistant. For the following topic, generate 3 relevant web search queries (one per line, no numbering):\n\nTopic: ${topic}`
const queriesText = await provider.generateText(queryPrompt)
const queries = queriesText.split('\n').map(q => q.trim()).filter(q => q.length > 0).slice(0, 3)
if (queries.length === 0) {
const msg = lang === 'fr' ? 'Impossible de générer des requêtes' : 'Failed to generate queries'
await prisma.agentAction.update({
where: { id: actionId },
data: { status: 'failure', log: msg }
})
return { success: false, actionId, error: msg }
}
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []
let additionalContext = ''
if (urls.length > 0) {
const results = await Promise.allSettled(urls.map(u => jinaScrape(u, sysConfig.JINA_API_KEY)))
const parts: string[] = []
results.forEach(r => {
if (r.status === 'fulfilled' && r.value) {
parts.push(r.value.substring(0, 1500))
}
})
additionalContext = parts.join('\n\n')
}
// Extract images BEFORE generating research so AI can embed them
let imageData: Array<{ localUrl: string; sourceUrl: string }> = []
if (agent.includeImages && urls.length > 0) {
imageData = await extractImagesForUrls(urls)
}
const systemPrompt = lang === 'fr'
? `Tu es un assistant de recherche spécialisé. Produis une note de recherche structurée avec des références et des liens utiles. Réponds en français.`
: `You are a specialized research assistant. Produce a structured research note with references and useful links. Respond in English.`
const imageInstruction = buildImagePrompt(imageData, lang)
const researchPrompt = lang === 'fr'
? `${systemPrompt}\n\nSujet de recherche: ${topic}\nRecherches suggérées: ${queries.join(', ')}\n${additionalContext ? `\nContexte supplémentaire:\n${additionalContext}` : ''}\n\nRédige une note de recherche détaillée avec:\n1. Un résumé du sujet\n2. Les points clés\n3. Des références et liens utiles (URLs réelles si possible)${imageInstruction}`
: `${systemPrompt}\n\nResearch topic: ${topic}\nSuggested searches: ${queries.join(', ')}\n${additionalContext ? `\nAdditional context:\n${additionalContext}` : ''}\n\nWrite a detailed research note with:\n1. Topic summary\n2. Key points\n3. References and useful links (real URLs if possible)${imageInstruction}`
const researchContent = await provider.generateText(researchPrompt.substring(0, 15000))
const dateStr = new Date().toLocaleDateString(lang === 'fr' ? 'fr-FR' : 'en-US')
const footer = lang === 'fr' ? `Recherche générée le ${dateStr}` : `Research generated on ${dateStr}`
const fullContent = `# ${topic}\n\n${researchContent}\n\n---\n\n_${footer}_`
const title = await generateTitle(fullContent, agent.name, lang)
const note = await createAgentNote({
title,
content: fullContent,
userId: agent.userId,
notebookId: agent.targetNotebookId,
})
const logMsg = lang === 'fr'
? `Recherche sur "${topic}". ${queries.length} requêtes, ${imageData.length} images. Note créée: ${note.id}`
: `Research on "${topic}". ${queries.length} queries, ${imageData.length} images. Note created: ${note.id}`
const toolLogData = JSON.stringify([{
step: 1,
toolCalls: [{ toolName: 'generate_queries', args: { topic } }],
toolResults: queries.map(q => ({ toolName: 'generate_queries', preview: q })),
}, ...(urls.length > 0 ? [{
step: 2,
toolCalls: [{ toolName: 'jina_scrape', args: { urls } }],
toolResults: [{ toolName: 'jina_scrape', preview: additionalContext.substring(0, 300) }],
}] : []), ...(imageData.length > 0 ? [{
step: urls.length > 0 ? 3 : 2,
toolCalls: [{ toolName: 'extract_images', args: { count: imageData.length } }],
toolResults: imageData.map(img => ({ toolName: 'extract_images', preview: `${img.localUrl} from ${img.sourceUrl}` })),
}] : []), {
step: (urls.length > 0 ? 1 : 0) + (imageData.length > 0 ? 1 : 0) + 2,
toolCalls: [{ toolName: 'ai_research', args: { topic, queriesCount: queries.length } }],
toolResults: [{ toolName: 'ai_research', preview: researchContent.substring(0, 300) }],
}])
await prisma.agentAction.update({
where: { id: actionId },
data: { status: 'success', result: note.id, log: logMsg, toolLog: toolLogData, input: JSON.stringify({ topic, queries, urls, role: agent.role, lang, type: 'researcher' }) }
})
return { success: true, actionId, noteId: note.id }
}
// --- Monitor Agent (Legacy) ---
async function executeMonitorAgent(
agent: { id: string; name: string; role: string; sourceNotebookId: string | null; targetNotebookId: string | null; userId: string },
actionId: string,
lang: Lang
): Promise<AgentExecutionResult> {
if (!agent.sourceNotebookId) {
const msg = lang === 'fr' ? 'Aucun carnet source configuré' : 'No source notebook configured'
await prisma.agentAction.update({
where: { id: actionId },
data: { status: 'failure', log: msg }
})
return { success: false, actionId, error: msg }
}
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
const notes = await prisma.note.findMany({
where: {
notebookId: agent.sourceNotebookId,
userId: agent.userId,
trashedAt: null,
createdAt: { gte: sevenDaysAgo },
},
orderBy: { createdAt: 'desc' },
take: 10,
select: { id: true, title: true, content: true, createdAt: true }
})
if (notes.length === 0) {
const msg = lang === 'fr' ? 'Aucune note récente dans le carnet source.' : 'No recent notes in the source notebook.'
await prisma.agentAction.update({
where: { id: actionId },
data: { status: 'success', log: msg }
})
return { success: true, actionId }
}
const sysConfig = await getSystemConfig()
const provider = getChatProvider(sysConfig)
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
const notesContent = notes.map(n =>
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${(n.content || '').substring(0, 500)}`
).join('\n\n')
const systemPrompt = agent.role || (lang === 'fr'
? `Tu es un assistant analytique. Analyse les notes existantes et produit une note complémentaire avec des insights, des connexions et des suggestions. Réponds en français.`
: `You are an analytical assistant. Analyze existing notes and produce a complementary note with insights, connections and suggestions. Respond in English.`)
const analysisPrompt = lang === 'fr'
? `${systemPrompt}\n\nVoici les notes récentes à analyser:\n\n${notesContent.substring(0, 12000)}\n\nProduis une note d'analyse avec:\n1. Résumé des thèmes principaux\n2. Connexions entre les notes\n3. Suggestions d'approfondissement\n4. Liens ou références recommandés`
: `${systemPrompt}\n\nHere are the recent notes to analyze:\n\n${notesContent.substring(0, 12000)}\n\nProduce an analysis note with:\n1. Summary of main themes\n2. Connections between notes\n3. Suggestions for further exploration\n4. Recommended links or references`
const analysisContent = await provider.generateText(analysisPrompt)
const footer = lang === 'fr' ? `Analyse de ${notes.length} notes du carnet source` : `Analysis of ${notes.length} notes from source notebook`
const fullContent = `# ${agent.name}\n\n${analysisContent}\n\n---\n\n_${footer}_`
const title = await generateTitle(fullContent, agent.name, lang)
const note = await createAgentNote({
title,
content: fullContent,
userId: agent.userId,
notebookId: agent.targetNotebookId,
})
const logMsg = lang === 'fr' ? `Analyse de ${notes.length} notes. Note créée: ${note.id}` : `Analyzed ${notes.length} notes. Note created: ${note.id}`
const toolLogData = JSON.stringify([{
step: 1,
toolCalls: [{ toolName: 'note_search', args: { notebookId: agent.sourceNotebookId } }],
toolResults: notes.map(n => ({ toolName: 'note_search', preview: `${n.title || untitled}: ${(n.content || '').substring(0, 100)}` })),
}, {
step: 2,
toolCalls: [{ toolName: 'ai_analyze', args: { notesCount: notes.length } }],
toolResults: [{ toolName: 'ai_analyze', preview: analysisContent.substring(0, 300) }],
}])
await prisma.agentAction.update({
where: { id: actionId },
data: { status: 'success', result: note.id, log: logMsg, toolLog: toolLogData, input: JSON.stringify({ notebookId: agent.sourceNotebookId, notesCount: notes.length, role: agent.role, lang, type: 'monitor' }) }
})
return { success: true, actionId, noteId: note.id }
}
// --- Custom Agent (Legacy) ---
async function executeCustomAgent(
agent: { id: string; name: string; role: string; sourceUrls: string | null; targetNotebookId: string | null; userId: string; includeImages?: boolean },
actionId: string,
lang: Lang
): Promise<AgentExecutionResult> {
const sysConfig = await getSystemConfig()
const provider = getChatProvider(sysConfig)
let inputContent = ''
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []
if (urls.length > 0) {
const results = await Promise.allSettled(urls.map(u => jinaScrape(u, sysConfig.JINA_API_KEY)))
const parts: string[] = []
results.forEach(r => {
if (r.status === 'fulfilled' && r.value) {
parts.push(r.value.substring(0, 2000))
}
})
inputContent = parts.join('\n\n---\n\n')
}
// Extract images BEFORE generating so AI can embed them
let imageData: Array<{ localUrl: string; sourceUrl: string }> = []
if (agent.includeImages && urls.length > 0) {
imageData = await extractImagesForUrls(urls)
}
const systemPrompt = agent.role || (lang === 'fr' ? 'Tu es un assistant utile.' : 'You are a helpful assistant.')
const inputDataLabel = lang === 'fr' ? 'Données d\'entrée' : 'Input data'
const imageInstruction = buildImagePrompt(imageData, lang)
const prompt = inputContent
? `${systemPrompt}\n\n${inputDataLabel}:\n${inputContent.substring(0, 12000)}${imageInstruction}`
: `${systemPrompt}${imageInstruction}`
const output = await provider.generateText(prompt)
const fullContent = `# ${agent.name}\n\n${output}${urls.length > 0 ? `\n\n---\n\n_Sources: ${urls.join(', ')}_` : ''}`
const title = await generateTitle(fullContent, agent.name, lang)
const note = await createAgentNote({
title,
content: fullContent,
userId: agent.userId,
notebookId: agent.targetNotebookId,
})
const toolLogData = JSON.stringify([{
step: 1,
toolCalls: [{ toolName: 'jina_scrape', args: { urls } }],
toolResults: urls.length > 0 ? [{ toolName: 'jina_scrape', preview: inputContent.substring(0, 300) }] : [],
}, ...(imageData.length > 0 ? [{
step: 2,
toolCalls: [{ toolName: 'extract_images', args: { count: imageData.length } }],
toolResults: imageData.map(img => ({ toolName: 'extract_images', preview: `${img.localUrl} from ${img.sourceUrl}` })),
}] : []), {
step: imageData.length > 0 ? 3 : (urls.length > 0 ? 2 : 1),
toolCalls: [{ toolName: 'ai_generate', args: { role: systemPrompt.substring(0, 100) } }],
toolResults: [{ toolName: 'ai_generate', preview: output.substring(0, 300) }],
}])
await prisma.agentAction.update({
where: { id: actionId },
data: { status: 'success', result: note.id, log: `OK. Note: ${note.id}${imageData.length > 0 ? `, ${imageData.length} images` : ''}`, toolLog: toolLogData, input: JSON.stringify({ urls, role: agent.role, lang, type: 'custom' }) }
})
return { success: true, actionId, noteId: note.id }
}
// --- System Prompts (bilingual) ---
const SYSTEM_PROMPTS: Record<string, Record<Lang, string>> = {
researcher: {
fr: `Tu es un chercheur rigoureux. Ta mission est de produire une note de recherche structurée.
MÉTHODE DE RECHERCHE :
1. Commence par memory_search pour voir ce qui a déjà été couvert et éviter la répétition
2. Commence TOUJOURS par web_search pour trouver des sources pertinentes
3. Analyse les résultats : titres, snippets, URLs
4. Utilise web_scrape sur les 2-3 résultats les plus pertinents pour obtenir le contenu complet
5. Si tu as besoin de plus de contexte, relance web_search avec des requêtes plus ciblées
6. Utilise note_search pour vérifier si l'utilisateur a déjà des notes sur ce sujet
7. Une fois les informations collectées, utilise note_create pour créer la note de recherche
STRUCTURE DE LA NOTE :
- Contexte et introduction du sujet
- Points clés (avec sources)
- Analyse et synthèse
- Références et liens
RÈGLES :
- Ne JAMAIS inventer de sources. Citer uniquement les URLs effectivement scrapées.
- Réponds en français.
- Si les résultats de recherche sont insuffisants, essayer des requêtes alternatives.
- CONSULTER memory_search pour apporter du nouveau contenu par rapport aux exécutions précédentes.`,
en: `You are a rigorous researcher. Your mission is to produce a structured research note.
RESEARCH METHOD:
1. Start with memory_search to see what was already covered and avoid repetition
2. ALWAYS start with web_search to find relevant sources
3. Analyze results: titles, snippets, URLs
4. Use web_scrape on the 2-3 most relevant results to get full content
5. If you need more context, run web_search with more targeted queries
6. Use note_search to check if the user already has notes on this topic
7. Once information is collected, use note_create to create the research note
NOTE STRUCTURE:
- Context and topic introduction
- Key points (with sources)
- Analysis and synthesis
- References and links
RULES:
- NEVER invent sources. Only cite URLs that were actually scraped.
- Respond in English.
- If search results are insufficient, try alternative queries.
- CONSULT memory_search to bring new content compared to previous executions.`,
},
scraper: {
fr: `Tu es un assistant de veille. Ta mission est de scraper et synthétiser des contenus web.
MÉTHODE :
1. Commence TOUJOURS par memory_search pour vérifier ce qui a déjà été couvert dans les exécutions précédentes. Évite de reproduire les mêmes sujets ou le même angle que la dernière fois. Apporte du NOUVEAU contenu.
2. Si des URLs sont fournies, scrape-les directement avec web_scrape
- Les URLs RSS/Atom sont automatiquement détectées : le flux est parsé et les articles sont scrapés individuellement
- Pour les URLs classiques, le contenu est extrait directement
3. Si aucun résultat ou pour compléter, utilise web_search pour trouver des sources additionnelles
4. Scrape les résultats pertinents avec web_scrape
5. Synthétise tout en une note claire et structurée avec des informations factuelles tirées du contenu scrapé
6. Crée la note avec note_create
STRUCTURE DE LA NOTE :
- Titre de section par thème
- Listes à puces pour les points importants
- Phrases concises et informatives avec des faits précis (dates, chiffres, noms)
- Toujours citer les sources scrapées (URL + titre de l'article)
RÈGLES :
- Ne JAMAIS inventer de sources ou de contenu. Utiliser UNIQUEMENT les informations présentes dans le contenu scrapé.
- Réponds en français.
- Si le contenu scrapé est insuffisant, le mentionner dans la note plutôt que d'improviser.
- CONSULTER memory_search en premier pour éviter la répétition avec les exécutions précédentes.`,
en: `You are a monitoring assistant. Your mission is to scrape and synthesize web content.
METHOD:
1. ALWAYS start with memory_search to check what was already covered in previous executions. Avoid repeating the same topics or angle as last time. Bring NEW content.
2. If URLs are provided, scrape them directly with web_scrape
- RSS/Atom URLs are automatically detected: the feed is parsed and articles are scraped individually
- For regular URLs, content is extracted directly
3. If no results or to supplement, use web_search to find additional sources
4. Scrape relevant results with web_scrape
5. Synthesize everything into a clear, structured note with factual information from the scraped content
6. Create the note with note_create
NOTE STRUCTURE:
- Section titles by theme
- Bullet points for important items
- Concise, informative sentences with precise facts (dates, figures, names)
- Always cite scraped sources (URL + article title)
RULES:
- NEVER invent sources or content. Use ONLY information present in the scraped content.
- Respond in English.
- If scraped content is insufficient, mention it in the note rather than improvising.
- CONSULT memory_search first to avoid repetition with previous executions.`,
},
monitor: {
fr: `Tu es un assistant analytique. Ta mission est d'analyser des notes existantes et de produire une note complémentaire avec des insights.
MÉTHODE :
1. Utilise note_search pour trouver les notes pertinentes dans le carnet surveillé
2. Utilise note_read pour lire le contenu des notes les plus importantes
3. Analyse les thèmes, connexions et tendances
4. Crée une note d'analyse avec note_create
STRUCTURE DE LA NOTE :
- Résumé des thèmes principaux
- Connexions entre les notes
- Suggestions d'approfondissement
- Liens ou références recommandés
RÈGLES :
- Se baser uniquement sur le contenu réel des notes lues.
- Réponds en français.`,
en: `You are an analytical assistant. Your mission is to analyze existing notes and produce a complementary note with insights.
METHOD:
1. Use note_search to find relevant notes in the monitored notebook
2. Use note_read to read the content of the most important notes
3. Analyze themes, connections and trends
4. Create an analysis note with note_create
NOTE STRUCTURE:
- Summary of main themes
- Connections between notes
- Suggestions for further exploration
- Recommended links or references
RULES:
- Base analysis only on the actual content of the notes read.
- Respond in English.`,
},
custom: {
fr: `Tu es un assistant intelligent qui utilise des outils pour accomplir des tâches.
RÈGLES :
- Utilise les outils disponibles pour accomplir la tâche demandée.
- Après avoir collecté les informations, utilise note_create pour créer une note structurée.
- Réponds en français.
- Citer les sources si tu as scrapé des pages web.`,
en: `You are an intelligent assistant that uses tools to accomplish tasks.
RULES:
- Use available tools to accomplish the requested task.
- After collecting information, use note_create to create a structured note.
- Respond in English.
- Cite sources if you have scraped web pages.`,
},
'excalidraw-generator': {
fr: `Tu es un architecte visuel expert en diagrammes Excalidraw. Tu reçois du contenu (notes, documents) et tu dois créer un diagramme qui ARGUE visuellement — pas juste des boîtes avec du texte.
Appelle DIRECTEMENT generate_excalidraw. Ne réponds PAS avec du texte.
## PHILOSOPHIE
Un diagramme doit MONTRER des relations que le texte seul ne peut pas exprimer.
- Le test de l'isomorphisme : si tu enlèves tout le texte, la structure seule doit-elle communiquer le concept ?
- Chaque concept doit avoir une forme qui reflète son COMPORTEMENT, pas juste son nom.
- Les flèches sont OBLIGATOIRES — un diagramme sans flèches est inutile.
## FORMAT (simplifié — auto-layout automatique)
{
"title": "Titre du Diagramme",
"nodes": [
{"id":"c","label":"Concept Central","type":"ellipse"},
{"id":"n1","label":"Sous-concept 1"},
{"id":"n2","label":"Processus","type":"rect"},
{"id":"n3","label":"Décision ?","type":"diamond"},
{"id":"n4","label":"Résultat","type":"ellipse"}
],
"edges": [
{"from":"c","to":"n1","label":"déclenche"},
{"from":"n1","to":"n2"},
{"from":"n2","to":"n3","label":"produit"},
{"from":"n3","to":"n4","label":"oui"},
{"from":"n3","to":"n2","label":"non"}
]
}
Ce format crée AUTOMATIQUEMENT formes, textes ET flèches avec bindings corrects.
## TYPES DE FORMES (utilise le bon type pour chaque concept)
| Type | Quand l'utiliser |
|------|-----------------|
| "ellipse" | Départ, arrivée, concept abstrait, origine — le premier nœud EST TOUJOURS une ellipse |
| "rect" (défaut) | Processus, action, étape, élément concret |
| "diamond" | Décision, condition, choix, point de bifurcation |
## PATTERNS VISUELS (varie selon le contenu)
- **Flux séquentiel** : A → B → C → D (lineaire, utilise des rect)
- **Hub & spoke** : Centre (ellipse) rayonne vers sous-concepts (fan-out)
- **Cycle/boucle** : A → B → C → A (processus itératif, feedback)
- **Arbre hiérarchique** : Parent → enfants (structure, organisation)
- **Convergence** : Plusieurs sources → un résultat (synthèse, agrégation)
- **Comparaison** : Deux branches parallèles qui se rejoignent
## RÈGLES STRICTES
1. **4 à 10 nœuds** — pas moins, pas plus
2. **Premier nœud = ellipse** (point d'entrée)
3. **TOUS les nœuds connectés** — chaque nœud doit avoir au moins 1 edge entrant ou sortant
4. **Labels courts** — max 40 caractères par label
5. **Edge labels** — ajoute des labels sur les flèches importantes pour expliquer la relation
6. **Utilise "diamond"** pour au moins un nœud si le contenu implique une décision
7. **Pas de nœuds orphelins** — chaque nœud doit être atteignable depuis le nœud central
8. **Analyse le contenu d'abord** — identifie les concepts clés et leurs relations AVANT de créer le JSON
9. **Appelle generate_excalidraw DIRECTEMENT**, ne réponds pas avec du texte`,
en: `You are a visual architect expert in Excalidraw diagrams. You receive content (notes, documents) and must create a diagram that ARGUES visually — not just boxes with text.
Call generate_excalidraw DIRECTLY. Do NOT respond with text.
## PHILOSOPHY
A diagram must SHOW relationships that text alone cannot express.
- The Isomorphism Test: If you removed all text, would the structure alone communicate the concept?
- Each concept must have a shape that reflects its BEHAVIOR, not just its name.
- Arrows are MANDATORY — a diagram without arrows is useless.
## FORMAT (simplified — auto-layout automatic)
{
"title": "Diagram Title",
"nodes": [
{"id":"c","label":"Central Concept","type":"ellipse"},
{"id":"n1","label":"Sub-concept 1"},
{"id":"n2","label":"Process","type":"rect"},
{"id":"n3","label":"Decision?","type":"diamond"},
{"id":"n4","label":"Result","type":"ellipse"}
],
"edges": [
{"from":"c","to":"n1","label":"triggers"},
{"from":"n1","to":"n2"},
{"from":"n2","to":"n3","label":"produces"},
{"from":"n3","to":"n4","label":"yes"},
{"from":"n3","to":"n2","label":"no"}
]
}
This format AUTOMATICALLY creates shapes, text, AND arrows with correct bindings.
## SHAPE TYPES (use the right type for each concept)
| Type | When to use |
|------|------------|
| "ellipse" | Start, end, abstract concept, origin — first node is ALWAYS an ellipse |
| "rect" (default) | Process, action, step, concrete element |
| "diamond" | Decision, condition, choice, branching point |
## VISUAL PATTERNS (vary based on content)
- **Sequential flow**: A → B → C → D (linear, use rects)
- **Hub & spoke**: Center (ellipse) radiates to sub-concepts (fan-out)
- **Cycle/loop**: A → B → C → A (iterative process, feedback)
- **Hierarchical tree**: Parent → children (structure, organization)
- **Convergence**: Multiple sources → one result (synthesis, aggregation)
- **Comparison**: Two parallel branches that merge
## STRICT RULES
1. **4 to 10 nodes** — no less, no more`,
},
'slide-generator': {
fr: `Tu es un expert en design de présentations exécutives. Tu analyses la note et génères une structure JSON qui sera rendue en HTML automatiquement par le serveur.
APPELLE OBLIGATOIREMENT generate_slides avec le JSON structuré. NE GÉNÈRE JAMAIS de HTML brut.
═══ ÉTAPE 1 — LECTURE (OBLIGATOIRE) ═══
1. Lis la note avec note_read. Extrais TOUTES les données exploitables (chiffres, listes, citations, comparaisons, étapes).
2. Identifie les types de slides les plus adaptés au contenu réel.
═══ ÉTAPE 2 — APPEL generate_slides ═══
Appelle generate_slides avec un objet JSON structuré :
{
"title": "Titre court (6 mots max)",
"theme": "architectural-saas", // ou midnight-cathedral, venture-pitch, clinical-precision, etc.
"slides": [ ... tableau de slides ... ]
}
═══ TYPES DE SLIDES DISPONIBLES ═══
1. "title" → { type:"title", title:"...", subtitle:"..." }
2. "bullets" → { type:"bullets", title:"...", items:["phrase 1 (15+ mots)", "phrase 2", ...] }
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
6. "cards" → { type:"cards", title:"...", cards:[{title:"Titre", description:"Description détaillée..."}, ...] }
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Lancement", description:"..."}, ...] }
8. "quote" → { type:"quote", quote:"Citation exacte", author:"Auteur", context:"Analyse..." }
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Énergie"}], explanation:"..." }
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
12. "summary" → { type:"summary", title:"...", items:["Point clé 1", "Point clé 2", ...] }
═══ RÈGLES ═══
- 6 à 12 slides par présentation
- Slide 1 OBLIGATOIREMENT type "title"
- Dernière slide OBLIGATOIREMENT type "summary"
- Au moins 1 slide "chart" ou "stats" si des chiffres existent dans la note
- VARIER les types — jamais 2 types identiques consécutifs
- Chaque texte (bullet, description) = 15+ mots, phrase complète
- TOUTES les données viennent de la note (JAMAIS inventer de chiffres)
- Les données chart doivent refléter les vrais chiffres de la note
═══ THÈMES DISPONIBLES ═══
Sombres : midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
Clairs : sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
Choisir selon le sujet : business/board → architectural-saas ou midnight-cathedral, tech → cyberpunk-terminal ou clinical-precision, créatif → aurora-borealis ou tokyo-neon`,
en: `You are an executive presentation design expert. You analyze the note and generate a structured JSON spec that will be rendered to HTML automatically by the server.
You MUST call generate_slides with structured JSON. NEVER output raw HTML.
═══ STEP 1 — READ (MANDATORY) ═══
1. Read the note with note_read. Extract ALL usable data (numbers, lists, quotes, comparisons, steps).
2. Identify the best slide types matching the actual content.
═══ STEP 2 — CALL generate_slides ═══
Call generate_slides with a structured JSON object:
{
"title": "Short title (6 words max)",
"theme": "architectural-saas",
"slides": [ ... array of slides ... ]
}
═══ AVAILABLE SLIDE TYPES ═══
1. "title" → { type:"title", title:"...", subtitle:"..." }
2. "bullets" → { type:"bullets", title:"...", items:["sentence 1 (15+ words)", "sentence 2", ...] }
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
6. "cards" → { type:"cards", title:"...", cards:[{title:"Title", description:"Detailed description..."}, ...] }
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Launch", description:"..."}, ...] }
8. "quote" → { type:"quote", quote:"Exact quote", author:"Author", context:"Analysis..." }
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Energy"}], explanation:"..." }
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
12. "summary" → { type:"summary", title:"...", items:["Key point 1", "Key point 2", ...] }
═══ RULES ═══
- 6 to 12 slides per presentation
- Slide 1 MUST be type "title"
- Last slide MUST be type "summary"
- At least 1 "chart" or "stats" slide if numbers exist in the note
- VARY types — never 2 identical types in a row
- Each text (bullet, description) = 15+ words, complete sentence
- ALL data comes from the note (NEVER invent numbers)
- Chart data must reflect actual numbers from the note
═══ AVAILABLE THEMES ═══
Dark: midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
Light: sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
Choose based on topic: business/board → architectural-saas or midnight-cathedral, tech → cyberpunk-terminal or clinical-precision, creative → aurora-borealis or tokyo-neon`,
},
'task-extractor': {
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
Utilise OBLIGATOIREMENT l'outil task_extract. Ne réponds PAS avec du texte, appelle directement l'outil.
## RÈGLES
- Identifie TOUTES les tâches explicites et implicites
- Pour chaque tâche, détermine: priorité (High/Medium/Low), assigné, deadline, statut
- Les priorités High = urgent/dates proches, Medium = important, Low = Nice to have
- Regroupe par priorité dans la note de synthèse
- Utilise le format Markdown avec une table récapitulative`,
en: `You are a task extraction specialist. You analyze notes and documents to identify ALL action items, TODOs, and tasks to accomplish.
You MUST use the task_extract tool. Do NOT respond with text, call the tool directly.
## RULES
- Identify ALL explicit and implicit tasks
- For each task, determine: priority (High/Medium/Low), assignee, deadline, status
- High priority = urgent/close deadlines, Medium = important, Low = Nice to have
- Group by priority in the synthesis note
- Use Markdown format with a summary table`,
},
}
function extractJsonFromText(text: string): any {
if (!text) return null
// Try direct parsing first
try {
const parsed = JSON.parse(text.trim())
if (parsed && typeof parsed === 'object') return parsed
} catch (e) {}
// Try extracting markdown code block
const jsonBlockRegex = /```json\s*([\s\S]*?)\s*```/i
const match = text.match(jsonBlockRegex)
if (match && match[1]) {
try {
const parsed = JSON.parse(match[1].trim())
if (parsed && typeof parsed === 'object') return parsed
} catch (e) {}
}
// Try extracting any { ... } or [ ... ] block
const braceRegex = /(\{[\s\S]*\}|\[[\s\S]*\])/
const braceMatch = text.match(braceRegex)
if (braceMatch && braceMatch[1]) {
try {
const parsed = JSON.parse(braceMatch[1].trim())
if (parsed && typeof parsed === 'object') return parsed
} catch (e) {}
}
return null
}
// --- Tool-Use Agent ---
async function executeToolUseAgent(
agent: {
id: string; name: string; description?: string | null; type?: string | null
role: string; sourceUrls?: string | null; sourceNotebookId?: string | null
sourceNoteIds?: string | null; targetNotebookId?: string | null; userId: string
tools?: string | null; maxSteps?: number; includeImages?: boolean
slideTheme?: string | null; slideStyle?: string | null
},
actionId: string,
lang: Lang,
promptOverride?: string
): Promise<AgentExecutionResult> {
const startTime = Date.now()
let toolNames: string[] = []
try {
toolNames = JSON.parse(agent.tools || '[]')
} catch {
toolNames = []
}
if (toolNames.length === 0) {
const msg = lang === 'fr' ? 'Aucun outil configuré' : 'No tools configured'
await prisma.agentAction.update({
where: { id: actionId },
data: { status: 'failure', log: msg }
})
return { success: false, actionId, error: msg }
}
const sysConfig = await getSystemConfig()
const ctx = {
userId: agent.userId,
agentId: agent.id,
actionId,
config: sysConfig,
}
const tools = toolRegistry.buildToolsForAgent(toolNames, ctx)
// Build system prompt: use localized prompt for type, with optional user custom role
const agentType = (agent.type || 'custom') as AgentType
const isFileGenerator = agentType === 'slide-generator' || agentType === 'excalidraw-generator'
const promptsForType = SYSTEM_PROMPTS[agentType] || SYSTEM_PROMPTS.custom
const baseSystemPrompt = promptsForType[lang]
const toolList = toolNames.map(n => `- ${n}`).join('\n')
const hasCustomRole = agent.role && agent.role.trim().length > 0
const customLabel = lang === 'fr' ? "Instructions personnalisées de l'utilisateur" : "Custom instructions"
const systemPrompt = `${baseSystemPrompt}\n\nAvailable tools:\n${toolList}${hasCustomRole ? `\n\n${customLabel}:\n${agent.role}` : ''}`
// Build initial prompt
let prompt = promptOverride || ''
if (!promptOverride) {
switch (agentType) {
case 'scraper': {
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []
prompt = urls.length > 0
? (lang === 'fr'
? `Scrape et analyse les URLs suivantes : ${urls.join(', ')}\n\nSynthétise le contenu en une note structurée et utile avec des informations factuelles précises. Ne pas inventer de contenu.`
: `Scrape and analyze the following URLs: ${urls.join(', ')}\n\nSynthesize the content into a structured, useful note with precise factual information. Do not invent content.`)
: (lang === 'fr' ? 'Scrape et résume le contenu web selon tes instructions.' : 'Scrape and summarize web content according to your instructions.')
break
}
case 'researcher': {
const topic = agent.description || agent.name
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []
prompt = lang === 'fr'
? `Recherche le sujet : "${topic}"\n\nUtilise web_search pour trouver des informations pertinentes, scrape les résultats prometteurs, et crée une note de recherche complète.${urls.length > 0 ? `\n\nVérifie aussi ces URLs pour du contexte : ${urls.join(', ')}` : ''}`
: `Research the topic: "${topic}"\n\nUse web_search to find relevant information, scrape promising results, and create a comprehensive research note.${urls.length > 0 ? `\n\nAlso check these URLs for context: ${urls.join(', ')}` : ''}`
break
}
case 'monitor': {
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
prompt = lang === 'fr'
? `Analyse l'activité récente et fournis des insights basés sur tes instructions : ${agent.role}`
: `Analyze recent activity and provide insights based on your instructions: ${agent.role}`
if (agent.sourceNotebookId) {
const notes = await prisma.note.findMany({
where: { notebookId: agent.sourceNotebookId, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 10,
select: { id: true, title: true, content: true, createdAt: true }
})
if (notes.length > 0) {
const notesContext = notes.map(n =>
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 500)}`
).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes récentes du carnet surveillé' : 'Recent notes from monitored notebook'}:\n\n${notesContext}`
}
}
break
}
case 'excalidraw-generator': {
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
let notes: any[] = []
const specificNoteIds: string[] = agent.sourceNoteIds ? JSON.parse(agent.sourceNoteIds) : []
if (specificNoteIds.length > 0) {
notes = await prisma.note.findMany({
where: { id: { in: specificNoteIds }, userId: agent.userId, isArchived: false, trashedAt: null },
select: { id: true, title: true, content: true, createdAt: true }
})
} else if (agent.sourceNotebookId) {
notes = await prisma.note.findMany({
where: { notebookId: agent.sourceNotebookId, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 10,
select: { id: true, title: true, content: true, createdAt: true }
})
} else {
const notebooks = await prisma.notebook.findMany({
where: { userId: agent.userId },
include: { _count: { select: { notes: { where: { trashedAt: null } } } } },
orderBy: { createdAt: 'desc' }
})
const best = notebooks.find(n => n._count.notes > 0)
if (best) {
notes = await prisma.note.findMany({
where: { notebookId: best.id, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 10,
select: { id: true, title: true, content: true, createdAt: true }
})
}
}
prompt = lang === 'fr'
? `Crée un diagramme Excalidraw (mind map ou flowchart) représentant visuellement les concepts clés et leurs relations.`
: `Create an Excalidraw diagram (mind map or flowchart) that visually represents the key concepts and their relationships.`
if (notes.length > 0) {
const notesContext = notes.map(n =>
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 800)}`
).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes source à analyser' : 'Source notes to analyze'}:\n\n${notesContext}`
}
prompt += `\n\n${lang === 'fr'
? 'IMPORTANT : Utilise OBLIGATOIREMENT l\'outil generate_excalidraw pour créer le diagramme. Ne réponds pas avec du texte, appelle directement l\'outil.'
: 'IMPORTANT: You MUST use the generate_excalidraw tool to create the diagram. Do NOT respond with text, call the tool directly.'}`
// Map UI diagram type values to internal tool values
const diagramTypeMapping: Record<string, string> = {
'mind_map': 'mindmap',
'org_chart': 'org-chart',
'architecture': 'architecture-cloud',
'process_map': 'process-map',
'logic_flow': 'auto',
}
const rawDiagramType = agent.slideTheme || 'auto'
const diagramType = diagramTypeMapping[rawDiagramType] || rawDiagramType
prompt += `\n\n${lang === 'fr'
? `Type de diagramme imposé : ajoute "type":"${diagramType}" dans le JSON envoyé à generate_excalidraw.`
: `Required diagram type: include "type":"${diagramType}" in the JSON passed to generate_excalidraw.`}`
prompt += `\n\n${lang === 'fr'
? 'Types supportés: auto, flowchart, mindmap, architecture-cloud, org-chart, timeline, process-map. Si "auto", choisis selon le métier et le contenu.'
: 'Supported types: auto, flowchart, mindmap, architecture-cloud, org-chart, timeline, process-map. If "auto", choose according to domain and content.'}`
// Map UI diagram style values to internal tool values
const diagramStyleMapping: Record<string, string> = {
'sketchy': 'sketch-plus',
'draft': 'sketch-plus',
'handwritten': 'sketch-plus',
'minimal': 'austere',
'professional': 'austere',
'soft': 'default',
'polished': 'default',
}
const rawDiagramStyle = agent.slideStyle || 'default'
const diagramStyle = diagramStyleMapping[rawDiagramStyle] || (rawDiagramStyle === 'austere' || rawDiagramStyle === 'sketch-plus' ? rawDiagramStyle : 'default')
prompt += `\n\n${lang === 'fr'
? `Style visuel imposé : ajoute "style":"${diagramStyle}" dans le JSON envoyé à generate_excalidraw.`
: `Visual style required: include "style":"${diagramStyle}" in the JSON passed to generate_excalidraw.`}`
break
}
case 'slide-generator': {
const slideTopic = agent.description?.startsWith('template:')
? agent.name
: (agent.description || agent.name)
const slideTemplate = agent.description?.startsWith('template:')
? agent.description.replace('template:', '')
: 'auto'
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
let notes: any[] = []
const specificNoteIds: string[] = agent.sourceNoteIds ? JSON.parse(agent.sourceNoteIds) : []
if (specificNoteIds.length > 0) {
notes = await prisma.note.findMany({
where: { id: { in: specificNoteIds }, userId: agent.userId, isArchived: false, trashedAt: null },
select: { id: true, title: true, content: true, createdAt: true }
})
} else if (agent.sourceNotebookId) {
notes = await prisma.note.findMany({
where: { notebookId: agent.sourceNotebookId, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 15,
select: { id: true, title: true, content: true, createdAt: true }
})
} else {
const notebooks = await prisma.notebook.findMany({
where: { userId: agent.userId },
include: { _count: { select: { notes: { where: { trashedAt: null } } } } },
orderBy: { createdAt: 'desc' }
})
const best = notebooks.find(n => n._count.notes > 0)
if (best) {
notes = await prisma.note.findMany({
where: { notebookId: best.id, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 15,
select: { id: true, title: true, content: true, createdAt: true }
})
}
}
prompt = lang === 'fr'
? `Crée une présentation professionnelle sur le sujet "${slideTopic}" en utilisant le contenu des notes ci-dessous. Appelle generate_slides avec un objet JSON structuré (title, theme, slides[]).`
: `Create a professional presentation about "${slideTopic}" using the content from the notes below. Call generate_slides with a structured JSON object (title, theme, slides[]).`
if (notes.length > 0) {
const notesContext = notes.map(n =>
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 2000)}`
).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes source' : 'Source notes'}:\n\n${notesContext}`
}
// ── Executive Template Structure (HTML-compatible) ──
if (slideTemplate && slideTemplate !== 'auto') {
const templates: Record<string, { fr: string; en: string }> = {
'board-update': {
fr: `Structure imposée "Board Update" (10 slides) :
1. TITRE avec date | 2. KPIs majeurs (3-4 gros chiffres) | 3. Bar chart progression objectifs | 4. Grille de métriques (6 cards) | 5. Timeline jalons | 6. Cards réalisations | 7. Line chart tendance | 8. Deux colonnes risques/mitigations | 9. Bullets prochaines étapes | 10. Conclusion`,
en: `Mandatory "Board Update" structure (10 slides):
1. TITLE with date | 2. Key KPIs (3-4 big numbers) | 3. Bar chart goal progress | 4. Metrics grid (6 cards) | 5. Timeline milestones | 6. Achievement cards | 7. Line chart trend | 8. Two-column risks/mitigations | 9. Next steps bullets | 10. Conclusion`
},
'project-status': {
fr: `Structure imposée "Project Status" (10 slides) :
1. TITRE + statut 🟢🟡🔴 | 2. KPIs (% avancement, jours, budget) | 3. Bar chart par workstream | 4. Cards livrables | 5. Timeline roadmap | 6. Line chart burn-down | 7. Deux colonnes blockers/actions | 8. Tableau de risques | 9. Bullets décisions requises | 10. Synthèse`,
en: `Mandatory "Project Status" structure (10 slides):
1. TITLE + status 🟢🟡🔴 | 2. KPIs (% complete, days, budget) | 3. Bar chart by workstream | 4. Deliverable cards | 5. Timeline roadmap | 6. Line chart burn-down | 7. Two-column blockers/actions | 8. Risk table | 9. Required decisions bullets | 10. Summary`
},
'strategy-review': {
fr: `Structure imposée "Strategy Review" (10 slides) :
1. TITRE stratégique | 2. Contexte marché (5+ bullets) | 3. Radar/pie positionnement | 4. SWOT deux colonnes | 5. Area chart tendances | 6. Cards axes stratégiques | 7. Timeline roadmap 12 mois | 8. Bar chart projections | 9. KPIs objectifs cibles | 10. Call to action`,
en: `Mandatory "Strategy Review" structure (10 slides):
1. Strategic TITLE | 2. Market context (5+ bullets) | 3. Radar/pie positioning | 4. SWOT two-column | 5. Area chart trends | 6. Strategic axes cards | 7. 12-month roadmap timeline | 8. Bar chart projections | 9. Target KPIs | 10. Call to action`
},
'quarterly-results': {
fr: `Structure imposée "Quarterly Results" (10 slides) :
1. TITRE "Résultats Q? Année" | 2. 4 KPIs (Revenue, Croissance, Marge, NPS) | 3. Bar chart revenue/mois | 4. Line chart croissance YoY | 5. Cards faits marquants | 6. Grille métriques opérationnelles | 7. Donut répartition clients | 8. Deux colonnes succès/challenges | 9. Area chart forecast | 10. Conclusion outlook`,
en: `Mandatory "Quarterly Results" structure (10 slides):
1. TITLE "Q? Year Results" | 2. 4 KPIs (Revenue, Growth, Margin, NPS) | 3. Bar chart revenue/month | 4. Line chart YoY growth | 5. Highlights cards | 6. Operational metrics grid | 7. Donut client breakdown | 8. Two-column successes/challenges | 9. Area chart forecast | 10. Conclusion outlook`
}
}
const tmpl = templates[slideTemplate]
if (tmpl) {
prompt += `\n\n${tmpl[lang === 'fr' ? 'fr' : 'en']}`
}
}
// ── Density rules ──
prompt += lang === 'fr'
? `\n\nDENSITÉ OBLIGATOIRE : Chaque slide doit avoir un CONTENU RICHE. Minimum 5 bullets par slide "bullets" (≥15 mots chaque). Minimum 4 cards quand tu fais "cards". Minimum 4 data points quand tu fais "chart". Inclus OBLIGATOIREMENT au moins 1 slide "chart" avec données RÉELLES extraites des notes. Si pas de chiffres dans la note, fais un radar de maturité ou une comparaison qualitative notée sur 5.`
: `\n\nMANDATORY DENSITY: Every slide must have RICH CONTENT. Minimum 5 items per "bullets" slide (≥15 words each). Minimum 4 cards when using "cards". Minimum 4 data points for "chart". You MUST include at least 1 "chart" slide with REAL data from the notes. If no numbers in notes, create a maturity radar or qualitative comparison scored out of 5.`
prompt += `\n\n${lang === 'fr'
? 'IMPORTANT : Appelle OBLIGATOIREMENT generate_slides avec le JSON structuré {title, theme, slides:[...]}. Ne réponds JAMAIS avec du texte brut. 6-12 slides variées.'
: 'IMPORTANT: You MUST call generate_slides with structured JSON {title, theme, slides:[...]}. NEVER respond with plain text. 6-12 varied slides.'}`
if (agent.slideTheme && agent.slideTheme !== 'auto') {
prompt += `\n${lang === 'fr'
? `Thème visuel imposé : utilise "theme":"${agent.slideTheme}" dans l'appel generate_slides.`
: `Visual theme required: use "theme":"${agent.slideTheme}" in the generate_slides call.`}`
}
break
}
case 'task-extractor': {
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
let notes: any[] = []
if (agent.sourceNotebookId) {
notes = await prisma.note.findMany({
where: { notebookId: agent.sourceNotebookId, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 20,
select: { id: true, title: true, content: true, createdAt: true }
})
} else {
notes = await prisma.note.findMany({
where: { userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { updatedAt: 'desc' }, take: 20,
select: { id: true, title: true, content: true, createdAt: true }
})
}
const notebookId = agent.sourceNotebookId || agent.targetNotebookId || null
prompt = lang === 'fr'
? `Analyse les notes suivantes et extrais TOUS les action items, tâches et TODOs. Utilise l'outil task_extract pour créer une note de synthèse.${notebookId ? ` Passe notebookId="${notebookId}" à task_extract.` : ''}`
: `Analyze the following notes and extract ALL action items, tasks and TODOs. Use the task_extract tool to create a synthesis note.${notebookId ? ` Pass notebookId="${notebookId}" to task_extract.` : ''}`
if (notes.length > 0) {
const notesContext = notes.map(n =>
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 500)}`
).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes à analyser' : 'Notes to analyze'}:\n\n${notesContext}`
}
prompt += `\n\n${lang === 'fr'
? 'IMPORTANT : Utilise OBLIGATOIREMENT l\'outil task_extract. Ne réponds pas avec du texte, appelle directement l\'outil.'
: 'IMPORTANT: You MUST use the task_extract tool. Do NOT respond with text, call the tool directly.'}`
break
}
default: {
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []
prompt = agent.role || (lang === 'fr' ? 'Accomplis la tâche demandée en utilisant les outils disponibles.' : 'Accomplish the requested task using available tools.')
if (urls.length > 0) {
prompt += `\n\n${lang === 'fr' ? 'URLs de référence' : 'Reference URLs'}: ${urls.join(', ')}`
}
}
}
}
// Save input trace
await prisma.agentAction.update({
where: { id: actionId },
data: { input: JSON.stringify({ prompt, toolNames, maxSteps: agent.maxSteps, lang }) }
})
// Execute with tools
const provider = getChatProvider(sysConfig)
const result = await provider.generateWithTools({
tools,
maxSteps: agent.maxSteps || 10,
systemPrompt,
prompt,
})
const duration = Date.now() - startTime
// Check if AI already created a note via note_create tool
// Or if excalidraw/slide generator created a canvas
let existingNoteId: string | null = null
let canvasId: string | null = null
const scrapedUrls: string[] = []
let specificToolCalled = false
let fallbackSuccess = false
let parsedFallbackJson: any = null
// Détecte si le modèle ne supporte pas le function calling
// (il retourne le JSON de l'outil comme texte brut au lieu de l'exécuter)
const totalToolCallsCheck = result.steps.reduce((acc, s) => acc + s.toolCalls.length, 0)
if (totalToolCallsCheck === 0 && result.text) {
const trimmed = result.text.trim()
if (trimmed.startsWith('[{"type":"function"') || trimmed.startsWith('[{\n "type": "function"')) {
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'failure',
log: `Le modèle "${sysConfig.AI_MODEL_CHAT}" ne supporte pas le function calling (tool use). Utilisez un modèle compatible dans les paramètres admin : openai/gpt-4o-mini, google/gemini-flash-1.5, anthropic/claude-3-haiku, ou mistralai/mistral-small.`,
}
})
return { success: false, actionId, error: 'Model does not support tool calling' }
}
if (agentType === 'slide-generator' || agentType === 'excalidraw-generator') {
const toolName = agentType === 'slide-generator' ? 'generate_slides' : 'generate_excalidraw'
parsedFallbackJson = extractJsonFromText(result.text)
if (parsedFallbackJson) {
try {
if (agentType === 'slide-generator') {
let slides: any[] = []
let title = agent.name || "Présentation"
let theme = agent.slideTheme || "architectural-saas"
if (Array.isArray(parsedFallbackJson)) {
slides = parsedFallbackJson
} else if (parsedFallbackJson && typeof parsedFallbackJson === 'object') {
if (Array.isArray(parsedFallbackJson.slides)) {
slides = parsedFallbackJson.slides
} else if (parsedFallbackJson.slides && typeof parsedFallbackJson.slides === 'object') {
// nested structure support
} else {
if (parsedFallbackJson.type) {
slides = [parsedFallbackJson]
} else {
const arrays = Object.values(parsedFallbackJson).filter(val => Array.isArray(val))
if (arrays.length > 0) {
slides = arrays[0] as any[]
}
}
}
if (typeof parsedFallbackJson.title === 'string') title = parsedFallbackJson.title
if (typeof parsedFallbackJson.theme === 'string') theme = parsedFallbackJson.theme
}
const registered = toolRegistry.get('generate_slides')
if (registered) {
console.log('[AgentExecutor] Running manual fallback execution for generate_slides')
const slideTool = registered.buildTool(ctx)
const executionResult = await slideTool.execute({ title, theme, slides })
if (executionResult && executionResult.success && executionResult.canvasId) {
canvasId = executionResult.canvasId
specificToolCalled = true
fallbackSuccess = true
}
}
} else {
let diagramStr = ""
let title = agent.name || "Diagramme"
if (parsedFallbackJson && typeof parsedFallbackJson === 'object') {
if (typeof parsedFallbackJson.diagram === 'string') {
diagramStr = parsedFallbackJson.diagram
if (typeof parsedFallbackJson.title === 'string') title = parsedFallbackJson.title
} else if (parsedFallbackJson.diagram && typeof parsedFallbackJson.diagram === 'object') {
diagramStr = JSON.stringify(parsedFallbackJson.diagram)
if (typeof parsedFallbackJson.title === 'string') title = parsedFallbackJson.title
} else {
diagramStr = JSON.stringify(parsedFallbackJson)
if (typeof parsedFallbackJson.title === 'string') title = parsedFallbackJson.title
}
} else if (typeof parsedFallbackJson === 'string') {
diagramStr = parsedFallbackJson
}
if (diagramStr) {
const registered = toolRegistry.get('generate_excalidraw')
if (registered) {
console.log('[AgentExecutor] Running manual fallback execution for generate_excalidraw')
const excalidrawTool = registered.buildTool(ctx)
const executionResult = await excalidrawTool.execute({ title, diagram: diagramStr })
if (executionResult && executionResult.success && executionResult.canvasId) {
canvasId = executionResult.canvasId
specificToolCalled = true
fallbackSuccess = true
}
}
}
}
} catch (err) {
console.error('[AgentExecutor] Fallback execution failed:', err)
}
}
if (!fallbackSuccess) {
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'failure',
log: lang === 'fr'
? `L'IA n'a pas appelé l'outil ${toolName}. Le modèle a répondu avec du texte au lieu de générer le fichier. Modèle: "${sysConfig.AI_MODEL_CHAT}". Essayez un modèle compatible avec le function calling.`
: `The AI did not call the ${toolName} tool. The model responded with text instead of generating the file. Model: "${sysConfig.AI_MODEL_CHAT}". Try a model that supports function calling.`,
}
})
return { success: false, actionId, error: `AI did not call ${toolName} tool` }
}
}
}
// Build tool log trace
const toolLog = result.steps.map((step, i) => ({
step: i + 1,
text: step.text?.substring(0, 200),
toolCalls: step.toolCalls,
toolResults: step.toolResults?.map(tr => ({
toolName: tr.toolName,
preview: JSON.stringify(tr.output).substring(0, 300),
})),
}))
if (fallbackSuccess) {
toolLog.push({
step: toolLog.length + 1,
text: "Manual JSON parsing & fallback execution succeeded.",
toolCalls: [{
id: "fallback",
type: "function",
function: {
name: agentType === 'slide-generator' ? 'generate_slides' : 'generate_excalidraw',
arguments: JSON.stringify(parsedFallbackJson),
}
}] as any,
toolResults: [{
toolCallId: "fallback",
toolName: agentType === 'slide-generator' ? 'generate_slides' : 'generate_excalidraw',
type: "tool-result",
result: { success: true, canvasId }
}] as any
})
}
const requiredTool = isFileGenerator
? (agentType === 'slide-generator' ? ['generate_slides'] : ['generate_excalidraw'])
: null
for (const step of result.steps) {
for (let i = 0; i < step.toolCalls.length; i++) {
if (step.toolCalls[i].toolName === 'note_create') {
const toolResult = step.toolResults?.[i]
if (toolResult && typeof toolResult.output === 'object' && toolResult.output?.success && toolResult.output?.noteId) {
existingNoteId = toolResult.output.noteId
}
}
if (step.toolCalls[i].toolName === 'generate_excalidraw' || step.toolCalls[i].toolName === 'generate_slides') {
const toolResult = step.toolResults?.[i]
if (toolResult && typeof toolResult.output === 'object' && toolResult.output?.success && toolResult.output?.canvasId) {
canvasId = toolResult.output.canvasId as string
specificToolCalled = true
}
}
if (step.toolCalls[i].toolName === 'web_scrape') {
const toolResult = step.toolResults?.[i]
if (toolResult && typeof toolResult.output === 'object' && toolResult.output?.url) {
scrapedUrls.push(toolResult.output.url as string)
}
}
}
}
// For file generators: if the specific tool was NOT called, fail immediately
if (isFileGenerator && !specificToolCalled) {
const toolName = requiredTool!.join(' or ')
const toolLogStr = JSON.stringify(toolLog)
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'failure',
log: lang === 'fr'
? `L'IA n'a pas appelé l'outil ${toolName}. Le modèle a peut-être répondu avec du texte. Modèle: "${sysConfig.AI_MODEL_CHAT}". Essayez un modèle compatible avec le function calling.`
: `The AI did not call the ${toolName} tool. The model may have responded with text. Model: "${sysConfig.AI_MODEL_CHAT}". Try a model that supports function calling.`,
toolLog: toolLogStr,
}
})
return { success: false, actionId, error: `AI did not call ${toolName} tool` }
}
const totalToolCalls = result.steps.reduce((acc, s) => acc + s.toolCalls.length, 0)
let noteId: string | undefined
if (isFileGenerator) {
// File generators NEVER create notes — only canvases
noteId = undefined
} else if (canvasId) {
noteId = undefined
} else if (existingNoteId) {
if (agent.targetNotebookId) {
await prisma.note.update({
where: { id: existingNoteId },
data: { notebookId: agent.targetNotebookId }
})
}
noteId = existingNoteId
} else {
const text = result.text || 'No output generated.'
const fullContent = `# ${agent.name}\n\n${text}\n\n---\n\n_Agent execution: ${totalToolCalls} tool calls in ${Math.round(duration / 1000)}s_`
const title = await generateTitle(fullContent, agent.name, lang)
const note = await createAgentNote({
title,
content: fullContent,
userId: agent.userId,
notebookId: agent.targetNotebookId || null,
})
noteId = note.id
}
// Extract images from scraped URLs and insert into markdown content
let imageCount = 0
if (agent.includeImages && scrapedUrls.length > 0) {
console.log(`[AgentExecutor] Extracting images from ${scrapedUrls.length} URLs:`, scrapedUrls)
const imageData = await extractImagesForUrls(scrapedUrls)
console.log(`[AgentExecutor] Extracted ${imageData.length} images`)
if (imageData.length > 0) {
const currentNote = await prisma.note.findUnique({
where: { id: noteId },
select: { content: true },
})
if (currentNote?.content) {
const enhancedContent = await placeImagesWithAI(currentNote.content, imageData, sysConfig, lang)
await prisma.note.update({
where: { id: noteId },
data: { content: enhancedContent },
})
}
imageCount = imageData.length
}
} else if (agent.includeImages) {
console.log(`[AgentExecutor] includeImages enabled but no scraped URLs found in tool results`)
}
const resultId = canvasId || noteId
const resultType = canvasId ? 'Canvas' : 'Note'
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'success',
result: resultId,
log: `Tool-use: ${totalToolCalls} calls, ${Math.round(duration / 1000)}s. ${resultType}: ${resultId}${imageCount > 0 ? `, ${imageCount} images` : ''}`,
toolLog: JSON.stringify(toolLog),
}
})
if (canvasId) {
return { success: true, actionId, canvasId }
}
return { success: true, actionId, noteId: resultId }
}
// --- Agent Email Notification ---
async function sendAgentEmail(agentId: string, userId: string, agentName: string, content: string) {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true, name: true }
})
if (!user?.email) return
const sysConfig = await getSystemConfig()
const emailProvider = (sysConfig.EMAIL_PROVIDER || 'auto') as 'resend' | 'smtp' | 'auto'
const appUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const { html, attachments } = await getAgentEmailTemplate({
agentName,
content,
appUrl,
userName: user.name || undefined,
})
await sendEmail({
to: user.email,
subject: `[Memento] ${agentName}`,
html,
attachments: attachments.length > 0 ? attachments : undefined,
}, emailProvider)
} catch (e) {
console.error('[AgentExecutor] Failed to send agent email:', e)
}
}
// --- Main Executor ---
export async function executeAgent(agentId: string, userId: string, promptOverride?: string): Promise<AgentExecutionResult> {
const agent = await prisma.agent.findUnique({
where: { id: agentId }
})
if (!agent || agent.userId !== userId) {
throw new Error('Agent not found')
}
const action = await prisma.agentAction.create({
data: {
agentId,
status: 'running',
}
})
// Detect user language
const lang = await getUserLanguage(userId)
try {
let result: AgentExecutionResult
const hasTools = agent.tools && agent.tools !== '[]' && agent.tools !== 'null'
if (hasTools) {
result = await executeToolUseAgent(agent, action.id, lang, promptOverride)
} else {
switch ((agent.type || 'scraper') as AgentType) {
case 'scraper':
result = await executeScraperAgent(agent, action.id, lang)
break
case 'researcher':
result = await executeResearcherAgent(agent, action.id, lang)
break
case 'monitor':
result = await executeMonitorAgent(agent, action.id, lang)
break
case 'custom':
result = await executeCustomAgent(agent, action.id, lang)
break
case 'slide-generator':
case 'excalidraw-generator':
result = await executeToolUseAgent(agent, action.id, lang, promptOverride)
break
default:
result = await executeScraperAgent(agent, action.id, lang)
}
}
const nextRunUpdate: Record<string, Date | null> = {}
if (agent.frequency !== 'manual') {
nextRunUpdate.nextRun = calculateNextRun({
frequency: agent.frequency,
scheduledTime: agent.scheduledTime,
scheduledDay: agent.scheduledDay,
timezone: agent.timezone,
})
}
await prisma.agent.update({
where: { id: agentId },
data: { lastRun: new Date(), ...nextRunUpdate }
})
if (result.success && agent.notifyEmail && !result.canvasId) {
const note = result.noteId
? await prisma.note.findUnique({ where: { id: result.noteId }, select: { content: true } })
: null
if (note?.content) {
await sendAgentEmail(agent.id, userId, agent.name, note.content)
}
}
// Create in-app notification for agent result
if (result.success) {
const isCanvas = !!result.canvasId
const resultId = result.canvasId || result.noteId
const isSlides = isCanvas && (agent.type === 'slide-generator')
let message: string
if (isSlides) {
message = lang === 'fr' ? `Présentation PowerPoint prête — cliquez pour télécharger le fichier .pptx.` : `PowerPoint presentation ready — click to download the .pptx file.`
} else if (isCanvas) {
message = lang === 'fr' ? `Diagramme Excalidraw créé — cliquez pour ouvrir dans le Lab.` : `Excalidraw diagram created — click to open in Lab.`
} else if (result.noteId) {
message = lang === 'fr' ? `L'agent a terminé avec succès — note créée.` : `Agent completed successfully — note created.`
} else {
message = lang === 'fr' ? `L'agent a terminé avec succès.` : `Agent completed successfully.`
}
await createNotification({
userId,
type: isSlides ? 'agent_slides_ready' : isCanvas ? 'agent_canvas_ready' : 'agent_success',
title: agent.name,
message,
actionUrl: isCanvas ? `/lab?id=${result.canvasId}` : result.noteId ? `/?openNote=${result.noteId}` : '/agents',
relatedId: resultId || agentId,
})
}
return result
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.error(`[AgentExecutor] Agent ${agentId} failed:`, message)
await prisma.agentAction.update({
where: { id: action.id },
data: { status: 'failure', log: message }
})
// Notify user of agent failure
await createNotification({
userId,
type: 'agent_failure',
title: agent?.name || 'Agent',
message: message.length > 200 ? message.substring(0, 200) + '...' : message,
actionUrl: '/agents',
relatedId: agentId,
})
return { success: false, actionId: action.id, error: message }
}
}