1107 lines
45 KiB
TypeScript
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  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  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:  — 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 }
|
|
}
|
|
}
|