feat(ai-note): ajouter boutons Générer slides/diagramme dans le panneau IA
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s

- Nouveau endpoint POST /api/agents/run-for-note : crée un agent one-shot
  (slide-generator ou excalidraw-generator) avec la note courante comme source
  et l'exécute immédiatement
- ContextualAIChat : prop noteId + section "Générer depuis cette note" avec
  deux boutons gradient (violet=slides, cyan=diagramme), spinner pendant la
  génération, bouton de téléchargement .pptx ou lien "Ouvrir dans le Lab"
  après succès
- note-editor.tsx : passage de note.id à ContextualAIChat
- i18n fr/en : nouvelles clés ai.generate.*

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-05-05 21:07:43 +00:00
parent e7f28abccc
commit d1e08f64c8
5 changed files with 237 additions and 8 deletions

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
type GenerateType = 'slide-generator' | 'excalidraw-generator'
const TYPE_DEFAULTS: Record<GenerateType, {
role: string
tools: string[]
maxSteps: number
}> = {
'slide-generator': {
role: 'Crée une présentation PowerPoint professionnelle et visuelle à partir du contenu de la note fournie.',
tools: JSON.stringify(['note_search', 'note_read', 'generate_pptx']),
maxSteps: 8,
},
'excalidraw-generator': {
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
tools: JSON.stringify(['note_search', 'note_read', 'generate_excalidraw']),
maxSteps: 6,
},
}
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
}
const userId = session.user.id
const body = await req.json()
const { noteId, type, theme, style } = body as {
noteId: string
type: GenerateType
theme?: string
style?: string
}
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
}
// Verify note belongs to user
const note = await prisma.note.findFirst({
where: { id: noteId, userId },
select: { id: true, title: true, notebookId: true },
})
if (!note) {
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
}
const defaults = TYPE_DEFAULTS[type]
const agentName = type === 'slide-generator'
? `Slides — ${(note.title || 'Note').substring(0, 40)}`
: `Diagramme — ${(note.title || 'Note').substring(0, 40)}`
// Create a dedicated one-shot agent for this note
const agent = await prisma.agent.create({
data: {
name: agentName,
type,
role: defaults.role,
tools: defaults.tools,
maxSteps: defaults.maxSteps,
frequency: 'manual',
isEnabled: true,
sourceNoteIds: JSON.stringify([noteId]),
targetNotebookId: note.notebookId ?? undefined,
slideTheme: theme ?? 'vibrant_tech',
slideStyle: style ?? 'soft',
userId,
},
})
try {
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(agent.id, userId)
return NextResponse.json(result)
} catch (err) {
console.error('[run-for-note] executeAgent error:', err)
return NextResponse.json(
{ success: false, error: err instanceof Error ? err.message : 'Erreur inconnue' },
{ status: 500 },
)
}
}

View File

@@ -13,6 +13,7 @@ import {
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
GitMerge, PlusCircle, Eye, Code, Languages,
Presentation, PenTool, ExternalLink,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
@@ -71,11 +72,18 @@ const ACTION_IDS = [
// ── Types ─────────────────────────────────────────────────────────────────────
interface GenerateResult {
type: 'slides' | 'diagram'
canvasId?: string
noteId?: string
}
interface ContextualAIChatProps {
onClose: () => void
noteTitle?: string
noteContent?: string
noteImages?: string[]
noteId?: string
/** Called when an action result should be injected into the note */
onApplyToNote?: (newContent: string) => void
/** Called when the user wants to undo the last injected action */
@@ -95,6 +103,7 @@ export function ContextualAIChat({
noteTitle,
noteContent,
noteImages,
noteId,
onApplyToNote,
onUndoLastAction,
lastActionApplied = false,
@@ -116,6 +125,10 @@ export function ContextualAIChat({
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
const [showLangPicker, setShowLangPicker] = useState(false)
const [translateTarget, setTranslateTarget] = useState('')
// Generate slides / diagram state
const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null)
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
const [customLangInput, setCustomLangInput] = useState('')
// Resource tab state
@@ -249,6 +262,41 @@ export function ContextualAIChat({
const handleDiscardPreview = () => setActionPreview(null)
// ── Generate slides / diagram ────────────────────────────────────────────────
const handleGenerate = async (type: 'slides' | 'diagram') => {
if (!noteId) {
toast.error(t('ai.generate.noNoteId') || 'Note non sauvegardée')
return
}
setGenerateLoading(type)
setGenerateResult(null)
try {
const res = await fetch('/api/agents/run-for-note', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
noteId,
type: type === 'slides' ? 'slide-generator' : 'excalidraw-generator',
}),
})
const data = await res.json()
if (!res.ok || !data.success) {
toast.error(data.error || t('ai.generate.error') || 'Erreur lors de la génération')
return
}
setGenerateResult({ type, canvasId: data.canvasId, noteId: data.noteId })
toast.success(type === 'slides'
? (t('ai.generate.slidesReady') || 'Présentation générée !')
: (t('ai.generate.diagramReady') || 'Diagramme généré !')
)
} catch {
toast.error(t('ai.generate.error') || 'Erreur lors de la génération')
} finally {
setGenerateLoading(null)
}
}
// ── Resource tab handlers ────────────────────────────────────────────────────
const handleScrapeUrl = async () => {
@@ -883,6 +931,76 @@ export function ContextualAIChat({
})
)}
{/* ── Generate slides / diagram ─────────────────────── */}
{noteId && (
<div className="mt-1">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5 mt-4">
{t('ai.generate.sectionLabel') || 'Générer depuis cette note'}
</p>
{/* Slides button */}
<button
onClick={() => handleGenerate('slides')}
disabled={!!generateLoading || !!actionLoading}
className="w-full flex items-center gap-3 rounded-xl border-2 border-purple-200 dark:border-purple-800 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/40 dark:to-pink-950/40 px-4 py-3 text-sm font-medium text-purple-700 dark:text-purple-300 hover:from-purple-100 hover:to-pink-100 dark:hover:from-purple-900/50 dark:hover:to-pink-900/50 transition-all text-left disabled:opacity-60 mb-2"
>
{generateLoading === 'slides'
? <Loader2 className="h-4 w-4 shrink-0 animate-spin" />
: <Presentation className="h-4 w-4 shrink-0" />
}
<div className="flex flex-col flex-1 min-w-0">
<span>{t('ai.generate.slides') || 'Générer une présentation'}</span>
{generateLoading === 'slides' && (
<span className="text-[10px] text-purple-500">{t('ai.generate.loading') || 'Génération en cours…'}</span>
)}
</div>
</button>
{/* Slides result */}
{generateResult?.type === 'slides' && generateResult.canvasId && (
<a
href={`/api/canvas/download?id=${generateResult.canvasId}`}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all mb-2"
>
<Download className="h-4 w-4 shrink-0" />
{t('ai.generate.downloadPptx') || 'Télécharger le .pptx'}
<ExternalLink className="h-3 w-3 ml-auto opacity-60" />
</a>
)}
{/* Diagram button */}
<button
onClick={() => handleGenerate('diagram')}
disabled={!!generateLoading || !!actionLoading}
className="w-full flex items-center gap-3 rounded-xl border-2 border-cyan-200 dark:border-cyan-800 bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-950/40 dark:to-blue-950/40 px-4 py-3 text-sm font-medium text-cyan-700 dark:text-cyan-300 hover:from-cyan-100 hover:to-blue-100 dark:hover:from-cyan-900/50 dark:hover:to-blue-900/50 transition-all text-left disabled:opacity-60"
>
{generateLoading === 'diagram'
? <Loader2 className="h-4 w-4 shrink-0 animate-spin" />
: <PenTool className="h-4 w-4 shrink-0" />
}
<div className="flex flex-col flex-1 min-w-0">
<span>{t('ai.generate.diagram') || 'Générer un diagramme'}</span>
{generateLoading === 'diagram' && (
<span className="text-[10px] text-cyan-500">{t('ai.generate.loading') || 'Génération en cours…'}</span>
)}
</div>
</button>
{/* Diagram result */}
{generateResult?.type === 'diagram' && generateResult.canvasId && (
<a
href={`/lab?canvas=${generateResult.canvasId}`}
className="mt-2 w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all"
>
<ExternalLink className="h-4 w-4 shrink-0" />
{t('ai.generate.openDiagram') || 'Ouvrir dans le Lab'}
</a>
)}
</div>
)}
{/* Undo last action shortcut */}
{lastActionApplied && onUndoLastAction && (
<button

View File

@@ -646,7 +646,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
<Dialog open={true} onOpenChange={onClose}>
<DialogContent
className={cn(
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch',
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch rounded-lg',
colorClasses.bg
)}
>
@@ -875,18 +875,18 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{!readOnly && (
<>
{/* Reminder */}
<Button variant="ghost" size="icon" className={cn('h-8 w-8', currentReminder && 'text-primary')}
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', currentReminder && 'text-primary')}
onClick={() => setShowReminderDialog(true)} title={t('notes.setReminder')}>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image */}
<Button variant="ghost" size="icon" className="h-8 w-8"
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link */}
<Button variant="ghost" size="icon" className="h-8 w-8"
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => setShowLinkDialog(true)} title={t('notes.addLink')}>
<LinkIcon className="h-4 w-4" />
</Button>
@@ -894,7 +894,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
<NoteTypeSelector value={noteType} onChange={(newType) => { setNoteType(newType); if (newType !== 'markdown') setShowMarkdownPreview(false) }} />
{noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8"
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
<Eye className="h-4 w-4" />
@@ -904,7 +904,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* AI Copilot */}
{noteType !== 'checklist' && aiAssistantEnabled && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)} title="IA Note">
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">IA Note</span>
@@ -914,7 +914,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* Size Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeSize')}>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
<Maximize2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -934,7 +934,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title={t('notes.changeColor')}>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -1023,6 +1023,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
noteTitle={title}
noteContent={content}
noteImages={allImages}
noteId={note.id}
onApplyToNote={(newContent) => {
setPreviousContentForCopilot(content)
setContent(newContent)

View File

@@ -436,6 +436,18 @@
"translate": "Translate",
"explain": "Explain"
},
"generate": {
"sectionLabel": "Generate from this note",
"slides": "Generate a presentation",
"diagram": "Generate a diagram",
"loading": "Generating…",
"slidesReady": "Presentation generated!",
"diagramReady": "Diagram generated!",
"downloadPptx": "Download .pptx",
"openDiagram": "Open in Lab",
"error": "Error during generation",
"noNoteId": "Save the note first"
},
"openAssistant": "Open AI Assistant",
"poweredByMomento": "Powered by Momento AI",
"welcomeMsg": "Hello! I'm your AI assistant. How can I help you with your notes today? I can help refine tone, expand messaging, or summarize content.",

View File

@@ -436,6 +436,18 @@
"translate": "Traduire",
"explain": "Expliquer"
},
"generate": {
"sectionLabel": "Générer depuis cette note",
"slides": "Générer une présentation",
"diagram": "Générer un diagramme",
"loading": "Génération en cours…",
"slidesReady": "Présentation générée !",
"diagramReady": "Diagramme généré !",
"downloadPptx": "Télécharger le .pptx",
"openDiagram": "Ouvrir dans le Lab",
"error": "Erreur lors de la génération",
"noNoteId": "Enregistrez d'abord la note"
},
"openAssistant": "Ouvrir IA Note",
"poweredByMomento": "Propulsé par Momento AI",
"welcomeMsg": "Bonjour ! Je suis votre assistant IA. Comment puis-je vous aider avec vos notes ? Je peux affiner le ton, développer un message ou résumer le contenu.",