feat: add slides generation tool with multiple slide types
- 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:
@@ -50,6 +50,7 @@ import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
|
||||
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
|
||||
import { scrapePageText } from '@/app/actions/scrape'
|
||||
import { PersonasPanel } from '@/components/personas-panel'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -210,11 +211,13 @@ export function ContextualAIChat({
|
||||
|
||||
// Generate slides / diagram state
|
||||
const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null)
|
||||
const [generateProgress, setGenerateProgress] = useState(0)
|
||||
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
|
||||
const [customLangInput, setCustomLangInput] = useState('')
|
||||
// Generation options
|
||||
const [slideTheme, setSlideTheme] = useState('architectural_mono')
|
||||
const [slideTheme, setSlideTheme] = useState('auto')
|
||||
const [slideStyle, setSlideStyle] = useState('professional')
|
||||
const [slideTemplate, setSlideTemplate] = useState('auto')
|
||||
const [diagramType, setDiagramType] = useState('logic_flow')
|
||||
const [diagramStyle, setDiagramStyle] = useState('polished')
|
||||
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
|
||||
@@ -358,6 +361,24 @@ export function ContextualAIChat({
|
||||
// ── Generate slides / diagram ────────────────────────────────────────────────
|
||||
|
||||
const generatePollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const generateProgressRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Fake progress bar: fast up to 30%, then slows, caps at 90% until done
|
||||
const startProgressBar = () => {
|
||||
setGenerateProgress(0)
|
||||
let current = 0
|
||||
if (generateProgressRef.current) clearInterval(generateProgressRef.current)
|
||||
generateProgressRef.current = setInterval(() => {
|
||||
current += current < 30 ? 3 : current < 60 ? 1.2 : current < 80 ? 0.4 : 0.1
|
||||
if (current >= 90) { clearInterval(generateProgressRef.current!); current = 90 }
|
||||
setGenerateProgress(Math.min(current, 90))
|
||||
}, 300)
|
||||
}
|
||||
const finishProgressBar = () => {
|
||||
if (generateProgressRef.current) clearInterval(generateProgressRef.current)
|
||||
setGenerateProgress(100)
|
||||
setTimeout(() => setGenerateProgress(0), 600)
|
||||
}
|
||||
|
||||
const handleGenerate = async (type: 'slides' | 'diagram') => {
|
||||
if (!noteId) {
|
||||
@@ -366,6 +387,7 @@ export function ContextualAIChat({
|
||||
}
|
||||
setGenerateLoading(type)
|
||||
setGenerateResult(null)
|
||||
startProgressBar()
|
||||
|
||||
const toastId = mToast.loading(
|
||||
type === 'slides' ? t('ai.generateSlidesLoading') : t('ai.generateDiagramLoading'),
|
||||
@@ -382,6 +404,7 @@ export function ContextualAIChat({
|
||||
theme: type === 'slides' ? slideTheme : diagramType,
|
||||
style: type === 'slides' ? slideStyle : diagramStyle,
|
||||
language: language === 'fr' ? 'French' : 'English',
|
||||
template: type === 'slides' ? slideTemplate : undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
@@ -394,7 +417,18 @@ export function ContextualAIChat({
|
||||
const { agentId } = data as { agentId: string }
|
||||
|
||||
if (generatePollRef.current) clearInterval(generatePollRef.current)
|
||||
let pollCount = 0
|
||||
const MAX_POLLS = 200 // 200 × 3s = 10 min safety timeout
|
||||
generatePollRef.current = setInterval(async () => {
|
||||
pollCount++
|
||||
if (pollCount > MAX_POLLS) {
|
||||
clearInterval(generatePollRef.current!)
|
||||
generatePollRef.current = null
|
||||
finishProgressBar()
|
||||
setGenerateLoading(null)
|
||||
mToast.error(t('ai.errorShort'), { id: toastId })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const pollRes = await fetch(`/api/agents/run-for-note?agentId=${agentId}`)
|
||||
const poll = await pollRes.json()
|
||||
@@ -402,12 +436,14 @@ export function ContextualAIChat({
|
||||
if (poll.status === 'success') {
|
||||
clearInterval(generatePollRef.current!)
|
||||
generatePollRef.current = null
|
||||
finishProgressBar()
|
||||
setGenerateLoading(null)
|
||||
setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId })
|
||||
mToast.success(t('ai.readyToast'), { id: toastId })
|
||||
} else if (poll.status === 'failure') {
|
||||
clearInterval(generatePollRef.current!)
|
||||
generatePollRef.current = null
|
||||
finishProgressBar()
|
||||
setGenerateLoading(null)
|
||||
mToast.error(poll.error || t('ai.errorShort'), { id: toastId })
|
||||
}
|
||||
@@ -415,6 +451,7 @@ export function ContextualAIChat({
|
||||
}, 3000)
|
||||
} catch {
|
||||
mToast.error(t('ai.errorShort'), { id: toastId })
|
||||
finishProgressBar()
|
||||
setGenerateLoading(null)
|
||||
}
|
||||
}
|
||||
@@ -731,7 +768,7 @@ export function ContextualAIChat({
|
||||
<div className="w-12 h-12 rounded-full border border-dashed border-concrete/10 flex items-center justify-center">
|
||||
<MessageSquare size={18} />
|
||||
</div>
|
||||
<p className="text-[11px] italic leading-relaxed px-12">{t('ai.welcomeMsg')}</p>
|
||||
<p className="text-[11px] italic leading-relaxed px-6">{t('ai.welcomeMsg')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -980,9 +1017,20 @@ export function ContextualAIChat({
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.theme')}</span>
|
||||
<select value={slideTheme} onChange={e => setSlideTheme(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="architectural_mono">{t('ai.generate.themeArchitecturalMono')}</option>
|
||||
<option value="vibrant_tech">{t('ai.generate.themeVibrantTech')}</option>
|
||||
<option value="minimal_silk">{t('ai.generate.themeMinimalSilk')}</option>
|
||||
<option value="auto">{t('ai.generate.themeAuto')}</option>
|
||||
<option value="Architectural SaaS">Architectural SaaS — SaaS, Produit</option>
|
||||
<option value="Midnight Cathedral">Midnight Cathedral — Finance, Luxe</option>
|
||||
<option value="Aurora Borealis">Aurora Borealis — Tech, IA</option>
|
||||
<option value="Tokyo Neon">Tokyo Neon — Gaming</option>
|
||||
<option value="Sunlit Gallery">Sunlit Gallery — Art, Culture</option>
|
||||
<option value="Clinical Precision">Clinical Precision — Santé, Science</option>
|
||||
<option value="Venture Pitch">Venture Pitch — Startup</option>
|
||||
<option value="Forest Floor">Forest Floor — ESG, Nature</option>
|
||||
<option value="Steel & Glass">Steel & Glass — Architecture</option>
|
||||
<option value="Cyberpunk Terminal">Cyberpunk Terminal — Dev, Cyber</option>
|
||||
<option value="Editorial Ink">Editorial Ink — Journalisme</option>
|
||||
<option value="Coastal Morning">Coastal Morning — Éducation</option>
|
||||
<option value="Paper Studio">Paper Studio — Research</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -994,10 +1042,36 @@ export function ContextualAIChat({
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
|
||||
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="auto">{t('ai.generate.templateAuto')}</option>
|
||||
<option value="board-update">{t('ai.generate.templateBoard')}</option>
|
||||
<option value="project-status">{t('ai.generate.templateProject')}</option>
|
||||
<option value="strategy-review">{t('ai.generate.templateStrategy')}</option>
|
||||
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading} className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50">
|
||||
{generateLoading === 'slides' ? <Loader2 size={14} className="animate-spin" /> : <><Presentation size={14} className="opacity-80" /> {t('ai.generating')}</>}
|
||||
</button>
|
||||
|
||||
{/* Progress bar — visible only during slides generation */}
|
||||
{generateLoading === 'slides' && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[9px] text-foreground/40">
|
||||
<span className="uppercase tracking-widest">⏳ Génération en cours…</span>
|
||||
<span className="font-mono tabular-nums">{Math.round(generateProgress)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 rounded-full bg-foreground/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-accent transition-all duration-300 ease-out"
|
||||
style={{ width: `${generateProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generateResult?.type === 'slides' && generateResult.canvasId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -1018,35 +1092,15 @@ export function ContextualAIChat({
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/canvas?id=${generateResult.canvasId}`)
|
||||
const data = await res.json()
|
||||
if (!data.canvas?.data) throw new Error('No data')
|
||||
const parsed = JSON.parse(data.canvas.data)
|
||||
if (!parsed.base64) throw new Error('No base64')
|
||||
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 blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = parsed.filename || `${data.canvas.name || 'presentation'}.pptx`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
mToast.error(t('ai.downloadFailedToast'))
|
||||
}
|
||||
}}
|
||||
<a
|
||||
href={`/lab?id=${generateResult.canvasId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-2.5 bg-brand-accent text-white rounded-lg text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 transition-opacity shadow-sm"
|
||||
>
|
||||
<Download size={13} />
|
||||
{t('ai.pptxDownloadButton')}
|
||||
</button>
|
||||
<Presentation size={13} />
|
||||
{t('ai.viewSlidesButton')}
|
||||
</a>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -1129,6 +1183,8 @@ export function ContextualAIChat({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Personas IA ── */}
|
||||
<PersonasPanel noteTitle={noteTitle} noteContent={noteContent} />
|
||||
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user