'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, Square, 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 noteImages?: 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, noteImages, 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(null) const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null) const messagesEndRef = useRef(null) const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current const buildChatBody = () => { const body: Record = { language, webSearch } if (chatScope === 'note') { body.noteContext = { title: noteTitle || '', content: noteContent || '', tone: selectedTone, images: noteImages || [], } } else if (chatScope !== 'all') { // scope is a notebook ID body.notebookId = chatScope } return body } const { messages, sendMessage, status, stop } = 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 (