All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m24s
466 lines
20 KiB
TypeScript
466 lines
20 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useEffect } 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, History, Send, Globe, Briefcase, Palette, GraduationCap, Coffee, Loader2, BookOpen, Layers, Square, Plus } 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'
|
|
|
|
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 ''
|
|
}
|
|
|
|
const TONES = [
|
|
{ id: 'professional', label: 'Professional', icon: Briefcase },
|
|
{ id: 'creative', label: 'Creative', icon: Palette },
|
|
{ id: 'academic', label: 'Academic', icon: GraduationCap },
|
|
{ id: 'casual', label: 'Casual', icon: Coffee },
|
|
]
|
|
|
|
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 [activeTab, setActiveTab] = useState('chat')
|
|
const [isContextualAIVisible, setIsContextualAIVisible] = useState(false)
|
|
const [selectedTone, setSelectedTone] = useState('professional')
|
|
const [webSearch, setWebSearch] = useState(false)
|
|
const [chatScope, setChatScope] = useState<'all' | string>('all')
|
|
const [input, setInput] = useState('')
|
|
const [conversationId, setConversationId] = useState<string | undefined>()
|
|
|
|
const [history, setHistory] = useState<any[]>([])
|
|
const [historyLoading, setHistoryLoading] = useState(false)
|
|
|
|
const [insights, setInsights] = useState<string>('')
|
|
const [insightsLoading, setInsightsLoading] = 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)
|
|
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('')
|
|
|
|
// Create conversation upfront so we have the ID for continuity
|
|
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: {
|
|
tone: selectedTone,
|
|
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)
|
|
}
|
|
|
|
const fetchInsights = async () => {
|
|
setInsightsLoading(true)
|
|
try {
|
|
const res = await fetch('/api/chat/insights')
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setInsights(data.insight)
|
|
}
|
|
} catch (e) { console.error(e) }
|
|
setInsightsLoading(false)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'history') fetchHistory()
|
|
if (activeTab === 'insights' && !insights) fetchInsights()
|
|
}, [activeTab])
|
|
|
|
useEffect(() => {
|
|
if (messagesEndRef.current) {
|
|
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
|
}
|
|
}, [messages])
|
|
|
|
useEffect(() => {
|
|
const handleToggle = () => setIsOpen(v => !v)
|
|
const handleVisibility = (e: any) => setIsContextualAIVisible(e.detail)
|
|
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)
|
|
}
|
|
}, [])
|
|
|
|
if (!isOpen) {
|
|
if (isContextualAIVisible) return null
|
|
if (!showFloatingTrigger) return null
|
|
return (
|
|
<Button
|
|
onClick={() => setIsOpen(true)}
|
|
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105 bg-muted text-foreground hover:bg-muted/80 border border-border"
|
|
size="icon"
|
|
title={t('ai.openAssistant')}
|
|
>
|
|
<Sparkles className="h-5 w-5 text-memento-accent" />
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<aside className={cn(
|
|
"fixed bottom-20 right-6 border border-border/40 bg-memento-paper dark:bg-background flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
|
|
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "h-[700px] max-h-[85vh] w-[360px]"
|
|
)}>
|
|
{/* Header */}
|
|
<div className="px-5 py-4 border-b border-border/40 flex items-center justify-between">
|
|
<div>
|
|
<h2 className="font-heading text-lg font-semibold text-foreground tracking-tight">{t('ai.assistantTitle')}</h2>
|
|
<p className="text-xs text-muted-foreground font-medium">{t('ai.poweredByMomento')}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="ghost" size="icon" onClick={() => { setMessages([]); setConversationId(undefined) }} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted" title={t('ai.newDiscussion')}>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
|
|
{isExpanded ? (
|
|
<>
|
|
<polyline points="4 14 10 14 10 20"></polyline>
|
|
<polyline points="20 10 14 10 14 4"></polyline>
|
|
<line x1="14" y1="10" x2="21" y2="3"></line>
|
|
<line x1="3" y1="21" x2="10" y2="14"></line>
|
|
</>
|
|
) : (
|
|
<>
|
|
<polyline points="15 3 21 3 21 9"></polyline>
|
|
<polyline points="9 21 3 21 3 15"></polyline>
|
|
<line x1="21" y1="3" x2="14" y2="10"></line>
|
|
<line x1="3" y1="21" x2="10" y2="14"></line>
|
|
</>
|
|
)}
|
|
</svg>
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted">
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation Tabs */}
|
|
<div className="flex px-4 pt-4 border-b border-border/40 shrink-0">
|
|
<button
|
|
onClick={() => setActiveTab('chat')}
|
|
className={cn(
|
|
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
|
activeTab === 'chat' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<Bot className="h-4 w-4" /> {t('ai.chatTab')}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('insights')}
|
|
className={cn(
|
|
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
|
activeTab === 'insights' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<Sparkles className="h-4 w-4" /> {t('ai.insightsTab')}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('history')}
|
|
className={cn(
|
|
"flex-1 pb-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all",
|
|
activeTab === 'history' ? "border-memento-blue text-memento-blue" : "border-transparent text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<History className="h-4 w-4" /> {t('ai.historyTab')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
<div className="flex-1 overflow-y-auto p-5 space-y-6">
|
|
{activeTab === 'chat' && (
|
|
<>
|
|
{/* AI Welcome Message */}
|
|
{messages.length === 0 && (
|
|
<div className="flex gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
|
|
<Bot className="h-4 w-4" />
|
|
</div>
|
|
<div className="bg-memento-paper dark:bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
|
<p className="text-sm text-foreground leading-relaxed">
|
|
{t('ai.welcomeMsg')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Messages */}
|
|
{messages.map((msg: UIMessage) => {
|
|
const text = getTextContent(msg)
|
|
// Skip empty assistant messages (thinking/reasoning phase)
|
|
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-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
|
|
msg.role === 'user'
|
|
? 'bg-muted border-border text-muted-foreground'
|
|
: 'bg-memento-blue/10 text-memento-blue border-memento-blue/20',
|
|
)}>
|
|
{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 shadow-sm',
|
|
msg.role === 'user'
|
|
? 'bg-memento-blue text-white rounded-tr-sm'
|
|
: 'bg-memento-paper dark:bg-background border border-border/50 rounded-tl-sm text-foreground',
|
|
)}>
|
|
{msg.role === 'assistant'
|
|
? <MarkdownContent content={text} />
|
|
: <p>{text}</p>}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{isLoading && (
|
|
<div className="flex gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
|
|
<Bot className="h-4 w-4" />
|
|
</div>
|
|
<div className="bg-memento-paper dark:bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'insights' && (
|
|
<div className="h-full">
|
|
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2"><Sparkles className="h-4 w-4 text-memento-accent" /> {t('ai.summaryLast5')}</h3>
|
|
{insightsLoading ? (
|
|
<div className="flex flex-col items-center justify-center py-10 opacity-60">
|
|
<Loader2 className="h-8 w-8 animate-spin mb-4 text-muted-foreground" />
|
|
<p className="text-xs text-muted-foreground">{t('ai.analyzingProgress')}</p>
|
|
</div>
|
|
) : insights ? (
|
|
<div className="prose prose-sm dark:prose-invert">
|
|
<MarkdownContent content={insights} />
|
|
</div>
|
|
) : (
|
|
<div className="flex justify-center mt-6">
|
|
<Button onClick={fetchInsights} variant="outline" size="sm">{t('ai.generateInsightsBtn')}</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'history' && (
|
|
<div className="space-y-3">
|
|
{historyLoading ? (
|
|
<div className="flex justify-center py-10 opacity-60">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : history.length > 0 ? (
|
|
history.map(conv => (
|
|
<button
|
|
key={conv.id}
|
|
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-memento-blue/30 transition-all flex flex-col gap-1"
|
|
onClick={() => {
|
|
setConversationId(conv.id)
|
|
setMessages(conv.messages.map((m: any) => ({
|
|
id: m.id,
|
|
role: m.role,
|
|
content: m.content,
|
|
parts: [{ type: 'text', text: m.content }]
|
|
})))
|
|
setActiveTab('chat')
|
|
}}
|
|
>
|
|
<span className="text-sm font-medium line-clamp-1">{conv.title || conv.messages[0]?.content || t('ai.newDiscussion')}</span>
|
|
<span className="text-[10px] text-muted-foreground">{new Date(conv.updatedAt).toLocaleString()}</span>
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-10 opacity-60 text-center space-y-2">
|
|
<History className="h-8 w-8 text-muted-foreground" />
|
|
<p className="text-sm">{t('ai.noRecentConversations')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input Area & Tone Controls (Only in Chat tab) */}
|
|
<div className={cn("p-4 border-t border-border/40 bg-memento-paper dark:bg-background shrink-0", activeTab !== 'chat' && "hidden")}>
|
|
{/* Context Scope */}
|
|
<div className="mb-3 space-y-2">
|
|
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block ml-1">Source du Contexte</span>
|
|
<button
|
|
onClick={() => setChatScope('all')}
|
|
className={cn(
|
|
'w-full p-2.5 border rounded-lg text-xs flex items-center justify-between transition-all',
|
|
chatScope === 'all' ? 'bg-blueprint/10 border-blueprint/30' : 'bg-card border-border hover:border-foreground/20'
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Layers className="h-3.5 w-3.5 text-blueprint/60" />
|
|
<span className={cn('font-medium', chatScope === 'all' ? 'text-blueprint' : 'text-foreground/60')}>
|
|
{t('ai.allMyNotes') || 'Toutes mes notes'}
|
|
</span>
|
|
</div>
|
|
{chatScope === 'all' && (
|
|
<span className="text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded 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-muted-foreground 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>
|
|
|
|
{/* Tone Selection */}
|
|
<div className="mb-3">
|
|
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.writingTone')}</span>
|
|
<div className="grid grid-cols-4 gap-1">
|
|
{TONES.map(tone => {
|
|
const Icon = tone.icon
|
|
const isSelected = selectedTone === tone.id
|
|
return (
|
|
<button
|
|
key={tone.id}
|
|
onClick={() => setSelectedTone(tone.id)}
|
|
title={tone.label}
|
|
className={cn(
|
|
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
|
|
isSelected
|
|
? "border-memento-blue bg-memento-blue/10 text-memento-blue shadow-sm"
|
|
: "border-border/60 bg-memento-paper dark:bg-background text-muted-foreground hover:bg-muted hover:border-border"
|
|
)}
|
|
>
|
|
<Icon className="h-3 w-3" />
|
|
<span className="hidden sm:inline text-[9px]">{tone.label.slice(0, 4)}.</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Text Input */}
|
|
<div className="relative bg-memento-paper dark:bg-background border border-border/60 rounded-xl p-1 focus-within:border-memento-blue focus-within:ring-1 focus-within:ring-memento-blue/20 transition-all shadow-sm">
|
|
<textarea
|
|
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]"
|
|
placeholder={t('ai.chatPlaceholder')}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
|
}}
|
|
disabled={isLoading}
|
|
/>
|
|
<div className="flex justify-between items-center px-1 pb-1">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn("h-7 px-2 text-[10px] gap-1", webSearch ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-100 hover:text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400" : "text-muted-foreground hover:text-foreground")}
|
|
onClick={() => webSearchAvailable && setWebSearch(!webSearch)}
|
|
disabled={!webSearchAvailable}
|
|
title={webSearchAvailable ? t('ai.webSearchLabel') : t('ai.webSearchNotConfigured')}
|
|
>
|
|
<Globe className="h-3.5 w-3.5" />
|
|
Web{webSearchAvailable ? '' : ' ⚠'}
|
|
</Button>
|
|
{isLoading ? (
|
|
<Button
|
|
size="icon"
|
|
className="h-8 w-8 rounded-lg bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all"
|
|
onClick={() => stop()}
|
|
>
|
|
<Square className="h-4 w-4" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="icon"
|
|
className="h-8 w-8 rounded-lg bg-memento-blue text-white shadow-sm hover:shadow-md transition-all"
|
|
onClick={handleSend}
|
|
disabled={!input.trim()}
|
|
>
|
|
<Send className="h-4 w-4 ml-0.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|