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

@@ -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)