Files
Keep/keep-notes/lib/ai/services/agent-executor.service.ts

1107 lines
45 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, getAIProvider } 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 tools for side-effect registration
import '../tools'
// --- Types ---
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom'
export interface AgentExecutionResult {
success: boolean
actionId: string
noteId?: string
error?: string
}
// --- 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 = getAIProvider(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).toLocaleDateString(dateLocale)}` : ''
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 = getAIProvider(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 prisma.note.create({
data: {
title,
content: fullContent,
isMarkdown: true,
autoGenerated: true,
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 = getAIProvider(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 prisma.note.create({
data: {
title,
content: fullContent,
isMarkdown: true,
autoGenerated: true,
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 = getAIProvider(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 prisma.note.create({
data: {
title,
content: fullContent,
isMarkdown: true,
autoGenerated: true,
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 = getAIProvider(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 prisma.note.create({
data: {
title,
content: fullContent,
isMarkdown: true,
autoGenerated: true,
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.`,
},
}
// --- 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
targetNotebookId?: string | null; userId: string; tools?: string | null; maxSteps?: number
includeImages?: boolean
},
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 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
}
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
// 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),
})),
}))
// Check if AI already created a note via note_create tool
let existingNoteId: string | null = null
const scrapedUrls: string[] = []
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 === 'web_scrape') {
const toolResult = step.toolResults?.[i]
if (toolResult && typeof toolResult.output === 'object' && toolResult.output?.url) {
scrapedUrls.push(toolResult.output.url as string)
}
}
}
}
const totalToolCalls = result.steps.reduce((acc, s) => acc + s.toolCalls.length, 0)
let noteId: string
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 prisma.note.create({
data: {
title,
content: fullContent,
isMarkdown: true,
autoGenerated: true,
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`)
}
await prisma.agentAction.update({
where: { id: actionId },
data: {
status: 'success',
result: noteId,
log: `Tool-use: ${totalToolCalls} calls, ${Math.round(duration / 1000)}s. Note: ${noteId}${imageCount > 0 ? `, ${imageCount} images` : ''}`,
toolLog: JSON.stringify(toolLog),
}
})
return { success: true, actionId, noteId }
}
// --- 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
default:
result = await executeScraperAgent(agent, action.id, lang)
}
}
await prisma.agent.update({
where: { id: agentId },
data: { lastRun: new Date() }
})
if (result.success && agent.notifyEmail) {
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)
}
}
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 }
})
return { success: false, actionId: action.id, error: message }
}
}