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:
272
memento-note/components/personas-panel.tsx
Normal file
272
memento-note/components/personas-panel.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import {
|
||||
Wrench, TrendingUp, Users, ThumbsDown, Sun,
|
||||
Loader2, ChevronDown, ChevronUp, X, Copy, CheckCircle, Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
|
||||
// ── Personas definition ──────────────────────────────────────────────────────
|
||||
|
||||
const PERSONAS = [
|
||||
{
|
||||
id: 'engineer',
|
||||
label: 'Ingénieur',
|
||||
sublabel: 'Faisabilité · Risques tech',
|
||||
icon: Wrench,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-50 dark:bg-blue-950/30',
|
||||
border: 'border-blue-200 dark:border-blue-800/50',
|
||||
hoverBorder: 'hover:border-blue-400/60',
|
||||
activeBg: 'bg-blue-600',
|
||||
},
|
||||
{
|
||||
id: 'financial',
|
||||
label: 'Financier',
|
||||
sublabel: 'ROI · Coûts · Viabilité',
|
||||
icon: TrendingUp,
|
||||
color: 'text-emerald-600',
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-950/30',
|
||||
border: 'border-emerald-200 dark:border-emerald-800/50',
|
||||
hoverBorder: 'hover:border-emerald-400/60',
|
||||
activeBg: 'bg-emerald-600',
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
label: 'Client',
|
||||
sublabel: 'UX · Valeur · Besoins',
|
||||
icon: Users,
|
||||
color: 'text-violet-600',
|
||||
bg: 'bg-violet-50 dark:bg-violet-950/30',
|
||||
border: 'border-violet-200 dark:border-violet-800/50',
|
||||
hoverBorder: 'hover:border-violet-400/60',
|
||||
activeBg: 'bg-violet-600',
|
||||
},
|
||||
{
|
||||
id: 'skeptic',
|
||||
label: 'Sceptique',
|
||||
sublabel: 'Points faibles · Angles morts',
|
||||
icon: ThumbsDown,
|
||||
color: 'text-rose-600',
|
||||
bg: 'bg-rose-50 dark:bg-rose-950/30',
|
||||
border: 'border-rose-200 dark:border-rose-800/50',
|
||||
hoverBorder: 'hover:border-rose-400/60',
|
||||
activeBg: 'bg-rose-600',
|
||||
},
|
||||
{
|
||||
id: 'optimist',
|
||||
label: 'Optimiste',
|
||||
sublabel: 'Opportunités · Potentiel',
|
||||
icon: Sun,
|
||||
color: 'text-amber-600',
|
||||
bg: 'bg-amber-50 dark:bg-amber-950/30',
|
||||
border: 'border-amber-200 dark:border-amber-800/50',
|
||||
hoverBorder: 'hover:border-amber-400/60',
|
||||
activeBg: 'bg-amber-600',
|
||||
},
|
||||
] as const
|
||||
|
||||
type PersonaId = (typeof PERSONAS)[number]['id']
|
||||
|
||||
interface PersonaResult {
|
||||
personaId: PersonaId
|
||||
personaLabel: string
|
||||
analysis: string
|
||||
}
|
||||
|
||||
interface PersonasPanelProps {
|
||||
noteTitle?: string
|
||||
noteContent?: string
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
|
||||
const [loadingId, setLoadingId] = useState<PersonaId | null>(null)
|
||||
const [results, setResults] = useState<Map<PersonaId, PersonaResult>>(new Map())
|
||||
const [expanded, setExpanded] = useState<PersonaId | null>(null)
|
||||
const [copied, setCopied] = useState<PersonaId | null>(null)
|
||||
|
||||
const handlePersona = async (personaId: PersonaId) => {
|
||||
if (loadingId) return
|
||||
|
||||
// Toggle off if already showing
|
||||
if (expanded === personaId && results.has(personaId)) {
|
||||
setExpanded(null)
|
||||
return
|
||||
}
|
||||
|
||||
// If we already have the result, just expand it
|
||||
if (results.has(personaId)) {
|
||||
setExpanded(personaId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!noteContent || noteContent.replace(/<[^>]+>/g, '').trim().split(/\s+/).length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingId(personaId)
|
||||
try {
|
||||
const res = await fetch('/api/ai/personas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Erreur')
|
||||
setResults(prev => new Map(prev).set(personaId, data))
|
||||
setExpanded(personaId)
|
||||
} catch {
|
||||
// silent error — user can retry
|
||||
} finally {
|
||||
setLoadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (personaId: PersonaId) => {
|
||||
const result = results.get(personaId)
|
||||
if (!result) return
|
||||
navigator.clipboard.writeText(result.analysis).catch(() => {})
|
||||
setCopied(personaId)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
}
|
||||
|
||||
const handleClear = (personaId: PersonaId) => {
|
||||
setResults(prev => {
|
||||
const next = new Map(prev)
|
||||
next.delete(personaId)
|
||||
return next
|
||||
})
|
||||
if (expanded === personaId) setExpanded(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap flex items-center gap-1.5">
|
||||
<Sparkles size={10} className="text-brand-accent" />
|
||||
Prismes IA
|
||||
</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-concrete/60 leading-relaxed text-center px-2">
|
||||
Réinterprétez vos notes à travers différents prismes d'analyse.
|
||||
</p>
|
||||
|
||||
{/* Persona buttons */}
|
||||
<div className="space-y-2">
|
||||
{PERSONAS.map((persona) => {
|
||||
const Icon = persona.icon
|
||||
const isLoading = loadingId === persona.id
|
||||
const hasResult = results.has(persona.id)
|
||||
const isExpanded = expanded === persona.id
|
||||
const result = results.get(persona.id)
|
||||
|
||||
return (
|
||||
<div key={persona.id} className="rounded-xl overflow-hidden border border-border/60">
|
||||
{/* Persona row */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePersona(persona.id)}
|
||||
disabled={!!loadingId && !isLoading}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 p-3.5 transition-all text-left group',
|
||||
hasResult ? cn(persona.bg, persona.border, 'border-b') : 'bg-white/50 dark:bg-white/5 hover:bg-white dark:hover:bg-white/10',
|
||||
isLoading && 'animate-pulse',
|
||||
!!loadingId && !isLoading && 'opacity-40 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg shrink-0 transition-colors',
|
||||
hasResult ? cn(persona.bg, 'border', persona.border) : 'bg-slate-50 dark:bg-white/10',
|
||||
!hasResult && `group-hover:${persona.activeBg} group-hover:text-white`,
|
||||
persona.color,
|
||||
)}>
|
||||
{isLoading
|
||||
? <Loader2 size={14} className="animate-spin" />
|
||||
: <Icon size={14} />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('text-[11px] font-bold text-ink', hasResult && persona.color)}>
|
||||
{persona.label}
|
||||
</span>
|
||||
{hasResult && (
|
||||
<span className={cn('text-[8px] font-bold uppercase tracking-widest px-1.5 py-0.5 rounded-full', persona.bg, persona.color)}>
|
||||
prêt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[9px] text-concrete/60 uppercase tracking-tight">{persona.sublabel}</p>
|
||||
</div>
|
||||
{hasResult
|
||||
? isExpanded ? <ChevronUp size={14} className="text-concrete shrink-0" /> : <ChevronDown size={14} className="text-concrete shrink-0" />
|
||||
: null
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Expanded result */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && result && (
|
||||
<motion.div
|
||||
key="result"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className={cn('p-4 space-y-3', persona.bg)}>
|
||||
<div className="text-[11px] leading-relaxed text-ink/80">
|
||||
<MarkdownContent content={result.analysis} />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(persona.id)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-white/70 dark:bg-black/20 border border-border/40 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all"
|
||||
>
|
||||
{copied === persona.id
|
||||
? <><CheckCircle size={10} className={persona.color} /> Copié</>
|
||||
: <><Copy size={10} /> Copier</>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClear(persona.id)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-white/70 dark:bg-black/20 border border-border/40 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-rose-500 transition-all"
|
||||
>
|
||||
<X size={10} /> Effacer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-border/40 bg-white/70 dark:bg-black/20 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all"
|
||||
title="Regénérer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleClear(persona.id)
|
||||
setTimeout(() => handlePersona(persona.id), 50)
|
||||
}}
|
||||
>
|
||||
↺ Regénérer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user