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
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:
86
memento-note/app/api/agents/run-for-note/route.ts
Normal file
86
memento-note/app/api/agents/run-for-note/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user