All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 4s
Covers architecture, configuration steps, user flows, API routes, webhooks, pricing, testing with Stripe CLI, production checklist, and troubleshooting.
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-12">{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>
|
|
)
|
|
}
|