feat: add slides generation tool with multiple slide types
Some checks failed
CI / Lint, Test & Build (push) Failing after 17s
CI / Deploy production (on server) (push) Has been skipped

- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types
- Chart types: bar, horizontal-bar, line, donut, radar
- Integrate with agent executor and canvas system
- Add multilingual support (en/fr)
- Various UI improvements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-22 17:18:48 +00:00
parent 0f6b9509da
commit 5728452b4a
68 changed files with 6990 additions and 2584 deletions

View File

@@ -4,17 +4,23 @@ import dynamic from 'next/dynamic'
import { useState, useEffect, useRef, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Download, Presentation } from 'lucide-react'
import { Download, Presentation, ExternalLink, Maximize2, ChevronLeft, ChevronRight } from 'lucide-react'
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css'
import type { PresentationSpec } from '@/lib/types/presentation'
const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
{ ssr: false }
)
const SlidesRenderer = dynamic(
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })),
{ ssr: false, loading: () => <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">Chargement des slides</div> }
)
interface CanvasBoardProps {
initialData?: string
canvasId?: string
@@ -22,32 +28,37 @@ interface CanvasBoardProps {
}
type PptxPayload = { type: string; filename: string; base64: string; slideCount?: number; theme?: string }
type SlidesPayload = { type: 'slides'; html: string; title: string; theme?: string; style?: string; slideCount?: number; spec?: PresentationSpec }
function parseCanvasScene(initialData?: string): {
slides: SlidesPayload | null
pptx: PptxPayload | null
elements: readonly ExcalidrawElement[]
files: BinaryFiles
} {
if (!initialData) {
return { pptx: null, elements: [], files: {} }
return { slides: null, pptx: null, elements: [], files: {} }
}
try {
const parsed = JSON.parse(initialData)
if (parsed && parsed.type === 'slides' && parsed.html) {
return { slides: parsed as SlidesPayload, pptx: null, elements: [], files: {} }
}
if (parsed && parsed.type === 'pptx' && parsed.base64) {
return { pptx: parsed as PptxPayload, elements: [], files: {} }
return { slides: null, pptx: parsed as PptxPayload, elements: [], files: {} }
}
if (parsed && Array.isArray(parsed)) {
return { pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
return { slides: null, pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
}
if (parsed && parsed.elements) {
const files: BinaryFiles =
parsed.files && typeof parsed.files === 'object' ? parsed.files : {}
return { pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
return { slides: null, pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
}
} catch (e) {
console.error('[CanvasBoard] Failed to parse canvas data:', e)
}
return { pptx: null, elements: [], files: {} }
return { slides: null, pptx: null, elements: [], files: {} }
}
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
@@ -105,6 +116,153 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
)
}
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [currentSlide, setCurrentSlide] = useState(1)
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
const totalSlides = data.slideCount || 1
// Hide the global AI floating button while slides are displayed
useEffect(() => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
return () => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false }))
}
}, [])
// Listen for slide-change events from the iframe via postMessage
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data?.type === 'slideChange' && typeof e.data.current === 'number') {
setCurrentSlide(e.data.current)
}
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [])
const navigate = (dir: number) => {
const iframe = iframeRef.current
if (!iframe) return
// Direct contentWindow access (works with allow-same-origin)
try {
const win = iframe.contentWindow as any
if (typeof win?.changeSlide === 'function') {
win.changeSlide(dir)
return
}
} catch (_) {}
// Fallback: postMessage
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
}
const openFullscreen = () => {
const blob = new Blob([data.html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
window.open(url, '_blank')
setTimeout(() => URL.revokeObjectURL(url), 10000)
}
const downloadHtml = () => {
const blob = new Blob([data.html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-z0-9]/gi, '-').toLowerCase() || 'presentation'}.html`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 5000)
}
return (
<div className="absolute inset-0 flex flex-col bg-zinc-950">
{/* Toolbar */}
<div className="shrink-0 h-11 flex items-center justify-between px-4 bg-zinc-900/80 border-b border-white/10 backdrop-blur-sm z-10">
<div className="flex items-center gap-2.5 text-white/70 min-w-0">
<Presentation className="w-4 h-4 shrink-0 text-white/50" />
<span className="text-sm font-medium truncate">{name}</span>
{totalSlides > 1 && (
<span className="text-xs bg-white/10 px-2 py-0.5 rounded-full shrink-0 tabular-nums">
{currentSlide} / {totalSlides}
</span>
)}
</div>
<div className="flex items-center gap-2">
{canvasId && (
<a
href={`/api/canvas/slides/pptx?id=${canvasId}`}
download
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Exporter en PowerPoint"
>
<Download className="w-3.5 h-3.5" />
Export PPTX
</a>
)}
<button
onClick={downloadHtml}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Télécharger le HTML"
>
<Download className="w-3.5 h-3.5" />
Export HTML
</button>
<button
onClick={openFullscreen}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Ouvrir en plein écran"
>
<Maximize2 className="w-3.5 h-3.5" />
Plein écran
</button>
</div>
</div>
{/* Slides: React renderer (legacy spec) or iframe (new HTML) */}
<div className="flex-1 relative overflow-hidden group">
{data.spec ? (
<SlidesRenderer spec={data.spec} />
) : (
<>
{/* Loading overlay — visible until iframe fires onLoad */}
{!isLoaded && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
<span className="text-xs text-white/30">Chargement de la présentation</span>
</div>
)}
<iframe
ref={iframeRef}
srcDoc={data.html}
className="absolute inset-0 w-full h-full border-0"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
title={name}
allow="fullscreen"
onLoad={() => setIsLoaded(true)}
/>
{/* Prev button */}
<button
onClick={() => navigate(-1)}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label="Slide précédent"
>
<ChevronLeft className="w-5 h-5" />
</button>
{/* Next button */}
<button
onClick={() => navigate(1)}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label="Slide suivant"
>
<ChevronRight className="w-5 h-5" />
</button>
</>
)}
</div>
</div>
)
}
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const [isDarkMode, setIsDarkMode] = useState(false)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
@@ -173,6 +331,10 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
}, 2000)
}
if (scene.slides) {
return <SlidesViewer data={scene.slides} name={name} canvasId={localId} />
}
if (scene.pptx) {
return <PptxViewer data={scene.pptx} name={name} />
}