- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types - Chart types: bar, horizontal-bar, line, donut, radar - Integrate with agent executor and canvas system - Add multilingual support (en/fr) - Various UI improvements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
407 lines
18 KiB
TypeScript
407 lines
18 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { useChat } from '@ai-sdk/react'
|
|
import { DefaultChatTransport } from 'ai'
|
|
import type { UIMessage } from 'ai'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
X, Bot, Sparkles, History, Send, Globe,
|
|
Loader2, Square, Plus, MessageSquare, FileCode,
|
|
Maximize2, Minimize2
|
|
} from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { MarkdownContent } from '@/components/markdown-content'
|
|
import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
|
|
import { useNotebooks } from '@/context/notebooks-context'
|
|
import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
|
|
import { toast } from 'sonner'
|
|
import { createConversation } from '@/app/actions/chat-actions'
|
|
import { motion, AnimatePresence } from 'motion/react'
|
|
|
|
function getTextContent(msg: UIMessage): string {
|
|
if (msg.parts && Array.isArray(msg.parts)) {
|
|
return msg.parts.filter((p: any) => p.type === 'text' && typeof p.text === 'string').map((p: any) => p.text).join('')
|
|
}
|
|
if (typeof (msg as any).content === 'string') return (msg as any).content
|
|
return ''
|
|
}
|
|
|
|
type AITab = 'discussion' | 'history'
|
|
|
|
export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: boolean } = {}) {
|
|
const { t, language } = useLanguage()
|
|
const webSearchAvailable = useWebSearchAvailable()
|
|
const { notebooks } = useNotebooks()
|
|
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
const [aiTab, setAiTab] = useState<AITab>('discussion')
|
|
const [webSearch, setWebSearch] = useState(false)
|
|
const [chatScope, setChatScope] = useState<'all' | string>('all')
|
|
const [input, setInput] = useState('')
|
|
const [conversationId, setConversationId] = useState<string | undefined>()
|
|
const [isContextualAIVisible, setIsContextualAIVisible] = useState(false)
|
|
|
|
const [history, setHistory] = useState<any[]>([])
|
|
const [historyLoading, setHistoryLoading] = useState(false)
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
|
|
|
|
const { messages, setMessages, sendMessage, status, stop } = useChat({
|
|
transport,
|
|
onError: (error) => {
|
|
console.error('Chat error:', error)
|
|
try {
|
|
const parsed = JSON.parse((error as Error).message || '{}')
|
|
if (parsed.error === 'QUOTA_EXCEEDED') {
|
|
const isBasic = (parsed.currentTier || 'BASIC') === 'BASIC'
|
|
toast.error(
|
|
isBasic
|
|
? t('chat.quotaExceededBasic')
|
|
: t('chat.quotaExceededTier', { tier: parsed.currentTier }),
|
|
{ duration: 8000 }
|
|
)
|
|
return
|
|
}
|
|
} catch {}
|
|
toast.error(t('chat.assistantError') || 'Chat error')
|
|
}
|
|
})
|
|
|
|
const isLoading = status === 'submitted' || status === 'streaming'
|
|
|
|
const handleSend = async () => {
|
|
const text = input.trim()
|
|
if (!text || isLoading) return
|
|
setInput('')
|
|
let convId = conversationId
|
|
if (!convId) {
|
|
try {
|
|
const result = await createConversation(text, chatScope !== 'all' ? chatScope : undefined)
|
|
convId = result.id
|
|
setConversationId(convId)
|
|
} catch {
|
|
toast.error(t('chat.createError'))
|
|
return
|
|
}
|
|
}
|
|
try {
|
|
await sendMessage(
|
|
{ text },
|
|
{
|
|
body: {
|
|
chatScope,
|
|
notebookId: chatScope !== 'all' ? chatScope : undefined,
|
|
webSearch: webSearch && webSearchAvailable,
|
|
conversationId: convId,
|
|
language,
|
|
}
|
|
}
|
|
)
|
|
} catch (error) {
|
|
console.error('Chat send error:', error)
|
|
toast.error(t('chat.assistantError') || 'Failed to send message')
|
|
}
|
|
}
|
|
|
|
const fetchHistory = async () => {
|
|
setHistoryLoading(true)
|
|
try {
|
|
const res = await fetch('/api/chat/history')
|
|
if (res.ok) setHistory(await res.json())
|
|
} catch (e) { console.error(e) }
|
|
setHistoryLoading(false)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (messagesEndRef.current) {
|
|
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
|
}
|
|
}, [messages])
|
|
|
|
const handleToggle = useCallback(() => setIsOpen(v => !v), [])
|
|
const handleVisibility = useCallback((e: any) => setIsContextualAIVisible(e.detail), [])
|
|
useEffect(() => {
|
|
window.addEventListener('toggle-ai-chat', handleToggle)
|
|
window.addEventListener('contextual-ai-visibility', handleVisibility)
|
|
return () => {
|
|
window.removeEventListener('toggle-ai-chat', handleToggle)
|
|
window.removeEventListener('contextual-ai-visibility', handleVisibility)
|
|
}
|
|
}, [handleToggle, handleVisibility])
|
|
|
|
// Floating trigger when closed
|
|
if (!isOpen) {
|
|
if (!showFloatingTrigger || isContextualAIVisible) return null
|
|
return (
|
|
<motion.button
|
|
onClick={() => setIsOpen(true)}
|
|
className="fixed bottom-6 end-6 h-12 w-12 rounded-2xl shadow-xl z-40 bg-brand-accent text-white hover:scale-105 border border-brand-accent/20"
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
title={t('ai.openAssistant')}
|
|
>
|
|
<Sparkles className="h-5 w-5 mx-auto" />
|
|
</motion.button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<motion.aside
|
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
|
className={cn(
|
|
"fixed bottom-20 end-6 border border-border/60 bg-[#FDFCFB] dark:bg-[#0D0D0D] shadow-2xl flex flex-col z-50 rounded-2xl overflow-hidden transition-all duration-300",
|
|
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "w-[420px] h-[85vh] max-h-[800px]"
|
|
)}
|
|
>
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<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">
|
|
<h3 className="flex items-center gap-2 font-serif text-xl font-medium text-ink">
|
|
<Sparkles size={18} className="text-brand-accent" />
|
|
{t('ai.assistantTitle')}
|
|
</h3>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setIsExpanded(e => !e)}
|
|
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
|
title={isExpanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
|
|
>
|
|
{isExpanded ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
|
</button>
|
|
<button
|
|
onClick={() => { setMessages([]); setConversationId(undefined) }}
|
|
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
|
title={t('ai.newDiscussion')}
|
|
>
|
|
<Plus size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => setIsOpen(false)}
|
|
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>
|
|
<p className="text-[11px] text-concrete uppercase tracking-wider font-medium opacity-60">
|
|
{t('ai.poweredByMomento')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-border px-2 shrink-0">
|
|
{(['discussion', 'history'] as AITab[]).map((tab) => {
|
|
const labels: Record<AITab, string> = {
|
|
discussion: t('ai.chatTab') || 'Discussion',
|
|
history: t('ai.historyTab') || 'Historique',
|
|
}
|
|
const icons: Record<AITab, React.ReactNode> = {
|
|
discussion: <MessageSquare size={12} />,
|
|
history: <History size={12} />,
|
|
}
|
|
return (
|
|
<button
|
|
key={tab}
|
|
onClick={() => {
|
|
setAiTab(tab)
|
|
if (tab === 'history') fetchHistory()
|
|
}}
|
|
className={cn(
|
|
"flex-1 py-3 text-[10px] uppercase tracking-[0.2em] font-bold transition-all relative flex items-center justify-center gap-1.5",
|
|
aiTab === tab ? 'text-brand-accent' : 'text-concrete hover:text-ink/60'
|
|
)}
|
|
>
|
|
{icons[tab]}
|
|
{labels[tab]}
|
|
{aiTab === tab && (
|
|
<motion.div
|
|
layoutId="activeAiTab"
|
|
className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand-accent"
|
|
/>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
|
<AnimatePresence mode="wait">
|
|
{aiTab === 'discussion' && (
|
|
<motion.div key="discussion" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-6">
|
|
{/* Scope selector */}
|
|
<div className="space-y-2">
|
|
<button
|
|
onClick={() => setChatScope('all')}
|
|
className={cn(
|
|
'w-full p-2.5 border rounded-xl text-[11px] flex items-center justify-between transition-all',
|
|
chatScope === 'all' ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white/50 dark:bg-white/5 border-border/40 hover:border-ink/20'
|
|
)}
|
|
title={t('ai.allMyNotes') || 'Toutes mes notes'}
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<FileCode size={14} className="text-brand-accent/60" />
|
|
<span className={cn('font-medium', chatScope === 'all' ? 'text-brand-accent' : 'text-concrete')}>
|
|
{t('ai.allMyNotes') || 'Toutes mes notes'}
|
|
</span>
|
|
</div>
|
|
{chatScope === 'all' && (
|
|
<span className="text-[8px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.5 rounded-full uppercase font-bold">Auto</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 !== 'all' ? chatScope : null}
|
|
onSelect={(id) => setChatScope(id)}
|
|
placeholder={t('ai.selectNotebook') || 'Inclure un carnet...'}
|
|
dropUp
|
|
/>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
{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 text = getTextContent(msg)
|
|
if (msg.role === 'assistant' && !text) return null
|
|
return (
|
|
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
|
|
<div className={cn(
|
|
'w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0',
|
|
msg.role === 'user' ? 'bg-brand-accent text-white' : 'bg-brand-accent/10 text-brand-accent',
|
|
)}>
|
|
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
|
|
</div>
|
|
<div className={cn(
|
|
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed',
|
|
msg.role === 'user'
|
|
? 'bg-brand-accent text-white rounded-tr-sm'
|
|
: 'bg-white/60 dark:bg-white/5 border border-border rounded-tl-sm text-ink',
|
|
)}>
|
|
{msg.role === 'assistant' ? <MarkdownContent content={text} /> : <p>{text}</p>}
|
|
</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} />
|
|
</motion.div>
|
|
)}
|
|
|
|
{aiTab === 'history' && (
|
|
<motion.div key="history" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-3">
|
|
{historyLoading ? (
|
|
<div className="flex justify-center py-12">
|
|
<Loader2 className="h-6 w-6 animate-spin text-concrete" />
|
|
</div>
|
|
) : history.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-concrete/30">
|
|
<History size={24} />
|
|
<p className="text-[11px] mt-3 italic">{t('ai.noHistory') || 'Aucun historique'}</p>
|
|
</div>
|
|
) : (
|
|
history.map((conv: any) => (
|
|
<button
|
|
key={conv.id}
|
|
onClick={() => { setConversationId(conv.id); setMessages(conv.messages || []); setAiTab('discussion') }}
|
|
className="w-full text-left p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl hover:border-brand-accent/30 transition-all group"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-lg bg-brand-accent/10 text-brand-accent group-hover:bg-brand-accent group-hover:text-white transition-colors">
|
|
<MessageSquare size={14} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-ink truncate">{conv.title || conv.id}</p>
|
|
<p className="text-[10px] text-concrete mt-0.5">{new Date(conv.createdAt).toLocaleDateString()}</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* Textarea — only on discussion tab */}
|
|
<AnimatePresence>
|
|
{aiTab === 'discussion' && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
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 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")}
|
|
title={webSearch ? (t('ai.webSearchEnabled') || 'Recherche web activée') : (t('ai.webSearchDisabled') || 'Activer la recherche web')}
|
|
>
|
|
<Globe size={14} />
|
|
</button>
|
|
</div>
|
|
<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>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</motion.aside>
|
|
)
|
|
}
|