Files
Momento/memento-note/components/ai-chat.tsx
sepehr b92f6384a4
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m11s
fix: chat memory lost between messages + per-note history
Chat (AIChat floating widget): conversationId was never captured from
the API response, so every message created a new conversation with no
context. Now creates the conversation upfront before streaming (same
pattern as ChatContainer) so the ID persists across messages.

Note history: was stored globally in UserAISettings, so enabling
history on one note enabled it for ALL notes. Now each Note has its
own historyEnabled boolean field. The "Enable history" action only
affects the specific note. A migration adds the column with default
false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:18:46 +02:00

450 lines
19 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
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() {
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
}
}
await sendMessage(
{ text },
{
body: {
tone: selectedTone,
chatScope,
notebookId: chatScope !== 'all' ? chatScope : undefined,
webSearch: webSearch && webSearchAvailable,
conversationId: convId,
language,
}
}
)
}
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
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"
size="icon"
title={t('ai.openAssistant')}
>
<Sparkles className="h-5 w-5" />
</Button>
)
}
return (
<aside className={cn(
"fixed bottom-20 right-6 border border-border/40 bg-card 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-primary text-primary" : "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-primary text-primary" : "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-primary text-primary" : "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-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
<Bot className="h-4 w-4" />
</div>
<div className="bg-muted/30 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-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
: 'bg-primary/10 text-primary border-primary/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-primary text-primary-foreground rounded-tr-sm'
: 'bg-muted/30 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-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
<Bot className="h-4 w-4" />
</div>
<div className="bg-muted/30 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-primary" /> {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-primary/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-muted/10 shrink-0", activeTab !== 'chat' && "hidden")}>
{/* Context Scope */}
<div className="mb-3">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.discussionContextLabel')}</span>
<Select value={chatScope} onValueChange={setChatScope}>
<SelectTrigger className="h-8 text-xs bg-card border-border/60">
<SelectValue placeholder={t('ai.selectNotebook')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<span>{t('ai.allMyNotes')}</span>
</div>
</SelectItem>
{notebooks && notebooks.length > 0 && notebooks.map(nb => (
<SelectItem key={nb.id} value={nb.id}>
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-muted-foreground" />
<span>{nb.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</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-primary bg-primary/10 text-primary shadow-sm"
: "border-border/60 bg-card 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-card border border-border/60 rounded-xl p-1 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/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-primary text-primary-foreground 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>
)
}