'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. * `options.asRichText` signals that newContent is HTML and the note should * switch out of markdown mode. */ onApplyToNote?: (newContent: string, options?: { asRichText?: boolean }) => 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 ( ) } // ── 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(null) useEffect(() => { setQuotaExceededFeature(null) }, [activeTab]) // Action state const [actionLoading, setActionLoading] = useState(null) const [actionPreview, setActionPreview] = useState<{ label: string; text: string; asRichText?: boolean } | 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(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(null) // Label regeneration state const [regenerateLabelsLoading, setRegenerateLabelsLoading] = useState(false) const [autoLabelOpen, setAutoLabelOpen] = useState(false) const messagesEndRef = useRef(null) const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current const buildChatBody = () => { const body: Record = { 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 // Dispatch quota refresh quand le streaming se termine useEffect(() => { if (status === 'ready' && messages.length > 0) { window.dispatchEvent(new Event('ai-usage-changed')) } }, [status]) 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, asRichText: action.id === 'toRichText' }) } catch (e: any) { mToast.error(e.message || t('ai.actionError')) } finally { setActionLoading(null) } } const handleApplyPreview = () => { if (!actionPreview || !onApplyToNote) return onApplyToNote(actionPreview.text, { asRichText: actionPreview.asRichText }) setActionPreview(null) mToast.success(t('ai.appliedToNote')) } const handleDiscardPreview = () => setActionPreview(null) // ── Generate slides / diagram ──────────────────────────────────────────────── const generatePollRef = useRef | null>(null) const generateProgressRef = useRef | 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

${alt}

\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 && (
setExpanded(false)} /> )}