All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m11s
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>
450 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|