Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
Replaced ~100+ hardcoded French and English text strings across 30+ components with proper i18n t() calls. Added 57 new translation keys to all 15 locale files (ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh). Key changes: - contextual-ai-chat.tsx: 30 French strings → t() (actions, toasts, labels, placeholders) - ai-chat.tsx: 15 French/English strings → t() (header, tabs, welcome, insights, history) - note-inline-editor.tsx: 20 French fallbacks removed (toolbar, save status, checklist) - lab-skeleton.tsx: French loading text → t() - admin-header.tsx, header.tsx, editor-connections-section.tsx: French fallbacks removed - New AI chat component, agent cards, sidebar, settings panel i18n cleanup Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
409 lines
18 KiB
TypeScript
409 lines
18 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 } 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'
|
|
|
|
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 } = useChat({
|
|
transport,
|
|
onError: (error) => {
|
|
console.error('Chat error:', error)
|
|
}
|
|
})
|
|
|
|
const isLoading = status === 'submitted' || status === 'streaming'
|
|
|
|
const handleSend = async () => {
|
|
const text = input.trim()
|
|
if (!text || isLoading) return
|
|
setInput('')
|
|
await sendMessage(
|
|
{ text },
|
|
{
|
|
body: {
|
|
tone: selectedTone,
|
|
chatScope,
|
|
notebookId: chatScope !== 'all' ? chatScope : undefined,
|
|
webSearch: webSearch && webSearchAvailable,
|
|
conversationId
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
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={() => 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) => (
|
|
<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={msg.parts?.map(p => 'text' in p ? p.text : '').join('') || ''} />
|
|
: <p>{msg.parts?.map(p => 'text' in p ? p.text : '').join('') || ''}</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">
|
|
<div className="flex items-center gap-2">
|
|
{chatScope === 'all' ? <Layers className="h-3.5 w-3.5 text-primary" /> : <BookOpen className="h-3.5 w-3.5 text-primary" />}
|
|
<SelectValue placeholder={t('ai.selectNotebook')} />
|
|
</div>
|
|
</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>
|
|
<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={isLoading || !input.trim()}
|
|
>
|
|
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4 ml-0.5" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|