'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(/
\n`
}
const safeMdAlt = alt.replace(/[\[\]]/g, '')
return `\n\n\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 (
)
}