'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, } from 'lucide-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 { useWebSearchAvailable } from '@/hooks/use-web-search-available' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { getNotebookIcon } from '@/lib/notebook-icon' import { scrapePageText } from '@/app/actions/scrape' // ── 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 TONES = [ { id: 'professional', label: 'Pro', full: 'Professional', icon: Briefcase }, { id: 'creative', label: 'Create', full: 'Creative', icon: Palette }, { id: 'academic', label: 'Acad.', full: 'Academic', icon: GraduationCap }, { id: 'casual', label: 'Casual', full: 'Casual', icon: Coffee }, ] interface ActionDef { id: string icon: any apiPath: string body: (content: string, images?: string[], lang?: string) => object resultKey: string i18nKey: string isImageAction?: boolean } const ACTION_IDS = [ { id: 'clarify', icon: Lightbulb, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'clarify' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.clarify' }, { id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'shorten' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' }, { id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'improve' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' }, { id: 'translate', icon: Languages, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string) => ({ text: content, option: 'translate', language: lang || 'fr' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.translate' }, { id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' }, { id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true }, ] // ── 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 }> /** Extra classes forwarded to the aside root element */ className?: string /** How to embed generated diagram images (markdown vs rich text HTML) */ diagramInsertFormat?: 'markdown' | 'html' } // ── Component ───────────────────────────────────────────────────────────────── export function ContextualAIChat({ onClose, noteTitle, noteContent, noteImages, noteId, onApplyToNote, onUndoLastAction, lastActionApplied = false, notebooks = [], className, diagramInsertFormat = 'markdown', }: ContextualAIChatProps) { const { t, language } = useLanguage() const webSearchAvailable = useWebSearchAvailable() const [activeTab, setActiveTab] = useState<'chat' | 'actions' | 'resource'>('chat') 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) // Action state const [actionLoading, setActionLoading] = useState(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 [generateResult, setGenerateResult] = useState(null) const [customLangInput, setCustomLangInput] = useState('') // Generation options const [slideTheme, setSlideTheme] = useState('vibrant_tech') const [slideStyle, setSlideStyle] = useState('soft') const [diagramType, setDiagramType] = useState('auto') const [diagramStyle, setDiagramStyle] = useState('default') 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) const messagesEndRef = useRef(null) const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current const buildChatBody = () => { const body: Record = { language, webSearch } if (chatScope === 'note') { body.noteContext = { title: noteTitle || '', content: noteContent || '', tone: selectedTone, images: noteImages || [], } } else if (chatScope !== 'all') { // scope is a notebook ID 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 setInput('') await sendMessage({ text }, { body: buildChatBody() }) } // ── Action execution ──────────────────────────────────────────────────────── const handleAction = async (action: ActionDef, targetLang?: string) => { // Image-specific action if (action.isImageAction) { if (!noteImages || noteImages.length === 0) { toast.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)), }) const data = await res.json() if (!res.ok) throw new Error(data.error || t('ai.genericError')) // Format image descriptions for preview 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.overview') || 'Résumé'}:** ${data.combinedSummary}` } setActionPreview({ label: t(action.i18nKey), text: resultText }) } catch (e: any) { toast.error(e.message || t('ai.actionError')) } finally { setActionLoading(null) } return } // Text-based actions const wc = (noteContent || '').split(/\s+/).filter(Boolean).length if (!noteContent || wc < 5) { toast.error(t('ai.minWordsError')) 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(noteContent, undefined, targetLang || language)), }) const data = await res.json() if (!res.ok) { // Use i18n key if provided by the server if (data.errorKey) { throw new Error(t(data.errorKey, data.params || {})) } throw new Error(data.error || t('ai.genericError')) } const result = data[action.resultKey] || '' setActionPreview({ label: t(action.i18nKey), text: result }) } catch (e: any) { toast.error(e.message || t('ai.actionError')) } finally { setActionLoading(null) } } const handleApplyPreview = () => { if (!actionPreview || !onApplyToNote) return onApplyToNote(actionPreview.text) setActionPreview(null) toast.success(t('ai.appliedToNote')) } const handleDiscardPreview = () => setActionPreview(null) // ── Generate slides / diagram ──────────────────────────────────────────────── const generatePollRef = useRef | null>(null) const handleGenerate = async (type: 'slides' | 'diagram') => { if (!noteId) { toast.error(t('ai.generate.noNoteId') || 'Note non sauvegardée') return } setGenerateLoading(type) setGenerateResult(null) // Persistent loading toast — layout-level (Sonner), survives navigation const toastId = toast.loading( type === 'slides' ? (t('ai.generate.toastLoading.slides') || '⏳ Génération de la présentation en cours…') : (t('ai.generate.toastLoading.diagram') || '⏳ Génération du diagramme en cours…'), { duration: Infinity, description: t('ai.generate.toastLoadingDesc') || 'Vous pouvez naviguer librement, une notification apparaîtra.' } ) try { // POST starts the agent immediately and returns agentId (fire-and-forget on server) 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, }), }) const data = await res.json() if (!res.ok || !data.success) { toast.error(data.error || t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId }) setGenerateLoading(null) return } const { agentId } = data as { agentId: string } // Poll status every 3 s until terminal state if (generatePollRef.current) clearInterval(generatePollRef.current) generatePollRef.current = setInterval(async () => { 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 setGenerateLoading(null) setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId }) if (type === 'slides' && poll.canvasId) { toast.success(t('ai.generate.slidesReady') || 'Présentation générée !', { id: toastId, duration: 10000, description: t('ai.generate.toastSuccessSlides') || 'Cliquez sur Télécharger dans le panneau IA.', action: { label: t('ai.generate.downloadPptx') || 'Télécharger', onClick: () => window.open(`/api/canvas/download?id=${poll.canvasId}`, '_blank') }, }) } else if (type === 'diagram' && poll.canvasId) { toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', { id: toastId, duration: 8000, description: t('ai.generate.diagramReadyHint') || '', }) } else { toast.success(type === 'slides' ? (t('ai.generate.slidesReady') || 'Présentation générée !') : (t('ai.generate.diagramReady') || 'Diagramme généré !'), { id: toastId } ) } } else if (poll.status === 'failure') { clearInterval(generatePollRef.current!) generatePollRef.current = null setGenerateLoading(null) toast.error(poll.error || t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId }) } // If still 'running', do nothing — next poll will check again } catch { // Network error during poll — keep polling } }, 3000) } catch { toast.error(t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId }) setGenerateLoading(null) } } // Cleanup polling on unmount useEffect(() => { return () => { if (generatePollRef.current) clearInterval(generatePollRef.current) } }, []) const buildDiagramImageSnippet = (imageUrl: string, alt: string) => { if (diagramInsertFormat === 'html') { const safeAlt = alt.replace(/&/g, '&').replace(/"/g, '"').replace(/${safeAlt}

\n` } const safeMdAlt = alt.replace(/[\[\]]/g, '') return `\n\n![${safeMdAlt}](${imageUrl})\n\n` } const handleEmbedDiagramInNote = async (canvasId: string) => { if (!onApplyToNote) { toast.error(t('ai.generate.insertNeedEditor')) return } setDiagramEmbedLoading(true) try { const res = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`) const data = await res.json() if (!res.ok || !data.canvas?.data) { toast.error(data.error || t('ai.generate.insertFetchError')) return } const blob = await exportExcalidrawSceneToPngBlob(data.canvas.data) if (!blob) { toast.error(t('ai.generate.insertExportError')) return } 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) { toast.error(upJson.error || t('ai.generate.insertUploadError')) return } const alt = t('ai.generate.diagramImageAlt') const snippet = buildDiagramImageSnippet(upJson.url as string, alt) onApplyToNote(`${noteContent ?? ''}${snippet}`) toast.success(t('ai.generate.insertedInNote')) } catch (e) { console.error('[embed diagram]', e) toast.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) { toast.error(t('ai.resource.failedToLoadUrl')); return } setResourceText(result.text) toast.success(t('ai.resource.pageLoaded', { title: result.title.slice(0, 40) })) } catch { toast.error(t('ai.resource.pageLoadError')) } finally { setResourceScraping(false) } } const handleResourcePreview = async () => { if (!resourceText.trim()) { toast.error(t('ai.resource.pasteOrUrlFirst')); return } if (resourceMode === 'replace') { setResourcePreview({ text: resourceText, source: 'paste' }) 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, }), }) 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) { toast.error(e.message || t('ai.resource.enrichError')) } finally { setResourceEnriching(false) } } const handleApplyResourcePreview = () => { if (!resourcePreview || !onApplyToNote) return onApplyToNote(resourcePreview.text) setResourcePreview(null) setResourceText('') setResourceUrl('') toast.success(t('ai.resource.contentApplied')) } /** Called from chat hover-actions: inject a chat message into the note */ const handleInjectFromChat = async (msgText: string, mode: 'replace' | 'complete' | 'merge') => { if (mode === 'replace') { // Stay on chat tab — show preview inline via resourcePreview without switching tabs setResourceText(msgText) setResourceMode('replace') setResourcePreview({ text: msgText, source: 'chat' }) return } setResourceText(msgText) setResourceMode(mode) // Do NOT switch tabs — enrich and show preview in current tab 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, }), }) const data = await res.json() if (!res.ok) throw new Error(data.error || 'Erreur IA') setResourcePreview({ text: data.enrichedContent, source: mode }) } catch (e: any) { toast.error(e.message || t('ai.resource.enrichErrorShort')) } finally { setResourceEnriching(false) } } // ── Scope label ───────────────────────────────────────────────────────────── const scopeLabel = chatScope === 'note' ? t('ai.thisNote') : chatScope === 'all' ? t('ai.allMyNotes') : notebooks.find(n => n.id === chatScope)?.name ?? t('ai.notebookGeneric') return (