Files
Momento/memento-note/components/contextual-ai-chat.tsx
Antigravity eff906d187
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
fix: exercices dans menu GraduationCap + équations KaTeX + refresh liste
- Menu déroulant GraduationCap : Flashcards + Exercices réunis
- Fix: language non défini dans toolbar (useLanguage destructuring)
- Fix: équations 658071 → KaTeX dans exercices (preprocessMathInHtml partagé)
- lib/text/math-preprocess.ts : utilitaire partagé wizard + exercices
- Toast avec bouton 'Voir' pour rafraîchir après création exercices
- emitNoteChange pour rafraîchir la liste
- i18n FR/EN
2026-06-14 20:13:25 +00:00

1305 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useRef, useEffect, useState } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import type { UIMessage } from 'ai'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
X, Bot, Sparkles, Send, Loader2, Square,
Briefcase, Palette, GraduationCap, Coffee,
Lightbulb, Minimize2, AlignLeft, Wand2,
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
GitMerge, PlusCircle, Eye, Code, Languages,
Presentation, PenTool, ExternalLink, ImagePlus,
ChevronRight, MessageSquare, History, Scissors, Zap, Layout, ArrowRightLeft, Copy, CheckCircle,
Tag as TagIcon, RefreshCw,
} from 'lucide-react'
import { motion, AnimatePresence } from 'motion/react'
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
import { toast } from 'sonner'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { InlinePaywall } from './settings/inline-paywall'
// ── Custom Toast Helper ──────────────────────────────────────────────────────
const mToast = {
success: (msg: string, options?: any) => toast.success(msg, {
...options,
className: 'memento-toast memento-toast-success',
}),
error: (msg: string, options?: any) => toast.error(msg, {
...options,
className: 'memento-toast memento-toast-error',
}),
loading: (msg: string, options?: any) => toast.loading(msg, {
...options,
className: 'memento-toast memento-toast-info',
}),
}
import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
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 ──────────────────────────────────────────────────────────────────
function getMessageContent(msg: UIMessage): string {
if (typeof (msg as any).content === 'string') return (msg as any).content
if (msg.parts && Array.isArray(msg.parts)) {
return msg.parts
.filter((p: any) => p.type === 'text')
.map((p: any) => p.text)
.join('')
}
return ''
}
// ── Constants ─────────────────────────────────────────────────────────────────
const TONE_ICONS = [
{ id: 'professional', icon: Briefcase },
{ id: 'creative', icon: Palette },
{ id: 'academic', icon: GraduationCap },
{ id: 'casual', icon: Coffee },
] as const
interface ActionDef {
id: string
icon: any
apiPath: string
body: (content: string, images?: string[], lang?: string, format?: string) => any
resultKey: string
i18nKey: string
isImageAction?: boolean
}
const ACTION_IDS = [
{ id: 'clarify', icon: Lightbulb, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'clarify', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.clarify' },
{ id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'shorten', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' },
{ id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'improve', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' },
{ id: 'translate', icon: Languages, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'translate', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.translate' },
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string, _images?: string[], _lang?: string, _format?: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
{ id: 'toRichText', icon: Wand2, apiPath: '/api/ai/convert-markdown', body: (content: string, _images?: string[], _lang?: string, _format?: string) => ({ content }), resultKey: 'html', i18nKey: 'ai.action.toRichText' },
{ id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string, _format?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true },
]
/** API language names sent to `/api/ai/reformulate` for translate targets */
const TRANSLATE_LANGUAGE_OPTIONS: { api: string; labelKey: string }[] = [
{ api: 'French', labelKey: 'languages.targets.french' },
{ api: 'English', labelKey: 'languages.targets.english' },
{ api: 'Spanish', labelKey: 'languages.targets.spanish' },
{ api: 'German', labelKey: 'languages.targets.german' },
{ api: 'Persian', labelKey: 'languages.targets.persian' },
{ api: 'Portuguese', labelKey: 'languages.targets.portuguese' },
{ api: 'Italian', labelKey: 'languages.targets.italian' },
{ api: 'Chinese', labelKey: 'languages.targets.chinese' },
{ api: 'Japanese', labelKey: 'languages.targets.japanese' },
]
// ── 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 */
onUndoLastAction?: () => void
/** Whether the last action has been applied (so we can show undo) */
lastActionApplied?: boolean
/** Notebooks available for scope selection */
notebooks?: Array<{ id: string; name: string; parentId?: string | null; trashedAt?: any }>
/** Extra classes forwarded to the aside root element */
className?: string
/** How to embed generated diagram images (markdown vs rich text HTML) */
diagramInsertFormat?: 'markdown' | 'html'
/** Called to trigger AI title generation for the note */
onGenerateTitle?: () => void
/** Notebook ID for label regeneration */
notebookId?: string
/** Notebook name for display */
notebookName?: string
}
function CopyPreviewButton({ text }: { text: string }) {
const { t } = useLanguage()
const [copied, setCopied] = useState(false)
const handleCopy = () => {
if (!text) return
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
ta.style.top = '-9999px'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.focus()
ta.setSelectionRange(0, ta.value.length)
let ok = false
try { ok = document.execCommand('copy') } catch { }
document.body.removeChild(ta)
if (!ok) {
try { navigator.clipboard.writeText(text) } catch { }
}
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
className="flex-1 py-3.5 border border-border rounded-xl text-[10px] font-bold uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-muted transition-all"
>
{copied ? <CheckCircle size={14} className="text-primary" /> : <Copy size={14} />}
{copied ? t('ai.copied') : t('ai.copy')}
</button>
)
}
// ── Component ─────────────────────────────────────────────────────────────────
export function ContextualAIChat({
onClose,
noteTitle,
noteContent,
noteImages,
noteId,
onApplyToNote,
onUndoLastAction,
lastActionApplied = false,
notebooks = [],
className,
diagramInsertFormat = 'markdown',
onGenerateTitle,
notebookId,
notebookName,
}: ContextualAIChatProps) {
const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable()
const { requestAiConsent } = useAiConsent()
const [activeTab, setActiveTab] = useState<'chat' | 'actions' | 'resource'>('actions')
const [selectedTone, setSelectedTone] = useState('professional')
const [input, setInput] = useState('')
const [chatScope, setChatScope] = useState<'note' | 'all' | string>('note')
const [webSearch, setWebSearch] = useState(false)
const [expanded, setExpanded] = useState(false)
const [quotaExceededFeature, setQuotaExceededFeature] = useState<string | null>(null)
useEffect(() => {
setQuotaExceededFeature(null)
}, [activeTab])
// Action state
const [actionLoading, setActionLoading] = useState<string | null>(null)
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 [generateProgress, setGenerateProgress] = useState(0)
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
const [customLangInput, setCustomLangInput] = useState('')
// Generation options
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)
// Resource tab state
const [resourceUrl, setResourceUrl] = useState('')
const [resourceText, setResourceText] = useState('')
const [resourceScraping, setResourceScraping] = useState(false)
const [resourceMode, setResourceMode] = useState<'replace' | 'complete' | 'merge'>('complete')
const [resourcePreview, setResourcePreview] = useState<{ text: string; source: string } | null>(null)
const [resourceEnriching, setResourceEnriching] = useState(false)
const [resourcePreviewFormat, setResourcePreviewFormat] = useState<'rendered' | 'markdown'>('rendered')
// hoveredMsgId: which chat message shows inject actions
const [hoveredMsgId, setHoveredMsgId] = useState<string | null>(null)
// Label regeneration state
const [regenerateLabelsLoading, setRegenerateLabelsLoading] = useState(false)
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
const buildChatBody = () => {
const body: Record<string, any> = {
language,
webSearch,
format: diagramInsertFormat || 'markdown'
}
if (chatScope === 'note') {
body.noteContext = {
title: noteTitle || '',
content: noteContent || '',
tone: selectedTone,
images: noteImages || [],
}
body.noteId = noteId
} else if (chatScope !== 'all') {
body.notebookId = chatScope
}
return body
}
const { messages, sendMessage, status, stop } = useChat({ transport })
const lastMsg = messages[messages.length - 1]
const lastMsgHasContent = lastMsg?.role === 'assistant' && !!getMessageContent(lastMsg)
const isLoading = (status === 'submitted' || status === 'streaming') && !lastMsgHasContent
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, resourcePreview])
useEffect(() => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
return () => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false }))
}
}, [])
// ── Chat send ───────────────────────────────────────────────────────────────
const handleSend = async () => {
const text = input.trim()
if (!text || isLoading) return
// GDPR AI Consent Check
const consented = await requestAiConsent()
if (!consented) return
try {
await sendMessage({ text }, { body: buildChatBody() })
} catch (error: any) {
console.error('Chat send error:', error)
const isQuota = error?.status === 402 || (error?.message && error.message.includes('402')) || (error?.message && error.message.includes('quota'));
if (isQuota) {
setQuotaExceededFeature('chat')
} else {
toast.error(t('chat.assistantError') || 'Failed to send message')
}
}
}
// ── Action execution ────────────────────────────────────────────────────────
const handleAction = async (action: ActionDef, targetLang?: string) => {
// GDPR AI Consent Check
const consented = await requestAiConsent()
if (!consented) return
if (action.isImageAction) {
if (!noteImages || noteImages.length === 0) {
mToast.error(t('ai.noImagesError') || 'Aucune image dans cette note')
return
}
setActionLoading(action.id)
setActionPreview(null)
try {
const res = await fetch(action.apiPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.body('', noteImages, language)),
})
if (res.status === 402) {
setQuotaExceededFeature('reformulate')
return
}
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
const descs = data.descriptions || []
let resultText = descs.map((d: any) =>
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
).join('\n\n')
if (data.combinedSummary) {
resultText += `\n\n---\n${t('ai.inlineSummaryMarkdown')} ${data.combinedSummary}`
}
setActionPreview({ label: t(action.i18nKey), text: resultText })
} catch (e: any) {
mToast.error(e.message || t('ai.actionError'))
} finally {
setActionLoading(null)
}
return
}
const wc = (noteContent || '').split(/\s+/).filter(Boolean).length
if (!noteContent || wc < 5) {
mToast.error(t('ai.minWordsError'))
return
}
setActionLoading(action.id)
setActionPreview(null)
try {
const format = diagramInsertFormat || 'markdown'
const res = await fetch(action.apiPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.body(noteContent, undefined, targetLang || language, format)),
})
if (res.status === 402) {
setQuotaExceededFeature('reformulate')
return
}
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
const result = data[action.resultKey] || ''
setActionPreview({ label: t(action.i18nKey), text: result })
} catch (e: any) {
mToast.error(e.message || t('ai.actionError'))
} finally {
setActionLoading(null)
}
}
const handleApplyPreview = () => {
if (!actionPreview || !onApplyToNote) return
onApplyToNote(actionPreview.text)
setActionPreview(null)
mToast.success(t('ai.appliedToNote'))
}
const handleDiscardPreview = () => setActionPreview(null)
// ── 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) {
mToast.error(t('ai.generate.noNoteId') || 'Note non sauvegardée')
return
}
setGenerateLoading(type)
setGenerateResult(null)
startProgressBar()
const toastId = mToast.loading(
type === 'slides' ? t('ai.generateSlidesLoading') : t('ai.generateDiagramLoading'),
{ duration: Infinity }
)
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',
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()
if (!res.ok || !data.success) {
mToast.error(data.error || t('ai.errorShort'), { id: toastId })
setGenerateLoading(null)
return
}
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()
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 })
}
} catch { }
}, 3000)
} catch {
mToast.error(t('ai.errorShort'), { id: toastId })
finishProgressBar()
setGenerateLoading(null)
}
}
const buildDiagramImageSnippet = (imageUrl: string, alt: string) => {
if (diagramInsertFormat === 'html') {
return `\n<p><img src="${imageUrl}" alt="${alt}" loading="lazy" style="max-width:100%;height:auto;border-radius:8px;" /></p>\n`
}
return `\n\n![${alt}](${imageUrl})\n\n`
}
const handleEmbedDiagramInNote = async (canvasId: string) => {
if (!onApplyToNote) return
setDiagramEmbedLoading(true)
try {
const res = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`)
const data = await res.json()
if (!res.ok || !data.canvas?.data) throw new Error()
const blob = await exportExcalidrawSceneToPngBlob(data.canvas.data)
if (!blob) throw new Error()
const fd = new FormData()
fd.append('file', blob, `diagram-${canvasId.slice(-8)}.png`)
const up = await fetch('/api/upload', { method: 'POST', body: fd })
const upJson = await up.json()
if (!up.ok || !upJson.url) throw new Error()
const alt = t('ai.generate.diagramImageAlt')
onApplyToNote(`${noteContent ?? ''}${buildDiagramImageSnippet(upJson.url as string, alt)}`)
mToast.success(t('ai.generate.insertedInNote'))
} catch (e) {
mToast.error(t('ai.generate.insertExportError'))
} finally {
setDiagramEmbedLoading(false)
}
}
// ── Resource tab handlers ────────────────────────────────────────────────────
const handleScrapeUrl = async () => {
if (!resourceUrl.trim()) return
setResourceScraping(true)
try {
const result = await scrapePageText(resourceUrl.trim())
if (!result) { mToast.error(t('ai.resource.failedToLoadUrl')); return }
setResourceText(result.text)
mToast.success(t('ai.resource.pageLoaded', { title: result.title.slice(0, 40) }))
} catch {
mToast.error(t('ai.resource.pageLoadError'))
} finally {
setResourceScraping(false)
}
}
const handleResourcePreview = async () => {
if (!resourceText.trim()) { mToast.error(t('ai.resource.pasteOrUrlFirst')); return }
if (resourceMode === 'replace') {
setResourcePreview({ text: resourceText, source: 'paste' })
return
}
// GDPR AI Consent Check
const consented = await requestAiConsent()
if (!consented) return
setResourceEnriching(true)
try {
const res = await fetch('/api/ai/enrich-from-resource', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
existingContent: noteContent || '',
resourceText: resourceText.trim(),
mode: resourceMode,
language,
format: diagramInsertFormat || 'markdown',
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.resource.enrichError'))
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
} catch (e: any) {
mToast.error(e.message || t('ai.resource.enrichError'))
} finally {
setResourceEnriching(false)
}
}
const handleApplyResourcePreview = () => {
if (!resourcePreview || !onApplyToNote) return
onApplyToNote(resourcePreview.text)
setResourcePreview(null)
setResourceText('')
setResourceUrl('')
mToast.success(t('ai.resource.contentApplied'))
}
const handleInjectFromChat = async (msgText: string, mode: 'replace' | 'complete' | 'merge') => {
setResourceText(msgText)
setResourceMode(mode)
if (mode === 'replace') {
setResourcePreview({ text: msgText, source: 'chat' })
return
}
// GDPR AI Consent Check
const consented = await requestAiConsent()
if (!consented) return
setResourceEnriching(true)
try {
const res = await fetch('/api/ai/enrich-from-resource', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
existingContent: noteContent || '',
resourceText: msgText,
mode,
language,
format: diagramInsertFormat || 'markdown',
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
setResourcePreview({ text: data.enrichedContent, source: mode })
} catch (e: any) {
mToast.error(e.message || t('ai.resource.enrichErrorShort'))
} finally {
setResourceEnriching(false)
}
}
const handleRegenerateLabels = () => {
if (!notebookId) {
mToast.error(t('ai.autoLabels.noNotebook') || 'Aucun carnet sélectionné')
return
}
setAutoLabelOpen(true)
}
return (
<>
{expanded && (
<div
className="fixed inset-0 z-[199] bg-foreground/40 backdrop-blur-sm"
onClick={() => setExpanded(false)}
/>
)}
<aside className={cn(
'border-l border-border bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col z-10 transition-all duration-300 shadow-2xl overflow-hidden',
expanded
? 'fixed right-0 top-0 h-screen w-[640px] z-[200]'
: 'self-stretch h-full w-[400px]',
!expanded && className,
)}>
<div className="p-6 border-b border-border/60 space-y-1.5 bg-white/50 dark:bg-black/20 backdrop-blur-md shrink-0">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<h3 className="flex items-center gap-2 font-serif text-xl font-medium text-ink">
<Sparkles size={18} className="text-brand-accent shrink-0" />
{t('ai.assistantTitle') || 'IA Assistant'}
</h3>
<p className="text-[11px] text-concrete uppercase tracking-wider font-medium opacity-60 truncate">
"{noteTitle || t('ai.currentNote')}"
</p>
</div>
<div className="flex items-center gap-1 shrink-0 ml-2">
<button
onClick={() => setExpanded(e => !e)}
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
>
{expanded ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
<button
onClick={onClose}
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
title={t('general.close') || 'Fermer'}
>
<X size={18} />
</button>
</div>
</div>
</div>
<div className="flex border-b border-border shrink-0 px-2 flex-shrink-0 h-12 items-stretch">
{[
{ id: 'actions' as const, label: t('ai.assistantTabActions'), icon: <Sparkles size={16} /> },
{ id: 'chat' as const, label: t('ai.chatTab'), icon: <MessageSquare size={16} /> },
{ id: 'resource' as const, label: t('ai.resourceTab'), icon: <Link2 size={16} /> },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={cn(
"flex-1 text-[10px] font-bold uppercase tracking-[0.2em] transition-all relative whitespace-nowrap focus:outline-none",
activeTab === tab.id ? 'text-brand-accent' : 'text-concrete hover:text-ink/60'
)}
>
{tab.label}
{activeTab === tab.id && (
<motion.div layoutId="activeTab" className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand-accent" />
)}
</button>
))}
</div>
<div className="flex-1 flex flex-col min-h-0 relative">
{quotaExceededFeature && (
<div className="absolute inset-x-4 top-4 z-30 animate-in fade-in slide-in-from-top-4 duration-300">
<InlinePaywall
feature={quotaExceededFeature}
onDismiss={() => setQuotaExceededFeature(null)}
/>
</div>
)}
{actionPreview && (
<div className="absolute inset-0 z-20 flex flex-col bg-[#FDFCFB]/95 dark:bg-[#0D0D0D]/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
<div className="px-6 py-4 border-b border-border flex items-center justify-between shrink-0">
<p className="text-[10px] font-bold uppercase tracking-widest text-brand-accent">{actionPreview.label}</p>
<button onClick={handleDiscardPreview} className="text-concrete hover:text-ink"><X size={18} /></button>
</div>
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
<div className="bg-white/60 dark:bg-white/5 border border-border p-6 rounded-2xl leading-relaxed text-sm">
<MarkdownContent content={actionPreview.text} />
</div>
</div>
<div className="p-6 border-t border-border flex gap-3 shrink-0">
<button onClick={handleDiscardPreview} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all">{t('ai.cancel')}</button>
<CopyPreviewButton text={actionPreview.text} />
<button onClick={handleApplyPreview} className="flex-1 py-3.5 bg-ink text-paper rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg transition-all hover:opacity-90">{t('ai.applyToNote')}</button>
</div>
</div>
)}
{resourcePreview && (
<div className="absolute inset-0 z-20 flex flex-col bg-[#FDFCFB]/95 dark:bg-[#0D0D0D]/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
<div className="px-6 py-4 border-b border-border flex items-center justify-between shrink-0">
<p className="text-[10px] font-bold uppercase tracking-widest text-brand-accent">
{resourcePreview.source === 'chat' ? t('ai.resourcePreviewInjectFromChat') : t('ai.resourcePreviewAiTitle')}
</p>
<button onClick={() => setResourcePreview(null)} className="text-concrete hover:text-ink">
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
<div className="bg-white/60 dark:bg-white/5 border border-border p-6 rounded-2xl leading-relaxed text-sm">
<MarkdownContent content={resourcePreview.text} />
</div>
</div>
<div className="p-6 border-t border-border flex gap-3 shrink-0">
<button onClick={() => setResourcePreview(null)} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all">{t('ai.cancel')}</button>
<CopyPreviewButton text={resourcePreview.text} />
<button onClick={handleApplyResourcePreview} className="flex-1 py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg shadow-brand-accent/20 transition-all hover:opacity-90">{t('ai.applyToNote')}</button>
</div>
</div>
)}
<div className="flex-1 flex flex-col min-h-0 relative">
<AnimatePresence mode="wait">
{activeTab === 'chat' && (
<motion.div
key="chat"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="flex flex-col flex-1 h-full min-h-0 overflow-hidden"
>
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar space-y-6 min-h-0">
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.writingTone')}</label>
<div className="flex items-center gap-1">
{TONE_ICONS.map((tone) => {
const Icon = tone.icon
return (
<button
key={tone.id}
onClick={() => setSelectedTone(tone.id)}
title={t(`ai.tones.${tone.id}`)}
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center text-[9px] font-bold transition-all border',
selectedTone === tone.id
? 'bg-brand-accent text-white border-brand-accent shadow-sm'
: 'bg-white/50 dark:bg-white/5 border-border/40 text-concrete hover:border-brand-accent/40'
)}
>
<Icon className="h-3.5 w-3.5" />
</button>
)
})}
</div>
</div>
<button
onClick={() => setChatScope('note')}
className={cn(
'w-full p-2.5 border rounded-xl text-[11px] flex items-center justify-between transition-all',
chatScope === 'note' ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white/50 dark:bg-white/5 border-border/40 hover:border-ink/20'
)}
>
<div className="flex items-center gap-2.5">
<BookOpen size={14} className="text-brand-accent/60" />
<span className={cn('font-medium', chatScope === 'note' ? 'text-brand-accent' : 'text-concrete')}>{t('ai.thisNote')}</span>
</div>
{chatScope === 'note' && (
<span className="text-[8px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.5 rounded-full uppercase font-bold">{t('ai.scopeAutoBadge')}</span>
)}
</button>
<div className="flex items-center gap-2 px-2">
<div className="h-px flex-1 bg-border/40" />
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">+ Carnet</span>
<div className="h-px flex-1 bg-border/40" />
</div>
<HierarchicalNotebookSelector
notebooks={(notebooks || []).filter(nb => !nb.trashedAt)}
selectedId={chatScope !== 'note' && chatScope !== 'all' ? chatScope : null}
onSelect={(id) => setChatScope(id)}
placeholder={t('ai.chatNotebookSelectPlaceholder')}
className="w-full"
size="sm"
dropUp
/>
</div>
{messages.length === 0 && (
<div className="h-48 flex flex-col items-center justify-center text-center space-y-3 text-concrete/30">
<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-6">{t('ai.welcomeMsg')}</p>
</div>
)}
{messages.map((msg: UIMessage) => {
const content = getMessageContent(msg)
if (msg.role === 'assistant' && !content) return null
const isAssistant = msg.role === 'assistant'
return (
<div key={msg.id} className={cn('flex gap-3', !isAssistant && 'flex-row-reverse')} onMouseEnter={() => isAssistant && setHoveredMsgId(msg.id)} onMouseLeave={() => setHoveredMsgId(null)}>
<div className={cn(
'w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0',
!isAssistant ? 'bg-ink text-paper' : 'bg-brand-accent/10 text-brand-accent',
)}>
{!isAssistant ? 'U' : <Bot className="h-4 w-4" />}
</div>
<div className="relative group/msg max-w-[85%]">
<div className={cn(
'p-3.5 rounded-2xl text-sm leading-relaxed',
!isAssistant
? 'bg-ink text-paper rounded-tr-sm'
: 'bg-white/60 dark:bg-white/5 border border-border rounded-tl-sm text-ink',
)}>
{isAssistant ? <MarkdownContent content={content} /> : <p>{content}</p>}
</div>
{isAssistant && onApplyToNote && (hoveredMsgId === msg.id || messages.at(-1)?.id === msg.id) && (
<div className="flex gap-2 mt-2 opacity-0 group-hover/msg:opacity-100 transition-all">
<button onClick={() => handleInjectFromChat(content, 'replace')} className="px-2.5 py-1 rounded-lg text-[8px] font-bold uppercase tracking-widest bg-ink text-paper hover:opacity-90">{t('ai.injectReplace')}</button>
<button onClick={() => handleInjectFromChat(content, 'complete')} className="px-2.5 py-1 rounded-lg text-[8px] font-bold uppercase tracking-widest bg-white/50 dark:bg-white/5 border border-border text-ink hover:bg-white dark:hover:bg-white/10">{t('ai.injectComplete')}</button>
<button onClick={() => handleInjectFromChat(content, 'merge')} className="px-2.5 py-1 rounded-lg text-[8px] font-bold uppercase tracking-widest bg-white/50 dark:bg-white/5 border border-border text-ink hover:bg-white dark:hover:bg-white/10">{t('ai.injectMerge')}</button>
</div>
)}
</div>
</div>
)
})}
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-xl bg-brand-accent/10 text-brand-accent flex items-center justify-center flex-shrink-0">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
<div className="bg-white/60 dark:bg-white/5 border border-border p-3.5 rounded-2xl rounded-tl-sm">
<Loader2 className="h-4 w-4 animate-spin text-concrete" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-6 bg-white/40 dark:bg-black/20 border-t border-border backdrop-blur-xl shrink-0">
<div className="relative group/chat">
<textarea
rows={4}
placeholder={t('ai.chatPlaceholder')}
className="w-full bg-white/80 dark:bg-white/5 border border-border rounded-[24px] p-5 pr-14 text-sm outline-none focus:border-brand-accent focus:ring-4 ring-brand-accent/5 transition-all resize-none leading-relaxed font-light shadow-inner text-ink"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}}
disabled={isLoading}
/>
<div className="absolute right-4 bottom-4 flex flex-col gap-2">
{isLoading ? (
<button onClick={() => stop()} className="p-2.5 bg-rose-500 text-white rounded-xl transition-all hover:scale-110 active:scale-95 shadow-lg shadow-rose-500/20">
<Square size={16} />
</button>
) : (
<button onClick={handleSend} disabled={!input.trim()} className="p-2.5 bg-brand-accent text-white rounded-xl transition-all hover:scale-110 active:scale-95 shadow-lg shadow-brand-accent/20 disabled:opacity-40 disabled:pointer-events-none">
<Send size={16} />
</button>
)}
</div>
<div className="absolute left-6 bottom-4 flex gap-3 text-concrete/40">
<button
onClick={() => webSearchAvailable && setWebSearch(!webSearch)}
className={cn("hover:text-brand-accent transition-colors", webSearch && "text-brand-accent")}
>
<Globe size={14} />
</button>
</div>
</div>
<div className="flex justify-center mt-4">
<p className="text-[9px] text-concrete/40 uppercase tracking-[0.3em] font-bold">{t('ai.newLineHint') || "Maj+Entrée = nouvelle ligne"}</p>
</div>
</div>
</motion.div>
)}
{activeTab === 'actions' && (
<motion.div
key="actions"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="flex flex-col flex-1 h-full min-h-0 overflow-y-auto p-6 space-y-10 custom-scrollbar"
>
{notebookId && (
<div className="space-y-3">
<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">{t('ai.organization') || 'Organisation'}</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<button
type="button"
onClick={handleRegenerateLabels}
className="w-full flex items-center gap-3 p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl transition-all hover:border-brand-accent/30 cursor-pointer"
>
<div className="p-2 bg-slate-50 dark:bg-white/10 rounded-lg text-brand-accent shrink-0"><TagIcon size={18} /></div>
<div className="flex-1 text-left">
<h5 className="text-[10px] font-bold text-ink">{t('ai.autoLabels.regenerate') || 'Labels IA'}</h5>
<p className="text-[8px] text-concrete uppercase tracking-tight">{notebookName || ''}</p>
</div>
<RefreshCw size={14} className="text-brand-accent shrink-0" />
</button>
</div>
)}
<div className="space-y-6">
<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">{t('ai.transformations')}</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
{lastActionApplied && onUndoLastAction && (
<motion.button
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
onClick={onUndoLastAction}
className="w-full py-3.5 bg-brand-accent/20 border border-brand-accent/50 rounded-xl flex items-center justify-center gap-2 text-[11px] font-bold text-brand-accent uppercase tracking-[0.2em] hover:bg-brand-accent/30 transition-all shadow-md"
>
<RotateCcw size={12} /> {t('ai.undoLastAction')}
</motion.button>
)}
<div className="grid grid-cols-2 gap-3">
{ACTION_IDS.filter(a => a.id !== 'markdown').map((action, i) => {
const loading = actionLoading === action.id
const isActive = action.id === 'translate' && showLangPicker
const Icon = action.icon
return (
<button key={i} onClick={() => action.id === 'translate' ? setShowLangPicker(v => !v) : handleAction(action)} disabled={!!actionLoading} className={cn("flex flex-col items-center gap-3 p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl transition-all group hover:border-ink/20 shadow-sm", isActive ? "border-brand-accent bg-brand-accent/5" : "")}>
<div className={cn("p-2 rounded-lg bg-slate-50 dark:bg-white/10 transition-colors group-hover:bg-brand-accent group-hover:text-white shadow-sm text-concrete", loading && "animate-pulse", isActive && "bg-brand-accent text-white")}>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
</div>
<span className={cn("text-[10px] font-bold text-ink/80 uppercase tracking-widest", isActive ? "text-brand-accent" : "")}>{t(action.i18nKey)}</span>
</button>
)
})}
<AnimatePresence mode="wait">
{showLangPicker && (
<motion.div
key="lang-picker"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="col-span-2 overflow-hidden"
>
<div className="mt-2 p-5 bg-white/50 dark:bg-white/5 border border-brand-accent/30 rounded-2xl space-y-5">
<div className="grid grid-cols-3 gap-2">
{TRANSLATE_LANGUAGE_OPTIONS.map(({ api, labelKey }) => (
<button
key={api}
onClick={() => setTranslateTarget(api)}
className={cn(
"py-2 px-1 rounded-lg border text-[10px] font-bold uppercase tracking-tighter transition-all",
translateTarget === api
? "bg-brand-accent border-brand-accent text-white shadow-md shadow-brand-accent/20"
: "bg-white/50 dark:bg-white/5 border-border text-concrete hover:border-ink/20"
)}
>
{t(labelKey)}
</button>
))}
</div>
<div className="space-y-2">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-concrete px-1">{t('ai.otherLanguage')}</span>
<input
type="text"
value={customLangInput}
onChange={(e) => {
setCustomLangInput(e.target.value)
setTranslateTarget(e.target.value)
}}
placeholder={t('languages.customPlaceholder')}
className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl px-4 py-2.5 text-[11px] outline-none focus:border-brand-accent transition-all text-ink"
/>
</div>
<button
onClick={() => handleAction(ACTION_IDS.find(a => a.id === 'translate')!, translateTarget)}
disabled={!translateTarget || !!actionLoading}
className="w-full py-3 bg-brand-accent text-white rounded-xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:opacity-90 disabled:opacity-50 shadow-lg shadow-brand-accent/20"
>
<Languages size={14} /> {t('ai.translateNow')}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{diagramInsertFormat === 'html' ? (
<button
onClick={() => handleAction(ACTION_IDS.find(a => a.id === 'markdown')!)}
disabled={!!actionLoading}
className="col-span-2 flex items-center justify-center gap-3 py-3.5 bg-white/50 dark:bg-white/5 border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-white dark:hover:bg-white/10 transition-all hover:border-ink/20 uppercase tracking-[0.2em] shadow-sm disabled:opacity-50"
>
<Code size={14} className="text-concrete" />
{actionLoading === 'markdown' ? <Loader2 size={14} className="animate-spin" /> : t('ai.action.toMarkdown')}
</button>
) : (
<button
onClick={() => handleAction(ACTION_IDS.find(a => a.id === 'toRichText')!)}
disabled={!!actionLoading}
className="col-span-2 flex items-center justify-center gap-3 py-3.5 bg-white/50 dark:bg-white/5 border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-white dark:hover:bg-white/10 transition-all hover:border-ink/20 uppercase tracking-[0.2em] shadow-sm disabled:opacity-50"
>
<Wand2 size={14} className="text-concrete" />
{actionLoading === 'toRichText' ? <Loader2 size={14} className="animate-spin" /> : t('ai.action.toRichText')}
</button>
)}
</div>
</div>
<div className="space-y-6">
<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">{t('ai.generationTools')}</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Layout size={80} className="text-brand-accent" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><Layout size={18} /></div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.slides')}</h5>
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.sectionLabel')}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<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="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 &amp; 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">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.style')}</span>
<select value={slideStyle} onChange={e => setSlideStyle(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="professional">{t('ai.generate.styleProfessional')}</option>
<option value="creative">{t('ai.generate.styleCreative')}</option>
<option value="brutalist">{t('ai.generate.styleBrutalist')}</option>
</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 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 p-4 bg-brand-accent/10 border border-brand-accent/20 rounded-xl space-y-3"
>
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest flex items-center gap-1.5">
<Check size={12} /> {t('ai.presentationReadyBadge')}
</span>
<a
href={`/lab?id=${generateResult.canvasId}`}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 bg-card/60 rounded-lg text-foreground/50 hover:text-foreground hover:bg-card transition-colors"
title={t('ai.openInLabTitle')}
>
<ExternalLink size={12} />
</a>
</div>
<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"
>
<Presentation size={13} />
{t('ai.viewSlidesButton')}
</a>
</motion.div>
)}
</div>
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<BookOpen size={80} className="text-brand-accent" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><BookOpen size={18} /></div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.diagram')}</h5>
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.diagramReadyHint')}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.diagramType')}</span>
<select value={diagramType} onChange={e => setDiagramType(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.typeAuto')}</option>
<option value="flowchart">{t('ai.generate.typeFlowchart')}</option>
<option value="mind_map">{t('ai.generate.typeMindMap')}</option>
<option value="timeline">{t('ai.generate.typeTimeline')}</option>
<option value="org_chart">{t('ai.generate.typeOrgChart')}</option>
<option value="architecture">{t('ai.generate.typeArchitecture')}</option>
<option value="process_map">{t('ai.generate.typeProcessMap')}</option>
</select>
</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.style')}</span>
<select value={diagramStyle} onChange={e => setDiagramStyle(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="sketchy">{t('ai.generate.styleSketchy')}</option>
<option value="soft">{t('ai.generate.styleSoft')}</option>
<option value="minimal">{t('ai.generate.styleMinimal')}</option>
<option value="professional">{t('ai.generate.styleProfessional')}</option>
<option value="draft">{t('ai.generate.styleDraft')}</option>
<option value="polished">{t('ai.generate.stylePolished')}</option>
<option value="handwritten">{t('ai.generate.styleHandwritten')}</option>
</select>
</div>
</div>
<button onClick={() => handleGenerate('diagram')} 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 === 'diagram' ? <Loader2 size={14} className="animate-spin" /> : <>{t('ai.generating')} <ArrowRightLeft size={14} className="opacity-60" /></>}
</button>
{generateResult?.type === 'diagram' && generateResult.canvasId && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 p-4 bg-brand-accent/10 border border-brand-accent/20 rounded-xl space-y-3"
>
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest">{t('ai.generate.diagramReady')}</span>
<div className="flex gap-2">
<a
href={`/lab?id=${generateResult.canvasId}`}
target="_blank"
rel="noopener noreferrer"
className="p-2 bg-card/60 rounded-lg text-foreground hover:bg-card transition-colors"
title={t('ai.generate.openInExcalidraw')}
>
<ExternalLink size={14} />
</a>
<button
onClick={() => handleEmbedDiagramInNote(generateResult.canvasId!)}
disabled={diagramEmbedLoading}
className="p-2 bg-brand-accent text-white rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
title={t('ai.generate.insertDiagramInNote')}
>
{diagramEmbedLoading ? <Loader2 size={14} className="animate-spin" /> : <ImagePlus size={14} />}
</button>
</div>
</div>
</motion.div>
)}
</div>
</div>
</div>
{/* ── Personas IA ── */}
<PersonasPanel noteTitle={noteTitle} noteContent={noteContent} />
</motion.div>
)}
{activeTab === 'resource' && (
<motion.div
key="resource"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="flex flex-col flex-1 h-full min-h-0 overflow-y-auto p-6 space-y-8 custom-scrollbar"
>
<div className="space-y-6">
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resource.urlLabel')}</label>
<div className="relative">
<input type="text" placeholder="https://..." className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl pl-4 pr-12 py-3 text-xs outline-none focus:border-brand-accent transition-colors text-ink" value={resourceUrl} onChange={e => setResourceUrl(e.target.value)} />
<Globe size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-concrete/40" />
</div>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resource.resourceText')}</label>
<textarea rows={10} placeholder={t('ai.resource.resourcePlaceholder')} className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl p-4 text-xs outline-none focus:border-brand-accent transition-colors resize-none leading-relaxed text-ink" value={resourceText} onChange={e => setResourceText(e.target.value)} />
</div>
<div className="space-y-4">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resource.integrationMode')}</label>
<div className="grid grid-cols-3 gap-2">
{[
{ id: 'replace', label: t('ai.resource.modeReplace'), sub: t('ai.resource.modeReplaceDesc') },
{ id: 'complete', label: t('ai.resource.modeComplete'), sub: t('ai.resource.modeCompleteDesc') },
{ id: 'merge', label: t('ai.resource.modeMerge'), sub: t('ai.resource.modeMergeDesc') },
].map((mode) => (
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white border-border hover:bg-slate-50 dark:hover:bg-white/5')}>
<span className={cn("text-[10px] font-bold", resourceMode === mode.id ? 'text-brand-accent' : 'text-ink')}>{mode.label}</span>
<span className="text-[8px] text-concrete opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
</button>
))}
</div>
</div>
<button onClick={handleResourcePreview} disabled={resourceEnriching || resourceScraping || !resourceText.trim()} className="w-full py-4 bg-brand-accent text-white rounded-xl text-[11px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-brand-accent/20 disabled:opacity-50">
{resourceEnriching || resourceScraping ? <Loader2 size={18} className="animate-spin" /> : <Sparkles size={18} />}
{t('ai.resource.generatePreview')}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Shared footer for all tabs to ensure consistent bottom spacing */}
<div className="flex flex-col items-center gap-4 py-8 opacity-20 mt-auto shrink-0 border-t border-border/10">
<PenTool size={20} />
<span className="text-[9px] font-bold uppercase tracking-[0.3em] whitespace-nowrap italic text-center">
{t('nav.workspace') || 'Momento Workspace'}
</span>
</div>
</div>
</aside>
{autoLabelOpen && notebookId && (
<AutoLabelSuggestionDialog
open={autoLabelOpen}
onOpenChange={setAutoLabelOpen}
notebookId={notebookId}
onLabelsCreated={() => {
mToast.success(t('ai.autoLabels.created', { count: 0 }) || 'Labels créés')
}}
/>
)}
</>
)
}