La landing page est sur /, l'app sur /home. Correction: actionUrl = /home?openNote=noteId Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1877 lines
86 KiB
TypeScript
1877 lines
86 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  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).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 ═══
|
||
- Nombre de slides : adapté au contenu réel de la note (la règle ⚠️ dans le message utilisateur est ABSOLUE)
|
||
- 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 ═══
|
||
- Slide count: adapt to the real content (the ⚠️ rule in the user message is ABSOLUTE)
|
||
- 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`,
|
||
},
|
||
}
|
||
|
||
/**
|
||
* Strip markdown syntax, URLs, metadata blocks, and frontmatter from note content.
|
||
* Returns only the semantic plain text, used to count real words before generating slides.
|
||
*/
|
||
function stripNoteMarkdown(raw: string): string {
|
||
return raw
|
||
// Remove frontmatter / YAML blocks
|
||
.replace(/^---[\s\S]*?---/m, '')
|
||
// Remove markdown headers
|
||
.replace(/^#{1,6}\s+/gm, '')
|
||
// Remove bold/italic
|
||
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
|
||
// Remove inline links — keep label only
|
||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||
// Remove bare URLs
|
||
.replace(/https?:\/\/\S+/g, '')
|
||
// Remove metadata lines (Connection to seed, Novelty score, Source brainstorm, Origin, Derived from, etc.)
|
||
.replace(/^\*{0,2}(Connection to seed|Novelty score|Source brainstorm|Source note|Origin|Derived from|## Origin)[^:\n]*:?.*$/gim, '')
|
||
// Remove horizontal rules
|
||
.replace(/^---+$/gm, '')
|
||
// Remove blockquote markers
|
||
.replace(/^>\s*/gm, '')
|
||
// Remove code blocks
|
||
.replace(/```[\s\S]*?```/g, '')
|
||
.replace(/`[^`]+`/g, '')
|
||
// Collapse whitespace
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
}
|
||
|
||
/** Count real semantic words in a note */
|
||
function countNoteWords(raw: string): number {
|
||
return stripNoteMarkdown(raw).split(/\s+/).filter(Boolean).length
|
||
}
|
||
|
||
/** Return max slides based on real word count */
|
||
function slideLimit(wordCount: number): { max: number; label: string } {
|
||
if (wordCount < 50) return { max: 3, label: '3 slides MAX (note très courte)' }
|
||
if (wordCount < 150) return { max: 4, label: '4 slides MAX (note courte)' }
|
||
if (wordCount < 350) return { max: 6, label: '6 slides MAX (note moyenne)' }
|
||
return { max: 8, label: '8 slides MAX (note longue)' }
|
||
}
|
||
|
||
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, language: 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, language: 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, language: true }
|
||
})
|
||
}
|
||
}
|
||
|
||
// Detect content language — use note's detected language, fallback to user lang
|
||
const noteLanguage = notes[0]?.language || null
|
||
// Map language codes to full names for the prompt
|
||
const LANG_NAMES: Record<string, string> = {
|
||
fr: 'French', en: 'English', es: 'Spanish', de: 'German', it: 'Italian',
|
||
pt: 'Portuguese', nl: 'Dutch', pl: 'Polish', ru: 'Russian', zh: 'Chinese',
|
||
ja: 'Japanese', ko: 'Korean', ar: 'Arabic', fa: 'Persian (Farsi)', hi: 'Hindi',
|
||
}
|
||
const contentLang = noteLanguage && LANG_NAMES[noteLanguage]
|
||
? LANG_NAMES[noteLanguage]
|
||
: (lang === 'fr' ? 'French' : 'English')
|
||
|
||
prompt = `Create a professional presentation using the content from the notes below. Call generate_slides with a structured JSON object (title, theme, slides[]).`
|
||
prompt += `\n\n⚠️ LANGUAGE RULE: ALL slide text (titles, bullets, labels, subtitles) MUST be written in ${contentLang}. Do NOT translate to another language.`
|
||
|
||
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')
|
||
|
||
// Compute real word count (stripped of markdown/metadata) to determine slide limit
|
||
const totalWords = notes.reduce((sum, n) => sum + countNoteWords(n.content || ''), 0)
|
||
const { max: maxSlides, label: slideRule } = slideLimit(totalWords)
|
||
|
||
prompt += `\n\n${lang === 'fr' ? 'Notes source' : 'Source notes'}:\n\n${notesContext}`
|
||
prompt += `\n\n${lang === 'fr'
|
||
? `⚠️ RÈGLE ABSOLUE : Cette note fait ${totalWords} mots réels (hors markdown/URLs/métadonnées). Tu DOIS générer EXACTEMENT ${slideRule}. Respecter cette limite est PLUS IMPORTANT que la diversité ou la densité.`
|
||
: `⚠️ ABSOLUTE RULE: This note has ${totalWords} real words (excluding markdown/URLs/metadata). You MUST generate EXACTLY ${slideRule}. This limit is MORE IMPORTANT than diversity or density.`
|
||
}`
|
||
}
|
||
|
||
// ── 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. Respecte ABSOLUMENT la limite de slides indiquée.'
|
||
: 'IMPORTANT: You MUST call generate_slides with structured JSON {title, theme, slides:[...]}. NEVER respond with plain text. STRICTLY respect the slide count limit indicated.'}`
|
||
|
||
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 ? `/home?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 }
|
||
}
|
||
}
|