Files
Momento/memento-note/components/contextual-ai-chat.tsx
sepehr 153c921960
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
fix: comprehensive i18n — replace hardcoded French/English strings with t() calls
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>
2026-04-26 21:14:45 +02:00

521 lines
24 KiB
TypeScript

'use client'
import { useRef, useEffect, useState } 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, Send, Loader2,
Briefcase, Palette, GraduationCap, Coffee,
Lightbulb, Minimize2, AlignLeft, Wand2,
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
import { toast } from 'sonner'
import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { getNotebookIcon } from '@/lib/notebook-icon'
// ── Helpers ──────────────────────────────────────────────────────────────────
function getMessageContent(msg: UIMessage): string {
if (typeof (msg as any).content === 'string') return (msg as any).content
if (msg.parts && Array.isArray(msg.parts)) {
return msg.parts
.filter((p: any) => p.type === 'text')
.map((p: any) => p.text)
.join('')
}
return ''
}
// ── Constants ─────────────────────────────────────────────────────────────────
const TONES = [
{ id: 'professional', label: 'Pro', full: 'Professional', icon: Briefcase },
{ id: 'creative', label: 'Create', full: 'Creative', icon: Palette },
{ id: 'academic', label: 'Acad.', full: 'Academic', icon: GraduationCap },
{ id: 'casual', label: 'Casual', full: 'Casual', icon: Coffee },
]
interface ActionDef {
id: string
icon: any
apiPath: string
body: (content: string) => object
resultKey: string
i18nKey: string
}
const ACTION_IDS = [
{ id: 'clarify', icon: Lightbulb, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'clarify' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.clarify' },
{ id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'shorten' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' },
{ id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'improve' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' },
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
]
// ── Types ─────────────────────────────────────────────────────────────────────
interface ContextualAIChatProps {
onClose: () => void
noteTitle?: string
noteContent?: string
/** Called when an action result should be injected into the note */
onApplyToNote?: (newContent: string) => void
/** Called when the user wants to undo the last injected action */
onUndoLastAction?: () => void
/** Whether the last action has been applied (so we can show undo) */
lastActionApplied?: boolean
/** Notebooks available for scope selection */
notebooks?: Array<{ id: string; name: string }>
/** Extra classes forwarded to the aside root element */
className?: string
}
// ── Component ─────────────────────────────────────────────────────────────────
export function ContextualAIChat({
onClose,
noteTitle,
noteContent,
onApplyToNote,
onUndoLastAction,
lastActionApplied = false,
notebooks = [],
className,
}: ContextualAIChatProps) {
const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable()
const [activeTab, setActiveTab] = useState<'chat' | 'actions'>('chat')
const [selectedTone, setSelectedTone] = useState('professional')
const [input, setInput] = useState('')
const [chatScope, setChatScope] = useState<'note' | 'all' | string>('note') // 'note', 'all', or notebook ID
const [webSearch, setWebSearch] = useState(false)
const [expanded, setExpanded] = useState(false)
// Action state
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
const buildChatBody = () => {
const body: Record<string, any> = { language, webSearch }
if (chatScope === 'note') {
body.noteContext = { title: noteTitle || '', content: noteContent || '', tone: selectedTone }
} else if (chatScope !== 'all') {
// scope is a notebook ID
body.notebookId = chatScope
}
return body
}
const { messages, sendMessage, status } = useChat({ transport })
const isLoading = status === 'submitted' || status === 'streaming'
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
useEffect(() => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
return () => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false }))
}
}, [])
// ── Chat send ───────────────────────────────────────────────────────────────
const handleSend = async () => {
const text = input.trim()
if (!text || isLoading) return
setInput('')
await sendMessage({ text }, { body: buildChatBody() })
}
// ── Action execution ────────────────────────────────────────────────────────
const handleAction = async (action: ActionDef) => {
const wc = (noteContent || '').split(/\s+/).filter(Boolean).length
if (!noteContent || wc < 5) {
toast.error(t('ai.minWordsError'))
return
}
setActionLoading(action.id)
setActionPreview(null)
try {
const res = await fetch(action.apiPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.body(noteContent)),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
const result = data[action.resultKey] || ''
setActionPreview({ label: t(action.i18nKey), text: result })
} catch (e: any) {
toast.error(e.message || t('ai.actionError'))
} finally {
setActionLoading(null)
}
}
const handleApplyPreview = () => {
if (!actionPreview || !onApplyToNote) return
onApplyToNote(actionPreview.text)
setActionPreview(null)
toast.success(t('ai.appliedToNote'))
}
const handleDiscardPreview = () => setActionPreview(null)
// ── Scope label ─────────────────────────────────────────────────────────────
const scopeLabel =
chatScope === 'note' ? t('ai.thisNote')
: chatScope === 'all' ? t('ai.allMyNotes')
: notebooks.find(n => n.id === chatScope)?.name ?? t('ai.notebookGeneric')
return (
<aside className={cn(
'border-l border-border/40 bg-card flex flex-col self-stretch flex-shrink-0 z-10 transition-all duration-300',
expanded ? 'w-[560px]' : 'w-[360px]',
className,
)}>
{/* ── Header ───────────────────────────────────────────────── */}
<div className="px-4 py-3 border-b border-border/40 flex items-center justify-between shrink-0">
<div className="min-w-0">
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary shrink-0" />
{t('ai.assistantTitle')}
</h2>
<p className="text-xs text-muted-foreground truncate">
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
{lastActionApplied && onUndoLastAction && (
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/30"
onClick={onUndoLastAction}
title={t('ai.undoLastAction')}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
{/* Expand / Shrink */}
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setExpanded(e => !e)}
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
>
{expanded
? <Minimize2 className="h-3.5 w-3.5" />
: <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7 text-muted-foreground hover:text-foreground">
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* ── Tabs ─────────────────────────────────────────────────── */}
<div className="flex border-b border-border/40 shrink-0">
{(['chat', 'actions'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
'flex-1 py-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all capitalize',
activeTab === tab
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{tab === 'chat' && <Bot className="h-3.5 w-3.5" />}
{tab === 'actions' && <Wand2 className="h-3.5 w-3.5" />}
{tab === 'chat' ? t('ai.chatTab') : t('ai.noteActions')}
</button>
))}
</div>
{/* ══════════════════════════════════════════════════════════ */}
{/* ── TAB: CHAT ─────────────────────────────────────────── */}
{/* ══════════════════════════════════════════════════════════ */}
{activeTab === 'chat' && (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Messages */}
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3 flex flex-col">
{messages.length === 0 && (
<div className="flex-1 flex flex-col items-center justify-center text-center opacity-60">
<div className="w-14 h-14 rounded-full bg-primary/5 flex items-center justify-center border border-primary/10 mb-4">
<Sparkles className="h-6 w-6 text-primary/50" />
</div>
<p className="text-sm text-muted-foreground max-w-[220px]">
{t('ai.askToStart')}
</p>
</div>
)}
{messages.map((msg: UIMessage) => {
const content = getMessageContent(msg)
return (
<div key={msg.id} className={cn('flex gap-2', msg.role === 'user' && 'flex-row-reverse')}>
<div className={cn(
'w-6 h-6 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-3 w-3" />}
</div>
<div className={cn(
'max-w-[88%] p-3 rounded-2xl text-sm leading-relaxed',
msg.role === 'user'
? 'bg-primary text-primary-foreground rounded-tr-sm'
: 'bg-muted/40 border border-border/40 rounded-tl-sm text-foreground',
)}>
{msg.role === 'assistant'
? <MarkdownContent content={content} />
: <p>{content}</p>}
</div>
</div>
)
})}
{isLoading && (
<div className="flex gap-2">
<div className="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center flex-shrink-0 border border-primary/20">
<Bot className="h-3 w-3" />
</div>
<div className="bg-muted/40 border border-border/40 p-3 rounded-2xl rounded-tl-sm">
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Scope & Tone Control Area */}
<div className="border-t border-border/20 shrink-0 bg-muted/10">
{/* Scope bar */}
<div className="px-3 py-2 border-b border-border/10 flex flex-col gap-1.5">
<label className="text-xs font-bold tracking-wider text-muted-foreground uppercase">
{t('ai.contextLabel')}
</label>
<div className="flex items-center gap-2">
<Select value={chatScope} onValueChange={setChatScope}>
<SelectTrigger className="h-8 flex-1 text-sm bg-card">
<SelectValue placeholder={t('ai.selectContext')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="note" className="text-sm">
<div className="flex items-center gap-2">
<FileText className="h-3 w-3 text-muted-foreground" /> {t('ai.thisNote')}
</div>
</SelectItem>
<SelectItem value="all" className="text-sm">
<div className="flex items-center gap-2">
<Bot className="h-3 w-3 text-muted-foreground" /> {t('ai.allMyNotes')}
</div>
</SelectItem>
{notebooks.map(nb => (
<SelectItem key={nb.id} value={nb.id} className="text-xs">
<div className="flex items-center gap-2">
{(() => {
const Icon = getNotebookIcon((nb as any).icon)
return <Icon className="w-3 h-3 text-muted-foreground" />
})()}
{nb.name.length > 25 ? nb.name.slice(0, 25) + '…' : nb.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Tone selector */}
<div className="px-3 py-2 flex flex-col gap-1.5">
<label className="text-xs font-bold tracking-wider text-muted-foreground uppercase">
{t('ai.writingTone')}
</label>
<div className="grid grid-cols-4 gap-1">
{TONES.map(tone => {
const Icon = tone.icon
const sel = selectedTone === tone.id
return (
<button
key={tone.id}
onClick={() => setSelectedTone(tone.id)}
title={tone.full}
className={cn(
'py-1.5 rounded border text-xs font-medium transition-all flex flex-col items-center gap-1',
sel
? 'border-primary bg-primary/10 text-primary'
: 'border-border/40 text-muted-foreground hover:bg-muted bg-card',
)}
>
<Icon className="h-3 w-3" />
{tone.label}
</button>
)
})}
</div>
</div>
</div>
{/* Input */}
<div className="p-3 border-t border-border/40 shrink-0 bg-card">
<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">
<textarea
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/60 p-2 min-h-[64px] max-h-[120px]"
placeholder={
chatScope === 'note'
? t('ai.askAboutThisNote')
: t('ai.askAboutYourNotes')
}
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 pt-1">
<div className="flex items-center gap-2">
{webSearchAvailable && (
<button
onClick={() => setWebSearch(w => !w)}
title={t('ai.webSearchLabel')}
className={cn(
'flex items-center justify-center gap-1.5 h-8 px-3 rounded-md border transition-all text-xs font-medium',
webSearch
? 'border-emerald-500 bg-emerald-50 text-emerald-600 dark:bg-emerald-950/30'
: 'border-border/50 text-muted-foreground hover:bg-muted bg-card',
)}
>
<Globe className="h-3 w-3" />
Web
</button>
)}
</div>
<Button
size="icon"
className="h-7 w-7 rounded-lg bg-primary text-primary-foreground shadow-sm disabled:opacity-50"
onClick={handleSend}
disabled={isLoading || !input.trim()}
>
{isLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5 ml-0.5" />}
</Button>
</div>
</div>
<p className="text-[9px] text-muted-foreground/40 text-center mt-1">{t('ai.newLineHint')}</p>
</div>
</div>
)}
{/* ══════════════════════════════════════════════════════════ */}
{/* ── TAB: ACTIONS ─────────────────────────────────────── */}
{/* ══════════════════════════════════════════════════════════ */}
{activeTab === 'actions' && (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Preview panel — result to apply */}
{actionPreview ? (
<div className="flex flex-col flex-1 overflow-hidden">
<div className="px-4 py-2.5 border-b border-border/40 flex items-center justify-between shrink-0">
<p className="text-xs font-semibold text-foreground">{t('ai.resultLabel')} {actionPreview.label}</p>
<button onClick={handleDiscardPreview} className="text-muted-foreground hover:text-foreground">
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="text-xs leading-relaxed text-foreground bg-muted/30 border border-border/40 rounded-xl p-3 whitespace-pre-wrap">
{actionPreview.text}
</div>
</div>
<div className="p-3 border-t border-border/40 flex gap-2 shrink-0">
<Button
variant="ghost" size="sm"
className="flex-1 text-xs gap-1.5"
onClick={handleDiscardPreview}
>
<X className="h-3.5 w-3.5" /> {t('ai.discardAction')}
</Button>
<Button
size="sm"
className="flex-1 text-xs gap-1.5 bg-primary"
onClick={handleApplyPreview}
disabled={!onApplyToNote}
>
<Check className="h-3.5 w-3.5" /> {t('ai.applyToNote')}
</Button>
</div>
</div>
) : (
<div className="flex-1 overflow-y-auto p-4 space-y-2.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-3">
{t('ai.transformationsDesc')}
</p>
{!noteContent || noteContent.trim().split(/\s+/).filter(Boolean).length < 5 ? (
<div className="flex items-start gap-2 p-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-700 dark:text-amber-400">
{t('ai.writeMinWordsAction')}
</p>
</div>
) : (
ACTION_IDS.map(action => {
const Icon = action.icon
const loading = actionLoading === action.id
return (
<button
key={action.id}
onClick={() => handleAction(action)}
disabled={!!actionLoading}
className="w-full flex items-center gap-3 rounded-xl border border-border/60 bg-card px-4 py-3 text-sm font-medium text-foreground hover:bg-muted hover:border-primary/40 transition-all text-left disabled:opacity-60"
>
{loading
? <Loader2 className="h-4 w-4 text-primary animate-spin shrink-0" />
: <Icon className="h-4 w-4 text-primary shrink-0" />
}
<span>{t(action.i18nKey)}</span>
{loading && <span className="ml-auto text-[10px] text-muted-foreground">{t('ai.processingAction')}</span>}
</button>
)
})
)}
{/* Undo last action shortcut */}
{lastActionApplied && onUndoLastAction && (
<button
onClick={onUndoLastAction}
className="w-full mt-2 flex items-center gap-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 px-4 py-2.5 text-sm font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-950/50 transition-all text-left"
>
<RotateCcw className="h-4 w-4 shrink-0" />
{t('ai.undoLastAction')}
</button>
)}
</div>
)}
</div>
)}
</aside>
)
}