feat(agents): refonte complète slide-generator + excalidraw-generator
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s

Slide generator (generate_pptx):
- Pivot vers génération PowerPoint natif (pptxgenjs) au lieu de Reveal.js HTML
- 4 nouveaux layouts diagramme : timeline, process, comparison, metrics
- 2 nouveaux layouts image : image-content (texte + image), image-full (plein cadre)
- Redesign visuel de tous les layouts (cover split, section full-bleed, header band)
- Palettes corrigées : bg blanc sur toutes les palettes, contrastes réels
- fit:shrink systématique sur tous les textes pour éviter les débordements
- Extraction automatique des images des notes (Markdown/HTML) et injection dans le prompt IA
- Prompt IA renforcé : impose "style" et "theme" explicitement dans le JSON, impose ≥2 layouts diagramme
- Fix overlap timeline : zones de texte calculées sans collision avec les cercles
- Notification agent mise à jour : bouton download .pptx au lieu d'ouvrir HTML

Excalidraw generator:
- Layout Dagre/ELK pour graphes auto-positionnés
- Styles visuels : coloré, austère, sketch-plus (Virgil font)
- Zones/containers pour architecture-cloud
- Sanitisation du graphe et métriques de qualité de layout

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-05-05 20:55:15 +00:00
parent 21fb56de3f
commit 129d5541e6
11 changed files with 7441 additions and 3543 deletions

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
export async function GET(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const canvasId = req.nextUrl.searchParams.get('id')
if (!canvasId) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId, userId: session.user.id },
})
if (!canvas) return NextResponse.json({ error: 'Not found' }, { status: 404 })
let parsed: any
try { parsed = JSON.parse(canvas.data) } catch {
return NextResponse.json({ error: 'Invalid data' }, { status: 500 })
}
if (parsed.type !== 'pptx' || !parsed.base64) {
return NextResponse.json({ error: 'Not a PPTX canvas' }, { status: 400 })
}
const byteChars = atob(parsed.base64)
const bytes = new Uint8Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i)
const filename = parsed.filename || `${canvas.name.replace(/[^a-zA-Z0-9]/g, '_')}.pptx`
// Auto-delete after serving
await prisma.canvas.delete({ where: { id: canvasId } })
return new NextResponse(bytes, {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(bytes.length),
},
})
} catch (error) {
console.error('[Canvas Download]', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
try {
const canvasId = req.nextUrl.searchParams.get('id')
console.log('[Slides API] Request for id:', canvasId)
if (!canvasId) {
console.log('[Slides API] ERROR: Missing id')
return new Response(JSON.stringify({ error: 'Missing id' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId },
})
if (!canvas) {
console.log('[Slides API] ERROR: Canvas not found for id:', canvasId)
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})
}
console.log('[Slides API] Canvas found:', canvas.name, '| data length:', canvas.data?.length)
console.log('[Slides API] Raw data start:', canvas.data?.substring(0, 200))
let parsed: any
try {
parsed = JSON.parse(canvas.data)
} catch (parseErr) {
console.log('[Slides API] ERROR: JSON parse failed:', parseErr)
return new Response(JSON.stringify({ error: 'Invalid data' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
console.log('[Slides API] Parsed type:', parsed.type, '| has html:', !!parsed.html, '| html length:', parsed.html?.length)
console.log('[Slides API] HTML start:', parsed.html?.substring(0, 150))
if (parsed.type !== 'slides' || !parsed.html) {
console.log('[Slides API] ERROR: Not a slides canvas. type:', parsed.type, 'html exists:', !!parsed.html)
return new Response(JSON.stringify({ error: 'Not a slides canvas' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
console.log('[Slides API] SUCCESS: Returning HTML, length:', parsed.html.length)
return new Response(parsed.html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache',
},
})
} catch (error) {
console.error('[Slides API] FATAL:', error)
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}

View File

@@ -6,15 +6,15 @@
* Novice-friendly: hides system prompt and tools behind "Advanced mode".
*/
import { useState, useMemo, useRef } from 'react'
import { X, Plus, Trash2, Globe, FileSearch, FilePlus, FileText, ExternalLink, Brain, ChevronDown, ChevronUp, HelpCircle, Mail, ImageIcon } from 'lucide-react'
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import { X, Plus, Trash2, Globe, FileSearch, FilePlus, FileText, ExternalLink, Brain, ChevronDown, ChevronUp, HelpCircle, Mail, ImageIcon, Presentation, Pencil, Check } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
// --- Types ---
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom'
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
/** Small "?" tooltip shown next to form labels */
function FieldHelp({ tooltip }: { tooltip: string }) {
@@ -41,6 +41,7 @@ interface AgentFormProps {
role: string
sourceUrls?: string | null
sourceNotebookId?: string | null
sourceNoteIds?: string | null
targetNotebookId?: string | null
frequency: string
tools?: string | null
@@ -50,6 +51,8 @@ interface AgentFormProps {
scheduledTime?: string | null
scheduledDay?: number | null
timezone?: string | null
slideTheme?: string | null
slideStyle?: string | null
} | null
notebooks: { id: string; name: string; icon?: string | null }[]
onSave: (data: FormData) => Promise<void>
@@ -62,6 +65,8 @@ const TOOL_PRESETS: Record<string, string[]> = {
researcher: ['web_search', 'web_scrape', 'note_search', 'note_create', 'memory_search'],
monitor: ['note_search', 'note_read', 'note_create', 'memory_search'],
custom: ['memory_search'],
'slide-generator': ['generate_pptx'],
'excalidraw-generator': ['generate_excalidraw'],
}
// --- Shared class strings ---
@@ -89,6 +94,13 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
return ['']
})
const [sourceNotebookId, setSourceNotebookId] = useState(agent?.sourceNotebookId || '')
const [sourceNoteIds, setSourceNoteIds] = useState<string[]>(() => {
if (agent?.sourceNoteIds) {
try { return JSON.parse(agent.sourceNoteIds) } catch { return [] }
}
return []
})
const [noteOptions, setNoteOptions] = useState<{ id: string; title: string }[]>([])
const [targetNotebookId, setTargetNotebookId] = useState(agent?.targetNotebookId || '')
const [frequency, setFrequency] = useState(agent?.frequency || 'manual')
const [scheduledTime, setScheduledTime] = useState(agent?.scheduledTime || '08:00')
@@ -110,7 +122,36 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
const [maxSteps, setMaxSteps] = useState(agent?.maxSteps || 10)
const [notifyEmail, setNotifyEmail] = useState(agent?.notifyEmail || false)
const [includeImages, setIncludeImages] = useState(agent?.includeImages || false)
const [slideTheme, setSlideTheme] = useState(agent?.slideTheme || '')
const [slideStyle, setSlideStyle] = useState<'soft' | 'sharp' | 'rounded' | 'pill'>(
(agent?.slideStyle as 'soft' | 'sharp' | 'rounded' | 'pill') || 'soft'
)
const [excalidrawStyle, setExcalidrawStyle] = useState<'default' | 'austere' | 'sketch-plus'>(() => {
if (agent?.slideStyle === 'austere') return 'austere'
if (agent?.slideStyle === 'sketch-plus') return 'sketch-plus'
return 'default'
})
const [excalidrawType, setExcalidrawType] = useState<'auto' | 'architecture-cloud' | 'flowchart' | 'mindmap' | 'org-chart' | 'timeline' | 'process-map'>(() => {
const value = (agent?.slideTheme || '').trim()
if (value === 'auto' || value === 'architecture-cloud' || value === 'mindmap' || value === 'org-chart' || value === 'timeline' || value === 'process-map') return value
return 'flowchart'
})
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (!sourceNotebookId || (type !== 'slide-generator' && type !== 'excalidraw-generator' && type !== 'monitor')) {
setNoteOptions([])
return
}
fetch(`/api/notes?notebookId=${sourceNotebookId}&limit=50`)
.then(r => r.json())
.then(data => {
const notes = Array.isArray(data.data) ? data.data : Array.isArray(data) ? data : []
setNoteOptions(notes.map((n: any) => ({ id: n.id, title: n.title || 'Sans titre' })))
})
.catch(() => setNoteOptions([]))
}, [sourceNotebookId, type])
const [showAdvanced, setShowAdvanced] = useState(() => {
// Auto-open advanced if editing an agent with custom tools or custom prompt
if (agent?.tools) {
@@ -133,6 +174,9 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
{ id: 'note_create', icon: FilePlus, labelKey: 'agents.tools.noteCreate', external: false },
{ id: 'url_fetch', icon: ExternalLink, labelKey: 'agents.tools.urlFetch', external: false },
{ id: 'memory_search', icon: Brain, labelKey: 'agents.tools.memorySearch', external: false },
{ id: 'generate_pptx', icon: Presentation, labelKey: 'agents.tools.generatePptx', external: false },
{ id: 'generate_slides', icon: Presentation, labelKey: 'agents.tools.generateSlides', external: false },
{ id: 'generate_excalidraw', icon: Pencil, labelKey: 'agents.tools.generateExcalidraw', external: false },
], [])
// Track previous type to detect user-initiated type changes
@@ -172,10 +216,14 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
formData.set('frequency', frequency)
formData.set('targetNotebookId', targetNotebookId)
if (type === 'monitor') {
if (type === 'monitor' || type === 'slide-generator' || type === 'excalidraw-generator') {
formData.set('sourceNotebookId', sourceNotebookId)
}
if (sourceNoteIds.length > 0) {
formData.set('sourceNoteIds', JSON.stringify(sourceNoteIds))
}
const validUrls = urls.filter(u => u.trim())
if (validUrls.length > 0) {
formData.set('sourceUrls', JSON.stringify(validUrls))
@@ -189,6 +237,15 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
formData.set('scheduledDay', String(scheduledDay))
formData.set('timezone', timezone)
if (type === 'slide-generator') {
if (slideTheme) formData.set('slideTheme', slideTheme)
formData.set('slideStyle', slideStyle)
}
if (type === 'excalidraw-generator') {
formData.set('slideTheme', excalidrawType)
formData.set('slideStyle', excalidrawStyle)
}
await onSave(formData)
} catch {
toast.error(t('agents.toasts.saveError'))
@@ -197,12 +254,14 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
}
}
const showSourceNotebook = type === 'monitor'
const showSourceNotebook = type === 'monitor' || type === 'slide-generator' || type === 'excalidraw-generator'
const agentTypes: { value: AgentType; labelKey: string; descKey: string }[] = [
{ value: 'researcher', labelKey: 'agents.types.researcher', descKey: 'agents.typeDescriptions.researcher' },
{ value: 'scraper', labelKey: 'agents.types.scraper', descKey: 'agents.typeDescriptions.scraper' },
{ value: 'monitor', labelKey: 'agents.types.monitor', descKey: 'agents.typeDescriptions.monitor' },
{ value: 'slide-generator', labelKey: 'agents.types.slideGenerator', descKey: 'agents.typeDescriptions.slideGenerator' },
{ value: 'excalidraw-generator', labelKey: 'agents.types.excalidrawGenerator', descKey: 'agents.typeDescriptions.excalidrawGenerator' },
{ value: 'custom', labelKey: 'agents.types.custom', descKey: 'agents.typeDescriptions.custom' },
]
@@ -318,13 +377,13 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
</div>
)}
{/* Source Notebook (monitor only) */}
{/* Source Notebook (monitor, slide, excalidraw) */}
{showSourceNotebook && (
<div>
<label className={labelCls}>{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
<select
value={sourceNotebookId}
onChange={e => setSourceNotebookId(e.target.value)}
onChange={e => { setSourceNotebookId(e.target.value); setSourceNoteIds([]) }}
className={selectCls}
>
<option value="">{t('agents.form.selectNotebook')}</option>
@@ -337,7 +396,149 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
</div>
)}
{/* Target Notebook */}
{/* Note multi-select (slide-generator, excalidraw-generator only) */}
{(type === 'slide-generator' || type === 'excalidraw-generator') && sourceNotebookId && noteOptions.length > 0 && (
<div>
<label className={labelCls}>{t('agents.form.selectNotes')}<FieldHelp tooltip={t('agents.help.tooltips.selectNotes')} /></label>
<div className="border border-input rounded-lg max-h-48 overflow-y-auto bg-card">
{noteOptions.map(note => {
const isSelected = sourceNoteIds.includes(note.id)
return (
<button
key={note.id}
type="button"
onClick={() => {
setSourceNoteIds(prev =>
isSelected ? prev.filter(id => id !== note.id) : [...prev, note.id]
)
}}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-accent/50 transition-colors ${isSelected ? 'bg-primary/5' : ''}`}
>
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 ${isSelected ? 'border-primary bg-primary' : 'border-input'}`}>
{isSelected && <Check className="w-3 h-3 text-primary-foreground" />}
</div>
<span className={isSelected ? 'text-primary font-medium' : 'text-foreground'}>{note.title}</span>
</button>
)
})}
</div>
{sourceNoteIds.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">{t('agents.form.notesSelected', { count: sourceNoteIds.length })}</p>
)}
</div>
)}
{/* Theme selector — slide-generator only */}
{type === 'slide-generator' && (
<>
<div>
<label className={labelCls}>{t('agents.form.slideTheme')}<FieldHelp tooltip={t('agents.help.tooltips.slideTheme')} /></label>
<select
value={slideTheme}
onChange={e => setSlideTheme(e.target.value)}
className={selectCls}
>
<option value="">{t('agents.form.slideThemeDefault')}</option>
<option value="modern_wellness">Modern & Wellness</option>
<option value="business_authority">Business & Authority</option>
<option value="nature_outdoors">Nature & Outdoors</option>
<option value="vintage_academic">Vintage & Academic</option>
<option value="soft_creative">Soft & Creative</option>
<option value="bohemian">Bohemian</option>
<option value="vibrant_tech">Vibrant & Tech</option>
<option value="craft_artisan">Craft & Artisan</option>
<option value="tech_night">Tech & Night (dark)</option>
<option value="education_charts">Education & Charts</option>
<option value="forest_eco">Forest & Eco</option>
<option value="elegant_fashion">Elegant & Fashion</option>
<option value="art_food">Art & Food</option>
<option value="luxury_mystery">Luxury & Mystery</option>
<option value="pure_tech_blue">Pure Tech Blue</option>
<option value="coastal_coral">Coastal Coral</option>
<option value="vibrant_orange_mint">Vibrant Orange Mint</option>
<option value="platinum_white_gold">Platinum White Gold</option>
</select>
</div>
<div>
<label className={labelCls}>{t('agents.form.slideStyle')}<FieldHelp tooltip={t('agents.help.tooltips.slideStyle')} /></label>
<div className="grid grid-cols-2 gap-2">
{(['soft', 'sharp', 'rounded', 'pill'] as const).map(s => (
<button
key={s}
type="button"
onClick={() => setSlideStyle(s)}
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
slideStyle === s
? 'border-primary bg-primary/5 text-primary font-medium'
: `${toggleOffBorder} text-muted-foreground`
}`}
>
{t(`agents.form.slideStyle${s.charAt(0).toUpperCase() + s.slice(1)}` as any)}
</button>
))}
</div>
</div>
</>
)}
{/* Visual style selector — excalidraw-generator only */}
{type === 'excalidraw-generator' && (
<>
<div>
<label className={labelCls}>{t('agents.form.excalidrawDiagramType')}</label>
<div className="grid grid-cols-1 gap-2">
{([
{ id: 'auto', labelKey: 'agents.form.excalidrawDiagramTypeAuto' },
{ id: 'flowchart', labelKey: 'agents.form.excalidrawDiagramTypeFlowchart' },
{ id: 'mindmap', labelKey: 'agents.form.excalidrawDiagramTypeMindmap' },
{ id: 'org-chart', labelKey: 'agents.form.excalidrawDiagramTypeOrgChart' },
{ id: 'timeline', labelKey: 'agents.form.excalidrawDiagramTypeTimeline' },
{ id: 'process-map', labelKey: 'agents.form.excalidrawDiagramTypeProcessMap' },
{ id: 'architecture-cloud', labelKey: 'agents.form.excalidrawDiagramTypeArchitectureCloud' },
] as const).map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setExcalidrawType(opt.id)}
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
excalidrawType === opt.id
? 'border-primary bg-primary/5 text-primary font-medium'
: `${toggleOffBorder} text-muted-foreground`
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
<div>
<label className={labelCls}>{t('agents.form.excalidrawDiagramStyle')}</label>
<div className="grid grid-cols-2 gap-2">
{([
{ id: 'default', labelKey: 'agents.form.excalidrawDiagramStyleDefault' },
{ id: 'sketch-plus', labelKey: 'agents.form.excalidrawDiagramStyleSketchPlus' },
{ id: 'austere', labelKey: 'agents.form.excalidrawDiagramStyleAustere' },
] as const).map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setExcalidrawStyle(opt.id)}
className={`px-3 py-2 rounded-lg border-2 text-sm transition-all text-left ${
excalidrawStyle === opt.id
? 'border-primary bg-primary/5 text-primary font-medium'
: `${toggleOffBorder} text-muted-foreground`
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
</>
)}
{/* Target Notebook — hidden for file generators (they never create notes) */}
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
<div>
<label className={labelCls}>{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
<select
@@ -353,6 +554,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
))}
</select>
</div>
)}
{/* Frequency */}
<div>
@@ -423,7 +625,8 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
</div>
)}
{/* Email Notification */}
{/* Email Notification — hidden for file generators */}
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
<div
onClick={() => setNotifyEmail(!notifyEmail)}
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
@@ -441,8 +644,10 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${notifyEmail ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
</div>
</div>
)}
{/* Include Images */}
{/* Include Images — hidden for file generators */}
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
<div
onClick={() => setIncludeImages(!includeImages)}
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
@@ -460,6 +665,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${includeImages ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
</div>
</div>
)}
{/* Advanced mode toggle */}
<button

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2 } from 'lucide-react'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation } from 'lucide-react'
import {
Popover,
PopoverContent,
@@ -196,26 +196,39 @@ export function NotificationPanel() {
) : (
<div className="max-h-96 overflow-y-auto">
{/* App notifications (agents, system) */}
{appNotifications.map((notif) => (
{appNotifications.map((notif) => {
const isSlides = notif.type === 'agent_slides_ready'
const isCanvas = notif.type === 'agent_canvas_ready'
const canvasId = notif.relatedId
return (
<div
key={notif.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
setOpen(false)
router.push(notif.actionUrl)
}
}}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div className="flex items-start gap-3">
<div
className="flex items-start gap-3 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
setOpen(false)
router.push(notif.actionUrl)
}
}}
>
<div className={cn(
"mt-0.5 flex-none rounded-full p-1",
notif.type === 'agent_success' && 'bg-green-100 dark:bg-green-900/30 text-green-600',
notif.type === 'agent_slides_ready' && 'bg-purple-100 dark:bg-purple-900/30 text-purple-600',
notif.type === 'agent_canvas_ready' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
notif.type === 'agent_failure' && 'bg-red-100 dark:bg-red-900/30 text-red-600',
notif.type === 'system' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
)}>
{notif.type.startsWith('agent') ? (
{isSlides ? (
<Presentation className="w-3.5 h-3.5" />
) : isCanvas ? (
<Pencil className="w-3.5 h-3.5" />
) : notif.type.startsWith('agent') ? (
<Bot className="w-3.5 h-3.5" />
) : (
<AlertCircle className="w-3.5 h-3.5" />
@@ -226,9 +239,13 @@ export function NotificationPanel() {
<span className={cn(
"text-[10px] font-semibold uppercase tracking-wider",
notif.type === 'agent_success' && 'text-green-600 dark:text-green-400',
notif.type === 'agent_slides_ready' && 'text-purple-600 dark:text-purple-400',
notif.type === 'agent_canvas_ready' && 'text-blue-600 dark:text-blue-400',
notif.type === 'agent_failure' && 'text-red-600 dark:text-red-400',
notif.type === 'system' && 'text-blue-600 dark:text-blue-400',
)}>
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Slides Ready')}
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagram Ready')}
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent completed')}
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
{notif.type === 'system' && 'System'}
@@ -251,8 +268,23 @@ export function NotificationPanel() {
<X className="w-3.5 h-3.5" />
</button>
</div>
{isSlides && canvasId && (
<div className="mt-2 ml-8">
<button
onClick={async () => {
handleMarkNotifRead(notif.id)
window.open(`/api/canvas/download?id=${canvasId}`, '_blank')
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-md bg-purple-500 text-white hover:bg-purple-600 shadow-sm transition-all active:scale-95"
>
<Download className="w-3 h-3" />
{t('notification.downloadPptx') || 'Download .pptx'}
</button>
</div>
)}
</div>
))}
)
})}
{/* Overdue reminders */}
{overdueReminders.map((note) => (

View File

@@ -23,12 +23,13 @@ import '../tools'
// --- Types ---
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom'
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
export interface AgentExecutionResult {
success: boolean
actionId: string
noteId?: string
canvasId?: string
error?: string
}
@@ -640,6 +641,7 @@ async function executeCustomAgent(
return { success: true, actionId, noteId: note.id }
}
// --- System Prompts (bilingual) ---
const SYSTEM_PROMPTS: Record<string, Record<Lang, string>> = {
@@ -792,6 +794,181 @@ RULES:
- 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
2. **First node = ellipse** (entry point)
3. **ALL nodes connected** — every node must have at least 1 incoming or outgoing edge
4. **Short labels** — max 40 chars per label
5. **Edge labels** — add labels on important arrows to explain the relationship
6. **Use "diamond"** for at least one node if the content involves a decision
7. **No orphan nodes** — every node must be reachable from the central node
8. **Analyze content first** — identify key concepts and their relationships BEFORE creating JSON
9. **Call generate_excalidraw DIRECTLY**, do not respond with text`,
},
'slide-generator': {
fr: `Tu es un designer de présentations visuelles de classe mondiale (style Manus AI / Beautiful.ai). Tu reçois du contenu de notes et tu dois créer une présentation PowerPoint (.pptx) professionnelle, moderne et visuellement riche.
Tu dois OBLIGATOIREMENT appeler l'outil generate_pptx. Ne réponds JAMAIS avec du texte — appelle l'outil directement.
RÈGLES DE DESIGN IMPÉRATIVES :
- 8-12 slides, chaque slide a un layout distinct
- Slide 1 : "title" (titre fort + sous-titre accrocheur)
- Slide 2 : "toc" (sommaire numéroté)
- Utilise AU MOINS 2 layouts "diagramme" parmi : "timeline", "process", "metrics", "comparison"
- "timeline" : étapes chronologiques ou roadmap (content items : "Étape: description")
- "process" : étapes numérotées avec détails (content items : "Action: explication")
- "metrics" : KPIs visuels avec grandes valeurs colorées (content items : "VALEUR: libellé")
- "comparison" : deux colonnes contrastées (subtitle="Avant | Après" ou "Option A | Option B")
- "cards" : fonctionnalités en grille 2-3 colonnes (3-6 items)
- "section" : séparateur de section (title=titre, content=[] — le numéro est auto-généré)
- "quote" : citation impactante (title=texte, subtitle=auteur)
- "summary" : récapitulatif final
- "content" : liste de points SEULEMENT si aucun layout visuel n'est adapté (max 7 points)
- Ne JAMAIS répéter le même layout consécutivement
- Pour "section" : ne pas mettre le numéro dans content, laisser content=[]
- Thèmes recommandés pour un rendu moderne : vibrant_tech, platinum_white_gold, business_authority, pure_tech_blue, tech_night
- Points concis (max 100 chars), titres percutants et courts
- JSON strict pour generate_pptx, sans texte hors JSON.`,
en: `You are a world-class visual presentation designer (Manus AI / Beautiful.ai style). You receive note content and must create a professional, modern, visually rich PowerPoint (.pptx) presentation.
You MUST call the generate_pptx tool. NEVER respond with text — call the tool directly.
MANDATORY DESIGN RULES:
- 8-12 slides, each slide has a distinct layout
- Slide 1: "title" (strong title + punchy subtitle)
- Slide 2: "toc" (numbered table of contents)
- Use AT LEAST 2 "diagram" layouts from: "timeline", "process", "metrics", "comparison"
- "timeline": chronological steps or roadmap (content items: "Step: description")
- "process": numbered steps with details (content items: "Action: explanation")
- "metrics": visual KPIs with large colored values (content items: "VALUE: label")
- "comparison": two contrasting columns (subtitle="Before | After" or "Option A | Option B")
- "cards": feature grid 2-3 columns (3-6 items)
- "section": section divider (title=heading, content=[] — number is auto-generated)
- "quote": impactful quote (title=text, subtitle=author)
- "summary": closing recap
- "content": bullet list ONLY if no visual layout fits (max 7 points)
- NEVER repeat the same layout consecutively
- For "section": do NOT put numbers in content, leave content=[]
- Recommended themes for modern look: vibrant_tech, platinum_white_gold, business_authority, pure_tech_blue, tech_night
- Concise points (max 100 chars), short impactful titles
- Strict JSON for generate_pptx, no text outside JSON.`,
},
}
// --- Tool-Use Agent ---
@@ -800,8 +977,9 @@ 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
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,
@@ -837,6 +1015,7 @@ async function executeToolUseAgent(
// 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')
@@ -886,6 +1065,141 @@ async function executeToolUseAgent(
}
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.'}`
const diagramType = agent.slideTheme || 'auto'
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.'}`
const diagramStyle = agent.slideStyle === 'austere' || agent.slideStyle === 'sketch-plus' ? agent.slideStyle : '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 || agent.name
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
let notes: any[] = []
const specificNoteIds: string[] = agent.sourceNoteIds ? JSON.parse(agent.sourceNoteIds) : []
if (specificNoteIds.length > 0) {
notes = await prisma.note.findMany({
where: { id: { in: specificNoteIds }, userId: agent.userId, isArchived: false, trashedAt: null },
select: { id: true, title: true, content: true, createdAt: true }
})
} else if (agent.sourceNotebookId) {
notes = await prisma.note.findMany({
where: { notebookId: agent.sourceNotebookId, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 15,
select: { id: true, title: true, content: true, createdAt: true }
})
} else {
const notebooks = await prisma.notebook.findMany({
where: { userId: agent.userId },
include: { _count: { select: { notes: { where: { trashedAt: null } } } } },
orderBy: { createdAt: 'desc' }
})
const best = notebooks.find(n => n._count.notes > 0)
if (best) {
notes = await prisma.note.findMany({
where: { notebookId: best.id, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 15,
select: { id: true, title: true, content: true, createdAt: true }
})
}
}
prompt = lang === 'fr'
? `Crée une présentation PowerPoint professionnelle sur le sujet "${slideTopic}" en utilisant le contenu des notes ci-dessous.`
: `Create a professional PowerPoint presentation about "${slideTopic}" using the content from the notes below.`
// Extract image URLs from note content
const extractedImages: Array<{ url: string; noteTitle: string }> = []
if (notes.length > 0) {
const notesContext = notes.map(n => {
// Extract markdown images: ![alt](url) — only external/data URLs (skip relative paths that won't resolve)
const mdMatches = [...n.content.matchAll(/!\[[^\]]*\]\((https?:\/\/[^)]+|data:[^)]+)\)/g)]
for (const m of mdMatches) {
if (m[1]) extractedImages.push({ url: m[1], noteTitle: n.title || untitled })
}
// Extract HTML img tags
const htmlMatches = [...n.content.matchAll(/<img[^>]+src=["'](https?:\/\/[^"']+|data:[^"']+)["']/g)]
for (const m of htmlMatches) {
if (m[1]) extractedImages.push({ url: m[1], noteTitle: n.title || untitled })
}
return `### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 800)}`
}).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes source à transformer en slides' : 'Source notes to turn into slides'}:\n\n${notesContext}`
}
// Inject available images into the prompt
const uniqueImages = extractedImages.slice(0, 6) // max 6 images
if (uniqueImages.length > 0) {
const imgList = uniqueImages.map((img, i) => ` ${i + 1}. "${img.noteTitle}" → ${img.url.substring(0, 120)}`).join('\n')
prompt += `\n\n${lang === 'fr'
? `IMAGES DISPONIBLES (extraites des notes) — utilise-les dans le JSON via le champ "imageUrl" avec le layout "image-content" ou "image-full" :\n${imgList}`
: `AVAILABLE IMAGES (extracted from notes) — use them in the JSON via the "imageUrl" field with layout "image-content" or "image-full":\n${imgList}`}`
}
prompt += `\n\n${lang === 'fr'
? 'IMPORTANT : Appelle OBLIGATOIREMENT generate_pptx. Ne réponds pas avec du texte. Crée 8-12 slides visuelles, commence par "title", puis "toc", intègre AU MOINS 2 layouts diagramme (timeline, process, metrics, ou comparison). Évite les slides avec juste du texte — favorise les layouts visuels.'
: 'IMPORTANT: You MUST call generate_pptx. Do NOT respond with text. Create 8-12 visual slides: start with "title", then "toc", include AT LEAST 2 diagram layouts (timeline, process, metrics, or comparison). Avoid text-only slides — prefer visual layouts.'}`
if (agent.slideTheme) {
prompt += `\n\n${lang === 'fr'
? `Thème imposé par l'utilisateur : "${agent.slideTheme}". Dans le JSON tu DOIS mettre "theme": "${agent.slideTheme}".`
: `User-selected theme: "${agent.slideTheme}". You MUST put "theme": "${agent.slideTheme}" in the JSON.`}`
}
if (agent.slideStyle) {
prompt += `\n${lang === 'fr'
? `Style visuel imposé : dans le JSON tu DOIS mettre "style": "${agent.slideStyle}". Les valeurs possibles sont: "sharp" (angles nets), "soft" (arrondi standard), "rounded" (très arrondi), "pill" (capsules).`
: `Visual style: you MUST put "style": "${agent.slideStyle}" in the JSON. Values: "sharp" (crisp edges), "soft" (standard rounded), "rounded" (very rounded), "pill" (capsule shapes).`}`
}
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.')
@@ -928,6 +1242,19 @@ async function executeToolUseAgent(
})
return { success: false, actionId, error: 'Model does not support tool calling' }
}
if (agentType === 'slide-generator' || agentType === 'excalidraw-generator') {
const toolName = agentType === 'slide-generator' ? 'generate_pptx' : 'generate_excalidraw'
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
@@ -942,8 +1269,15 @@ async function executeToolUseAgent(
}))
// 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
const requiredTool = isFileGenerator
? (agentType === 'slide-generator' ? ['generate_pptx'] : ['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') {
@@ -952,6 +1286,13 @@ async function executeToolUseAgent(
existingNoteId = toolResult.output.noteId
}
}
if (step.toolCalls[i].toolName === 'generate_excalidraw' || step.toolCalls[i].toolName === 'generate_slides' || step.toolCalls[i].toolName === 'generate_pptx') {
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) {
@@ -961,10 +1302,32 @@ async function executeToolUseAgent(
}
}
// 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
if (existingNoteId) {
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 },
@@ -1010,17 +1373,23 @@ async function executeToolUseAgent(
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: noteId,
log: `Tool-use: ${totalToolCalls} calls, ${Math.round(duration / 1000)}s. Note: ${noteId}${imageCount > 0 ? `, ${imageCount} images` : ''}`,
result: resultId,
log: `Tool-use: ${totalToolCalls} calls, ${Math.round(duration / 1000)}s. ${resultType}: ${resultId}${imageCount > 0 ? `, ${imageCount} images` : ''}`,
toolLog: JSON.stringify(toolLog),
}
})
return { success: true, actionId, noteId }
if (canvasId) {
return { success: true, actionId, canvasId }
}
return { success: true, actionId, noteId: resultId }
}
// --- Agent Email Notification ---
@@ -1097,6 +1466,10 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
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)
}
@@ -1117,7 +1490,7 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
data: { lastRun: new Date(), ...nextRunUpdate }
})
if (result.success && agent.notifyEmail) {
if (result.success && agent.notifyEmail && !result.canvasId) {
const note = result.noteId
? await prisma.note.findUnique({ where: { id: result.noteId }, select: { content: true } })
: null
@@ -1128,15 +1501,26 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
// 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: 'agent_success',
type: isSlides ? 'agent_slides_ready' : isCanvas ? 'agent_canvas_ready' : 'agent_success',
title: agent.name,
message: result.noteId
? (lang === 'fr' ? `L'agent a terminé avec succès — note créée.` : `Agent completed successfully — note created.`)
: (lang === 'fr' ? `L'agent a terminé avec succès.` : `Agent completed successfully.`),
actionUrl: result.noteId ? `/?openNote=${result.noteId}` : '/agents',
relatedId: result.noteId || agentId,
message,
actionUrl: isCanvas ? `/lab?id=${result.canvasId}` : result.noteId ? `/?openNote=${result.noteId}` : '/agents',
relatedId: resultId || agentId,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@ import './note-crud.tool'
import './web-scrape.tool'
import './url-fetch.tool'
import './memory.tool'
import './excalidraw.tool'
import './pptx.tool'
import './slides.tool'
// Re-export registry
export { toolRegistry, type ToolContext, type RegisteredTool } from './registry'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,736 @@
'use server'
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
interface SlideSpec {
title: string
subtitle?: string
content: string[]
layout?: 'title' | 'content' | 'section' | 'two-column' | 'cards' | 'stats' | 'quote' | 'toc' | 'summary' | 'image'
imageUrl?: string
notes?: string
}
interface PresentationSpec {
title: string
slides: SlideSpec[]
theme?: string
style?: string
author?: string
}
interface Palette {
primary: string
secondary: string
accent: string
light: string
bg: string
isDark: boolean
}
const PALETTES: Record<string, Palette> = {
modern_wellness: { primary: '#006d77', secondary: '#83c5be', accent: '#e29578', light: '#ffddd2', bg: '#edf6f9', isDark: false },
business_authority: { primary: '#2b2d42', secondary: '#8d99ae', accent: '#ef233c', light: '#edf2f4', bg: '#edf2f4', isDark: false },
nature_outdoors: { primary: '#606c38', secondary: '#283618', accent: '#dda15e', light: '#fefae0', bg: '#fefae0', isDark: false },
vintage_academic: { primary: '#780000', secondary: '#669bbc', accent: '#c1121f', light: '#fdf0d5', bg: '#fdf0d5', isDark: false },
soft_creative: { primary: '#7c6c8a', secondary: '#a89bbd', accent: '#d4a5c9', light: '#e8dff0', bg: '#f3eef8', isDark: false },
bohemian: { primary: '#8a7e5e', secondary: '#a89e72', accent: '#c4a06a', light: '#e9dcc0', bg: '#f5eed8', isDark: false },
vibrant_tech: { primary: '#023047', secondary: '#219ebc', accent: '#ffb703', light: '#8ecae6', bg: '#f8fbff', isDark: false },
craft_artisan: { primary: '#5e3e28', secondary: '#8a6548', accent: '#a68a64', light: '#d4c4a8', bg: '#ede0d4', isDark: false },
tech_night: { primary: '#e0e0e0', secondary: '#ffc300', accent: '#ffd60a', light: '#003566', bg: '#001d3d', isDark: true },
education_charts: { primary: '#264653', secondary: '#2a9d8f', accent: '#e76f51', light: '#e9c46a', bg: '#f4f1eb', isDark: false },
forest_eco: { primary: '#344e41', secondary: '#588157', accent: '#a3b18a', light: '#dad7cd', bg: '#eae8e3', isDark: false },
elegant_fashion: { primary: '#4a5759', secondary: '#8f9fa2', accent: '#b0c4b1', light: '#c9ada7', bg: '#f2e9e4', isDark: false },
art_food: { primary: '#335c67', secondary: '#5e8a6f', accent: '#e09f3e', light: '#f3d97a', bg: '#fff8e1', isDark: false },
luxury_mystery: { primary: '#22223b', secondary: '#4a4e69', accent: '#9a8c98', light: '#c9ada7', bg: '#f2e9e4', isDark: false },
pure_tech_blue: { primary: '#03045e', secondary: '#0077b6', accent: '#00b4d8', light: '#90e0ef', bg: '#caf0f8', isDark: false },
coastal_coral: { primary: '#0081a7', secondary: '#00afb9', accent: '#f07167', light: '#fed9b7', bg: '#fdfcdc', isDark: false },
vibrant_orange_mint: { primary: '#1a1a2e', secondary: '#2ec4b6', accent: '#ff9f1c', light: '#cbf3f0', bg: '#ffffff', isDark: false },
platinum_white_gold: { primary: '#0a0a0a', secondary: '#0070F3', accent: '#D4AF37', light: '#f5f5f5', bg: '#ffffff', isDark: false },
}
const PALETTE_ALIASES: Record<string, string> = {
modern: 'vibrant_tech', corporate: 'business_authority', minimal: 'elegant_fashion',
dark: 'tech_night', midnight: 'luxury_mystery', forest: 'forest_eco', coral: 'coastal_coral',
ocean: 'pure_tech_blue', charcoal: 'platinum_white_gold', teal: 'education_charts',
berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness',
warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech',
}
const THEME_NAMES: Record<string, string> = {
modern_wellness: 'Modern & Wellness', business_authority: 'Business & Authority',
nature_outdoors: 'Nature & Outdoors', vintage_academic: 'Vintage & Academic',
soft_creative: 'Soft & Creative', bohemian: 'Bohemian',
vibrant_tech: 'Vibrant & Tech', craft_artisan: 'Craft & Artisan',
tech_night: 'Tech & Night', education_charts: 'Education & Charts',
forest_eco: 'Forest & Eco', elegant_fashion: 'Elegant & Fashion',
art_food: 'Art & Food', luxury_mystery: 'Luxury & Mystery',
pure_tech_blue: 'Pure Tech Blue', coastal_coral: 'Coastal Coral',
vibrant_orange_mint: 'Vibrant Orange Mint', platinum_white_gold: 'Platinum White Gold',
}
function resolvePalette(spec: PresentationSpec): { palette: Palette; key: string } {
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_')
const key = PALETTE_ALIASES[name] || (PALETTES[name] ? name : 'vibrant_tech')
return { palette: PALETTES[key]!, key }
}
function resolveRadius(style?: string): string {
switch ((style || '').toLowerCase()) {
case 'sharp': return '2px'
case 'rounded': return '16px'
case 'pill': return '24px'
default: return '10px'
}
}
function esc(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function safeHtml(str: string): string {
return str
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/\son\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/\son\w+\s*=\s*[^\s>]*/gi, '')
.replace(/javascript\s*:/gi, '')
}
function buildThemeCSS(p: Palette, radius: string): string {
const text = p.isDark ? '#f0f0f0' : '#1a1a1a'
const muted = p.isDark ? '#999' : '#555'
const heading = p.isDark ? '#ffffff' : p.primary
const bgText = p.isDark ? '#e0e0e0' : '#ffffff'
const shadowAlpha = p.isDark ? '0.35' : '0.08'
const shadowAlphaSm = p.isDark ? '0.25' : '0.05'
return `:root {
--p-primary: ${p.primary};
--p-secondary: ${p.secondary};
--p-accent: ${p.accent};
--p-light: ${p.light};
--p-bg: ${p.bg};
--p-text: ${text};
--p-muted: ${muted};
--p-heading: ${heading};
--p-on-primary: ${bgText};
--p-radius: ${radius};
--p-shadow: 0 8px 32px rgba(0,0,0,${shadowAlpha});
--p-shadow-sm: 0 2px 12px rgba(0,0,0,${shadowAlphaSm});
--p-gradient: linear-gradient(135deg, ${p.primary} 0%, ${p.secondary} 100%);
--p-border: ${p.isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
--p-border-accent: ${p.isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'};
--r-background-color: ${p.bg};
--r-main-font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--r-heading-font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--r-main-font-size: 26px;
--r-heading-font-weight: 700;
--r-heading-color: ${heading};
--r-heading-line-height: 1.15;
--r-heading-letter-spacing: -0.03em;
--r-heading-text-transform: none;
--r-heading-text-shadow: none;
--r-heading1-size: 3.2em;
--r-heading2-size: 2em;
--r-heading3-size: 1.4em;
--r-heading4-size: 1em;
--r-main-color: ${text};
--r-block-margin: 16px;
--r-link-color: ${p.accent};
--r-link-color-hover: ${p.secondary};
--r-selection-background-color: ${p.accent};
--r-selection-color: ${bgText};
}`
}
function buildLayoutCSS(): string {
return `
.reveal-viewport { background: var(--p-bg); }
.reveal {
font-family: var(--r-main-font);
font-weight: 400;
letter-spacing: -0.01em;
color: var(--p-text);
}
.reveal h1, .reveal h2, .reveal h3, .reveal h4 {
font-family: var(--r-heading-font);
font-weight: 700;
text-transform: none;
letter-spacing: -0.03em;
line-height: 1.15;
}
.reveal h1 { font-size: var(--r-heading1-size); }
.reveal h2 { font-size: var(--r-heading2-size); margin-bottom: 0.1em; }
.reveal h3 { font-size: var(--r-heading3-size); }
.reveal section { padding: 40px 60px; text-align: left; }
.reveal a { color: var(--p-accent); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
.reveal a:hover { border-bottom-color: var(--p-accent); }
.reveal .accent-bar {
width: 48px; height: 3px; background: var(--p-accent);
border-radius: 2px; margin-bottom: 1.4rem; flex-shrink: 0;
}
.reveal .accent-bar--center {
margin-left: auto; margin-right: auto;
}
.reveal .accent-bar--wide {
width: 80px; height: 4px;
}
/* ======= DECORATIVE FRAME ======= */
.reveal .frame-top,
.reveal .frame-bottom,
.reveal .frame-left,
.reveal .frame-right {
position: fixed; z-index: 10; background: var(--p-accent); pointer-events: none;
}
.reveal .frame-top { top: 0; left: 0; right: 0; height: 4px; }
.reveal .frame-bottom { bottom: 0; left: 0; right: 0; height: 4px; }
.reveal .frame-left { top: 0; bottom: 0; left: 0; width: 4px; }
.reveal .frame-right { top: 0; bottom: 0; right: 0; width: 4px; }
/* ======= TITLE ======= */
.reveal .s-title {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; text-align: center; padding: 0 80px;
}
.reveal .s-title::before {
content: ''; position: absolute; inset: 0; z-index: -1;
background: var(--p-gradient); opacity: 0.06;
}
.reveal .s-title h1 {
color: var(--p-heading); margin: 0; line-height: 1.1;
}
.reveal .s-title .subtitle {
color: var(--p-muted); font-size: 16pt; margin-top: 1.2rem;
font-weight: 300; letter-spacing: 0.02em;
}
/* ======= SECTION DIVIDER ======= */
.reveal .s-section {
display: flex; flex-direction: column; justify-content: center; align-items: center;
height: 100%; text-align: center;
}
.reveal .s-section::before {
content: ''; position: absolute; inset: 0; z-index: -1;
background: var(--p-light);
}
.reveal .s-section .section-num {
color: var(--p-accent); font-size: 120pt; font-weight: 800;
opacity: 0.12; line-height: 1; margin-bottom: -0.3em;
}
.reveal .s-section h2 {
color: var(--p-heading);
}
.reveal .s-section .subtitle {
color: var(--p-muted); font-size: 14pt; margin-top: 0.5rem;
}
/* ======= TOC ======= */
.reveal .s-toc h2 { color: var(--p-heading); }
.reveal .s-toc .toc-list { display: flex; flex-direction: column; gap: 2px; }
.reveal .s-toc .toc-item {
display: flex; align-items: center; gap: 16px;
padding: 10px 16px; border-radius: var(--p-radius);
transition: background 0.2s;
}
.reveal .s-toc .toc-item:nth-child(odd) { background: var(--p-border); }
.reveal .s-toc .toc-num {
color: var(--p-accent); font-size: 24pt; font-weight: 800;
min-width: 50px; text-align: right; line-height: 1;
}
.reveal .s-toc .toc-label {
color: var(--p-text); font-size: 14pt; padding-left: 12px;
border-left: 3px solid var(--p-secondary);
}
/* ======= CONTENT ======= */
.reveal .s-content h2 { color: var(--p-heading); }
.reveal .s-content ul { list-style: none; padding: 0; margin: 0; }
.reveal .s-content li {
color: var(--p-text); font-size: 14pt; padding: 8px 0;
display: flex; align-items: flex-start; gap: 14px; line-height: 1.5;
}
.reveal .s-content li::before {
content: ''; display: block; width: 8px; height: 8px; min-width: 8px;
background: var(--p-accent); border-radius: 50%; margin-top: 0.5em;
}
/* ======= TWO COLUMN ======= */
.reveal .s-twocol h2 { color: var(--p-heading); }
.reveal .s-twocol .cols {
display: grid; grid-template-columns: 1fr 1fr; gap: 32px;
}
.reveal .s-twocol .col {
background: var(--p-border); border-radius: var(--p-radius);
padding: 20px 24px;
}
.reveal .s-twocol .col--accent {
border-left: 3px solid var(--p-accent);
}
.reveal .s-twocol .col p {
color: var(--p-text); font-size: 13pt; margin: 8px 0; line-height: 1.55;
}
/* ======= CARDS ======= */
.reveal .s-cards h2 { color: var(--p-heading); }
.reveal .s-cards .card-grid { display: grid; gap: 14px; }
.reveal .s-cards .card-grid.g2 { grid-template-columns: repeat(2, 1fr); }
.reveal .s-cards .card-grid.g3 { grid-template-columns: repeat(3, 1fr); }
.reveal .s-cards .card {
border-radius: var(--p-radius); padding: 22px 24px;
display: flex; flex-direction: column; gap: 6px;
border: 1px solid var(--p-border-accent);
background: var(--p-border);
transition: transform 0.2s, box-shadow 0.2s;
}
.reveal .s-cards .card:nth-child(odd) {
background: var(--p-primary); border-color: transparent;
}
.reveal .s-cards .card:nth-child(odd) .card-num { color: rgba(255,255,255,0.25); }
.reveal .s-cards .card:nth-child(odd) .card-text { color: #ffffff; }
.reveal .s-cards .card:nth-child(even) .card-num { color: var(--p-accent); opacity: 0.5; }
.reveal .s-cards .card:nth-child(even) .card-text { color: var(--p-text); }
.reveal .s-cards .card-num {
font-size: 18pt; font-weight: 800; line-height: 1;
}
.reveal .s-cards .card-text {
font-size: 12pt; line-height: 1.5;
}
/* ======= STATS ======= */
.reveal .s-stats h2 { color: var(--p-heading); }
.reveal .s-stats .stat-grid {
display: grid; gap: 28px; margin-top: 1.2rem;
}
.reveal .s-stats .stat {
text-align: center; padding-top: 16px;
border-top: 4px solid var(--p-accent);
}
.reveal .s-stats .stat-value {
color: var(--p-heading); font-size: 44pt; font-weight: 800; line-height: 1;
}
.reveal .s-stats .stat-label {
color: var(--p-muted); font-size: 12pt; margin-top: 8px;
text-transform: uppercase; letter-spacing: 0.06em;
}
/* ======= QUOTE ======= */
.reveal .s-quote {
display: flex; flex-direction: column; justify-content: center;
height: 100%; text-align: left; padding: 0 80px;
}
.reveal .s-quote::before {
content: ''; position: absolute; inset: 0; z-index: -1;
background: var(--p-gradient); opacity: 0.08;
}
.reveal .s-quote .q-mark {
color: var(--p-accent); font-size: 100pt; font-weight: 700;
line-height: 0.4; font-family: 'Playfair Display', Georgia, serif;
opacity: 0.5;
}
.reveal .s-quote blockquote {
color: var(--p-heading); font-size: 22pt; font-style: italic;
line-height: 1.5; margin: 16px 0 24px;
font-family: 'Playfair Display', Georgia, serif;
border: none; box-shadow: none; padding: 0; background: none;
width: 100%; text-align: left;
}
.reveal .s-quote cite {
color: var(--p-accent); font-size: 12pt; font-style: normal;
font-family: var(--r-main-font);
}
/* ======= SUMMARY ======= */
.reveal .s-summary h2 { color: var(--p-heading); }
.reveal .s-summary::before {
content: ''; position: absolute; inset: 0; z-index: -1;
background: var(--p-light);
}
.reveal .s-summary .summary-list { display: flex; flex-direction: column; gap: 4px; }
.reveal .s-summary .summary-item {
display: flex; align-items: center; gap: 14px;
padding: 10px 16px; border-radius: var(--p-radius);
background: var(--p-border);
}
.reveal .s-summary .summary-dot {
width: 10px; height: 10px; min-width: 10px;
background: var(--p-accent); border-radius: 50%;
}
.reveal .s-summary .summary-text {
color: var(--p-text); font-size: 14pt;
}
/* ======= IMAGE ======= */
.reveal .s-image h2 { color: var(--p-heading); }
.reveal .s-image img {
max-width: 85%; max-height: 55vh; border-radius: var(--p-radius);
box-shadow: var(--p-shadow); display: block; margin: 1rem auto 0;
}
.reveal .s-image .caption {
color: var(--p-muted); font-size: 11pt; text-align: center; margin-top: 12px;
}
/* ======= UI ======= */
.reveal .controls button { color: var(--p-accent); }
.reveal .progress span { background: var(--p-accent); }
.reveal .slide-number { color: var(--p-muted); font-size: 10pt; opacity: 0.7; }
/* ======= PRINT ======= */
@page { size: 1219px 686px; margin: 0; }
@media print {
.reveal section { padding: 20px; }
.reveal .frame-top, .reveal .frame-bottom,
.reveal .frame-left, .reveal .frame-right { display: none; }
}`
}
function renderSlide(slide: SlideSpec, index: number): string {
const layout = slide.layout || (index === 0 ? 'title' : 'content')
let html = ''
switch (layout) {
case 'title':
html = `<section class="s-title">
<div class="accent-bar accent-bar--wide accent-bar--center"></div>
<h1>${safeHtml(slide.title)}</h1>
${slide.subtitle ? `<p class="subtitle">${safeHtml(slide.subtitle)}</p>` : ''}
<div class="accent-bar accent-bar--wide accent-bar--center" style="margin-top:1.5rem;"></div>
</section>`
break
case 'toc':
html = `<section class="s-toc">
<h2>${safeHtml(slide.title || 'Sommaire')}</h2>
<div class="accent-bar"></div>
<div class="toc-list">
${slide.content.map((item, i) => `<div class="toc-item">
<span class="toc-num">${String(i + 1).padStart(2, '0')}</span>
<span class="toc-label">${safeHtml(item)}</span>
</div>`).join('\n ')}
</div>
</section>`
break
case 'section':
html = `<section class="s-section">
<span class="section-num">${safeHtml(slide.content[0] || String(index).padStart(2, '0'))}</span>
<h2>${safeHtml(slide.title)}</h2>
${slide.subtitle ? `<p class="subtitle">${safeHtml(slide.subtitle)}</p>` : ''}
<div class="accent-bar accent-bar--center" style="margin-top:1rem;"></div>
</section>`
break
case 'content':
html = `<section class="s-content">
<h2>${safeHtml(slide.title)}</h2>
<div class="accent-bar"></div>
<ul>
${slide.content.map(item => `<li><span>${safeHtml(item)}</span></li>`).join('\n ')}
</ul>
</section>`
break
case 'two-column': {
const mid = Math.ceil(slide.content.length / 2)
const left = slide.content.slice(0, mid)
const right = slide.content.slice(mid)
html = `<section class="s-twocol">
<h2>${safeHtml(slide.title)}</h2>
<div class="accent-bar"></div>
<div class="cols">
<div class="col">
${left.map(item => `<p>${safeHtml(item)}</p>`).join('\n ')}
</div>
<div class="col col--accent">
${right.map(item => `<p>${safeHtml(item)}</p>`).join('\n ')}
</div>
</div>
</section>`
break
}
case 'cards': {
const items = slide.content.slice(0, 6)
const gClass = items.length <= 3 ? `g${items.length}` : 'g2'
html = `<section class="s-cards">
<h2>${safeHtml(slide.title)}</h2>
<div class="accent-bar"></div>
<div class="card-grid ${gClass}">
${items.map((item, i) => `<div class="card">
<span class="card-num">${String(i + 1).padStart(2, '0')}</span>
<p class="card-text">${safeHtml(item)}</p>
</div>`).join('\n ')}
</div>
</section>`
break
}
case 'stats':
html = `<section class="s-stats">
<h2>${safeHtml(slide.title)}</h2>
<div class="accent-bar"></div>
<div class="stat-grid" style="grid-template-columns:repeat(${slide.content.slice(0, 4).length}, 1fr);">
${slide.content.slice(0, 4).map(item => {
const parts = item.split(/[-\u2013\u2014:]/)
const stat = parts[0]?.trim() || item
const label = parts.slice(1).join(':').trim()
return `<div class="stat">
<div class="stat-value">${safeHtml(stat)}</div>
${label ? `<div class="stat-label">${safeHtml(label)}</div>` : ''}
</div>`
}).join('\n ')}
</div>
</section>`
break
case 'quote':
html = `<section class="s-quote">
<div class="q-mark">\u201C</div>
<blockquote>${safeHtml(slide.title)}</blockquote>
${slide.subtitle ? `<cite>\u2014 ${safeHtml(slide.subtitle)}</cite>` : ''}
</section>`
break
case 'summary':
html = `<section class="s-summary">
<h2>${safeHtml(slide.title || 'En r\u00e9sum\u00e9')}</h2>
<div class="accent-bar"></div>
<div class="summary-list">
${slide.content.slice(0, 5).map(item => `<div class="summary-item">
<div class="summary-dot"></div>
<span class="summary-text">${safeHtml(item)}</span>
</div>`).join('\n ')}
</div>
</section>`
break
case 'image':
html = `<section class="s-image">
<h2>${safeHtml(slide.title)}</h2>
<div class="accent-bar"></div>
${slide.imageUrl ? `<img src="${esc(slide.imageUrl)}" alt="${esc(slide.title)}">` : ''}
${slide.content[0] ? `<p class="caption">${safeHtml(slide.content[0])}</p>` : ''}
</section>`
break
default:
html = `<section class="s-content">
<h2>${safeHtml(slide.title)}</h2>
<ul>
${slide.content.map(item => `<li><span>${safeHtml(item)}</span></li>`).join('\n ')}
</ul>
</section>`
}
if (slide.notes) {
html = html.replace('</section>', `<aside class="notes">${esc(slide.notes)}</aside>\n</section>`)
}
return html
}
function buildRevealHtml(spec: PresentationSpec): string {
const { palette, key } = resolvePalette(spec)
const baseTheme = palette.isDark ? 'moon' : 'white'
const radius = resolveRadius(spec.style)
const slidesHtml = spec.slides.map((s, i) => renderSlide(s, i)).join('\n')
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${esc(spec.title)}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/${baseTheme}.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Playfair+Display:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
<style>
${buildThemeCSS(palette, radius)}
${buildLayoutCSS()}
</style>
</head>
<body>
<div class="reveal">
<div class="frame-top"></div>
<div class="frame-bottom"></div>
<div class="frame-left"></div>
<div class="frame-right"></div>
<div class="slides">
${slidesHtml}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/notes/notes.js"></script>
<script>
Reveal.initialize({
hash: true,
slideNumber: 'c/t',
showSlideNumber: 'all',
transition: 'slide',
transitionSpeed: 'default',
backgroundTransition: 'fade',
center: true,
margin: 0.06,
width: 1280,
height: 720,
plugins: [ RevealNotes ],
keyboard: true,
overview: true,
touch: true,
loop: false,
controls: true,
controlsLayout: 'bottom-right',
controlsBackArrows: 'visible',
progress: true,
});
</script>
</body>
</html>`
}
function parseSlidesFromText(text: string): PresentationSpec {
const lines = text.split('\n').filter(l => l.trim().length > 0)
const title = lines[0]?.replace(/^#+\s*/, '').trim() || 'Presentation'
const slides: SlideSpec[] = []
let current: SlideSpec | null = null
for (const line of lines) {
const t = line.trim()
if (t.match(/^#{1,2}\s+/) || t.match(/^slide\s+\d+/i)) {
if (current) slides.push(current)
current = { title: t.replace(/^#{1,2}\s+/, '').replace(/^slide\s+\d+\s*[:-]?\s*/i, ''), content: [] }
} else if (current && (t.match(/^[-*]\s+/) || t.match(/^\d+\.\s+/))) {
current.content.push(t.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, ''))
}
}
if (current) slides.push(current)
if (slides.length === 0) slides.push({ title, content: lines.slice(1, 8).map(l => l.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '')) })
return { title, slides }
}
toolRegistry.register({
name: 'generate_slides',
description: 'Generate a beautiful HTML presentation with Reveal.js and save it for viewing.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: `Generate a beautiful HTML presentation using Reveal.js and save it.
Provide a JSON specification:
{
"title": "Presentation Title",
"theme": "vibrant_tech",
"slides": [
{ "title": "Title", "subtitle": "Subtitle", "content": [], "layout": "title" },
{ "title": "Sommaire", "content": ["Section 1", "Section 2"], "layout": "toc" },
{ "title": "Key Points", "content": ["Point 1", "Point 2"], "layout": "content" },
{ "title": "Features", "content": ["Feature A: desc", "Feature B: desc"], "layout": "cards" },
{ "title": "Metrics", "content": ["99% - Uptime", "50K - Users"], "layout": "stats" },
{ "title": "Introduction", "content": ["01"], "subtitle": "Topic", "layout": "section" },
{ "title": "A great quote.", "subtitle": "- Author", "layout": "quote" },
{ "title": "Summary", "content": ["Point 1", "Point 2"], "layout": "summary" }
]
}
THEMES: modern_wellness, business_authority, nature_outdoors, vintage_academic, soft_creative, bohemian, vibrant_tech, craft_artisan, tech_night, education_charts, forest_eco, elegant_fashion, art_food, luxury_mystery, pure_tech_blue, coastal_coral, vibrant_orange_mint, platinum_white_gold
LAYOUTS: title, toc, content, section, two-column, cards, stats, quote, summary, image
RULES:
- First slide MUST be "title"
- Second slide should be "toc"
- Use "section" for dividers (content[0]=section number like "01")
- Use "cards" for feature lists (3-6 items)
- Use "stats" for numbers (format: "NUMBER - LABEL")
- Use "quote" for quotes (title=quote, subtitle=attribution)
- Use "summary" for closing summary
- Use "two-column" for comparisons
- 5-12 slides, vary layouts, no repeats consecutively`,
inputSchema: z.object({
title: z.string().describe('Title for the presentation'),
slides: z.string().describe('JSON presentation specification'),
}),
execute: async ({ title, slides }) => {
try {
console.log('[Slides Tool] INPUT title:', title)
console.log('[Slides Tool] INPUT slides (first 500 chars):', slides?.substring(0, 500))
let spec: PresentationSpec
try {
const parsed = JSON.parse(slides)
console.log('[Slides Tool] JSON parsed OK. slides count:', parsed.slides?.length, 'theme:', parsed.theme, 'title:', parsed.title)
if (parsed.slides && Array.isArray(parsed.slides) && parsed.slides.length > 0) {
spec = {
title: parsed.title || title || 'Presentation',
theme: parsed.theme || 'vibrant_tech',
style: parsed.style,
slides: parsed.slides.map((s: any) => ({
title: String(s.title || '').substring(0, 200),
subtitle: s.subtitle ? String(s.subtitle).substring(0, 300) : undefined,
content: Array.isArray(s.content) ? s.content.map((c: any) => String(c).substring(0, 500)).slice(0, 12) : [],
layout: ['title', 'content', 'section', 'two-column', 'cards', 'stats', 'quote', 'toc', 'summary', 'image'].includes(s.layout) ? s.layout : undefined,
imageUrl: s.imageUrl ? String(s.imageUrl).substring(0, 500) : undefined,
notes: s.notes ? String(s.notes).substring(0, 1000) : undefined,
})),
}
} else {
console.log('[Slides Tool] No slides array in JSON, falling back to text parse')
spec = parseSlidesFromText(slides)
}
} catch (parseErr) {
console.log('[Slides Tool] JSON parse failed, falling back to text parse:', parseErr)
spec = parseSlidesFromText(slides)
}
console.log('[Slides Tool] Spec:', JSON.stringify({ title: spec.title, theme: spec.theme, style: spec.style, slideCount: spec.slides.length, layouts: spec.slides.map(s => s.layout) }))
if (spec.slides.length === 0) {
console.log('[Slides Tool] ERROR: No slides provided')
return { success: false, error: 'No slides provided' }
}
const html = buildRevealHtml(spec)
console.log('[Slides Tool] HTML generated. Length:', html.length, '| Start:', html.substring(0, 120))
const canvas = await prisma.canvas.create({
data: {
name: title || spec.title || 'Presentation',
data: JSON.stringify({
type: 'slides',
title: spec.title,
theme: spec.theme,
slideCount: spec.slides.length,
html,
}),
userId: ctx.userId,
},
})
console.log('[Slides Tool] Canvas created:', canvas.id, canvas.name)
return {
success: true, canvasId: canvas.id, canvasName: canvas.name,
slideCount: spec.slides.length, theme: spec.theme,
message: `Presentation created with ${spec.slides.length} slides. Open in browser to view.`,
}
} catch (e: any) {
console.error('[Slides Tool] FATAL:', e)
return { success: false, error: `Failed: ${e.message}` }
}
},
}),
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff