Files
Momento/memento-note/components/contextual-ai-chat.tsx

1381 lines
71 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, Square,
Briefcase, Palette, GraduationCap, Coffee,
Lightbulb, Minimize2, AlignLeft, Wand2,
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
GitMerge, PlusCircle, Eye, Code, Languages,
Presentation, PenTool, ExternalLink, ImagePlus,
} from 'lucide-react'
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
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'
import { scrapePageText } from '@/app/actions/scrape'
// ── 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, images?: string[], lang?: string) => object
resultKey: string
i18nKey: string
isImageAction?: boolean
}
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: 'translate', icon: Languages, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string) => ({ text: content, option: 'translate', language: lang || 'fr' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.translate' },
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
{ id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true },
]
// ── Types ─────────────────────────────────────────────────────────────────────
interface GenerateResult {
type: 'slides' | 'diagram'
canvasId?: string
noteId?: string
}
interface ContextualAIChatProps {
onClose: () => void
noteTitle?: string
noteContent?: string
noteImages?: string[]
noteId?: 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
/** How to embed generated diagram images (markdown vs rich text HTML) */
diagramInsertFormat?: 'markdown' | 'html'
}
// ── Component ─────────────────────────────────────────────────────────────────
export function ContextualAIChat({
onClose,
noteTitle,
noteContent,
noteImages,
noteId,
onApplyToNote,
onUndoLastAction,
lastActionApplied = false,
notebooks = [],
className,
diagramInsertFormat = 'markdown',
}: ContextualAIChatProps) {
const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable()
const [activeTab, setActiveTab] = useState<'chat' | 'actions' | 'resource'>('chat')
const [selectedTone, setSelectedTone] = useState('professional')
const [input, setInput] = useState('')
const [chatScope, setChatScope] = useState<'note' | 'all' | string>('note')
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 [showLangPicker, setShowLangPicker] = useState(false)
const [translateTarget, setTranslateTarget] = useState('')
// Generate slides / diagram state
const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null)
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
const [customLangInput, setCustomLangInput] = useState('')
// Generation options
const [slideTheme, setSlideTheme] = useState('vibrant_tech')
const [slideStyle, setSlideStyle] = useState('soft')
const [diagramType, setDiagramType] = useState('auto')
const [diagramStyle, setDiagramStyle] = useState('default')
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
// Resource tab state
const [resourceUrl, setResourceUrl] = useState('')
const [resourceText, setResourceText] = useState('')
const [resourceScraping, setResourceScraping] = useState(false)
const [resourceMode, setResourceMode] = useState<'replace' | 'complete' | 'merge'>('complete')
const [resourcePreview, setResourcePreview] = useState<{ text: string; source: string } | null>(null)
const [resourceEnriching, setResourceEnriching] = useState(false)
const [resourcePreviewFormat, setResourcePreviewFormat] = useState<'rendered' | 'markdown'>('rendered')
// hoveredMsgId: which chat message shows inject actions
const [hoveredMsgId, setHoveredMsgId] = useState<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,
images: noteImages || [],
}
} else if (chatScope !== 'all') {
// scope is a notebook ID
body.notebookId = chatScope
}
return body
}
const { messages, sendMessage, status, stop } = useChat({ transport })
const lastMsg = messages[messages.length - 1]
const lastMsgHasContent = lastMsg?.role === 'assistant' && !!getMessageContent(lastMsg)
const isLoading = (status === 'submitted' || status === 'streaming') && !lastMsgHasContent
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, resourcePreview])
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, targetLang?: string) => {
// Image-specific action
if (action.isImageAction) {
if (!noteImages || noteImages.length === 0) {
toast.error(t('ai.noImagesError') || 'Aucune image dans cette note')
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('', noteImages, language)),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
// Format image descriptions for preview
const descs = data.descriptions || []
let resultText = descs.map((d: any) =>
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
).join('\n\n')
if (data.combinedSummary) {
resultText += `\n\n---\n**${t('ai.overview') || 'Résumé'}:** ${data.combinedSummary}`
}
setActionPreview({ label: t(action.i18nKey), text: resultText })
} catch (e: any) {
toast.error(e.message || t('ai.actionError'))
} finally {
setActionLoading(null)
}
return
}
// Text-based actions
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, undefined, targetLang || language)),
})
const data = await res.json()
if (!res.ok) {
// Use i18n key if provided by the server
if (data.errorKey) {
throw new Error(t(data.errorKey, data.params || {}))
}
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)
// ── Generate slides / diagram ────────────────────────────────────────────────
const generatePollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const handleGenerate = async (type: 'slides' | 'diagram') => {
if (!noteId) {
toast.error(t('ai.generate.noNoteId') || 'Note non sauvegardée')
return
}
setGenerateLoading(type)
setGenerateResult(null)
// Persistent loading toast — layout-level (Sonner), survives navigation
const toastId = toast.loading(
type === 'slides'
? (t('ai.generate.toastLoading.slides') || '⏳ Génération de la présentation en cours…')
: (t('ai.generate.toastLoading.diagram') || '⏳ Génération du diagramme en cours…'),
{ duration: Infinity, description: t('ai.generate.toastLoadingDesc') || 'Vous pouvez naviguer librement, une notification apparaîtra.' }
)
try {
// POST starts the agent immediately and returns agentId (fire-and-forget on server)
const res = await fetch('/api/agents/run-for-note', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
noteId,
type: type === 'slides' ? 'slide-generator' : 'excalidraw-generator',
theme: type === 'slides' ? slideTheme : diagramType,
style: type === 'slides' ? slideStyle : diagramStyle,
}),
})
const data = await res.json()
if (!res.ok || !data.success) {
toast.error(data.error || t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
setGenerateLoading(null)
return
}
const { agentId } = data as { agentId: string }
// Poll status every 3 s until terminal state
if (generatePollRef.current) clearInterval(generatePollRef.current)
generatePollRef.current = setInterval(async () => {
try {
const pollRes = await fetch(`/api/agents/run-for-note?agentId=${agentId}`)
const poll = await pollRes.json()
if (poll.status === 'success') {
clearInterval(generatePollRef.current!)
generatePollRef.current = null
setGenerateLoading(null)
setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId })
if (type === 'slides' && poll.canvasId) {
toast.success(t('ai.generate.slidesReady') || 'Présentation générée !', {
id: toastId, duration: 10000,
description: t('ai.generate.toastSuccessSlides') || 'Cliquez sur Télécharger dans le panneau IA.',
action: { label: t('ai.generate.downloadPptx') || 'Télécharger', onClick: () => window.open(`/api/canvas/download?id=${poll.canvasId}`, '_blank') },
})
} else if (type === 'diagram' && poll.canvasId) {
toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', {
id: toastId, duration: 8000,
description: t('ai.generate.diagramReadyHint') || '',
})
} else {
toast.success(type === 'slides'
? (t('ai.generate.slidesReady') || 'Présentation générée !')
: (t('ai.generate.diagramReady') || 'Diagramme généré !'),
{ id: toastId }
)
}
} else if (poll.status === 'failure') {
clearInterval(generatePollRef.current!)
generatePollRef.current = null
setGenerateLoading(null)
toast.error(poll.error || t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
}
// If still 'running', do nothing — next poll will check again
} catch {
// Network error during poll — keep polling
}
}, 3000)
} catch {
toast.error(t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
setGenerateLoading(null)
}
}
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (generatePollRef.current) clearInterval(generatePollRef.current)
}
}, [])
const buildDiagramImageSnippet = (imageUrl: string, alt: string) => {
if (diagramInsertFormat === 'html') {
const safeAlt = alt.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
return `\n<p><img src="${imageUrl}" alt="${safeAlt}" loading="lazy" style="max-width:100%;height:auto;border-radius:8px;" /></p>\n`
}
const safeMdAlt = alt.replace(/[\[\]]/g, '')
return `\n\n![${safeMdAlt}](${imageUrl})\n\n`
}
const handleEmbedDiagramInNote = async (canvasId: string) => {
if (!onApplyToNote) {
toast.error(t('ai.generate.insertNeedEditor'))
return
}
setDiagramEmbedLoading(true)
try {
const res = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`)
const data = await res.json()
if (!res.ok || !data.canvas?.data) {
toast.error(data.error || t('ai.generate.insertFetchError'))
return
}
const blob = await exportExcalidrawSceneToPngBlob(data.canvas.data)
if (!blob) {
toast.error(t('ai.generate.insertExportError'))
return
}
const fd = new FormData()
fd.append('file', blob, `diagram-${canvasId.slice(-8)}.png`)
const up = await fetch('/api/upload', { method: 'POST', body: fd })
const upJson = await up.json()
if (!up.ok || !upJson.url) {
toast.error(upJson.error || t('ai.generate.insertUploadError'))
return
}
const alt = t('ai.generate.diagramImageAlt')
const snippet = buildDiagramImageSnippet(upJson.url as string, alt)
onApplyToNote(`${noteContent ?? ''}${snippet}`)
toast.success(t('ai.generate.insertedInNote'))
} catch (e) {
console.error('[embed diagram]', e)
toast.error(t('ai.generate.insertExportError'))
} finally {
setDiagramEmbedLoading(false)
}
}
// ── Resource tab handlers ────────────────────────────────────────────────────
const handleScrapeUrl = async () => {
if (!resourceUrl.trim()) return
setResourceScraping(true)
try {
const result = await scrapePageText(resourceUrl.trim())
if (!result) { toast.error(t('ai.resource.failedToLoadUrl')); return }
setResourceText(result.text)
toast.success(t('ai.resource.pageLoaded', { title: result.title.slice(0, 40) }))
} catch {
toast.error(t('ai.resource.pageLoadError'))
} finally {
setResourceScraping(false)
}
}
const handleResourcePreview = async () => {
if (!resourceText.trim()) { toast.error(t('ai.resource.pasteOrUrlFirst')); return }
if (resourceMode === 'replace') {
setResourcePreview({ text: resourceText, source: 'paste' })
return
}
setResourceEnriching(true)
try {
const res = await fetch('/api/ai/enrich-from-resource', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
existingContent: noteContent || '',
resourceText: resourceText.trim(),
mode: resourceMode,
language,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.resource.enrichError'))
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
} catch (e: any) {
toast.error(e.message || t('ai.resource.enrichError'))
} finally {
setResourceEnriching(false)
}
}
const handleApplyResourcePreview = () => {
if (!resourcePreview || !onApplyToNote) return
onApplyToNote(resourcePreview.text)
setResourcePreview(null)
setResourceText('')
setResourceUrl('')
toast.success(t('ai.resource.contentApplied'))
}
/** Called from chat hover-actions: inject a chat message into the note */
const handleInjectFromChat = async (msgText: string, mode: 'replace' | 'complete' | 'merge') => {
if (mode === 'replace') {
// Stay on chat tab — show preview inline via resourcePreview without switching tabs
setResourceText(msgText)
setResourceMode('replace')
setResourcePreview({ text: msgText, source: 'chat' })
return
}
setResourceText(msgText)
setResourceMode(mode)
// Do NOT switch tabs — enrich and show preview in current tab
setResourceEnriching(true)
try {
const res = await fetch('/api/ai/enrich-from-resource', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
existingContent: noteContent || '',
resourceText: msgText,
mode,
language,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Erreur IA')
setResourcePreview({ text: data.enrichedContent, source: mode })
} catch (e: any) {
toast.error(e.message || t('ai.resource.enrichErrorShort'))
} finally {
setResourceEnriching(false)
}
}
// ── 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-5 pt-5 pb-4 border-b border-border/40 shrink-0">
<div className="flex items-start justify-between">
<div className="min-w-0">
<h2 className="font-memento-serif text-xl font-medium text-foreground flex items-center gap-2 leading-tight">
<Sparkles className="h-4 w-4 text-amber-500 shrink-0" />
{t('ai.aiNoteTitle')}
</h2>
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.15em] mt-1 truncate font-medium">
{noteTitle ? noteTitle : t('ai.currentNote')}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0 -mt-1">
{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>
)}
<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>
</div>
{/* ── Tabs ── */}
<div className="flex border-b border-border/40 shrink-0">
{([
{ id: 'chat', icon: Bot, label: 'Chat' },
{ id: 'actions', icon: Wand2, label: 'Actions' },
{ id: 'resource', icon: ArrowDownToLine, label: 'Source' },
] as const).map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={cn(
'relative flex-1 py-2.5 text-[11px] font-semibold uppercase tracking-wider flex items-center justify-center gap-1.5 transition-all whitespace-nowrap',
activeTab === tab.id
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
<tab.icon className="h-3.5 w-3.5 shrink-0" />
{tab.label}
{activeTab === tab.id && (
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground" />
)}
</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 gap-3 py-8">
<div className="w-10 h-10 rounded-full bg-amber-50 dark:bg-amber-950/30 border border-amber-200/50 flex items-center justify-center">
<Sparkles className="h-5 w-5 text-amber-500" />
</div>
<p className="text-[13px] text-muted-foreground text-center max-w-[200px] leading-relaxed">
{t('ai.askToStart')}
</p>
</div>
)}
{messages.map((msg: UIMessage) => {
const content = getMessageContent(msg)
if (msg.role === 'assistant' && !content) return null
const isAssistant = msg.role === 'assistant'
const isHovered = hoveredMsgId === msg.id
return (
<div
key={msg.id}
className={cn('flex gap-2', !isAssistant && 'flex-row-reverse')}
onMouseEnter={() => isAssistant && setHoveredMsgId(msg.id)}
onMouseLeave={() => setHoveredMsgId(null)}
>
<div className={cn(
'w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
!isAssistant
? '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',
)}>
{!isAssistant ? 'U' : <Bot className="h-3 w-3" />}
</div>
<div className="flex flex-col gap-1 max-w-[88%]">
<div className={cn(
'p-3 rounded-2xl text-sm leading-relaxed',
!isAssistant
? 'bg-primary text-primary-foreground rounded-tr-sm'
: 'bg-muted/40 border border-border/40 rounded-tl-sm text-foreground',
)}>
{isAssistant
? <MarkdownContent content={content} />
: <p>{content}</p>}
</div>
{/* Inject buttons — always visible on last assistant msg */}
{isAssistant && onApplyToNote && (() => {
const lastAssistantId = messages.filter(m => m.role === 'assistant').at(-1)?.id
const alwaysShow = msg.id === lastAssistantId
return (
<div className={cn(
'flex gap-1 transition-all duration-150',
(alwaysShow || isHovered) ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}>
<button
onClick={() => handleInjectFromChat(content, 'replace')}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-primary/10 hover:text-primary border border-border/40 transition-colors"
title={t('ai.injectReplaceTitle')}
>
<Download className="h-2.5 w-2.5" /> {t('ai.injectReplace')}
</button>
<button
onClick={() => handleInjectFromChat(content, 'complete')}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-950/30 border border-border/40 transition-colors"
title={t('ai.injectCompleteTitle')}
>
<PlusCircle className="h-2.5 w-2.5" /> {t('ai.injectComplete')}
</button>
<button
onClick={() => handleInjectFromChat(content, 'merge')}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-violet-50 hover:text-violet-600 dark:hover:bg-violet-950/30 border border-border/40 transition-colors"
title={t('ai.injectMergeTitle')}
>
<GitMerge className="h-2.5 w-2.5" /> {t('ai.injectMerge')}
</button>
</div>
)
})()}
</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>
)}
{/* Enrich-in-progress indicator */}
{resourceEnriching && !isLoading && (
<div className="flex gap-2">
<div className="w-6 h-6 rounded-full bg-emerald-500/10 text-emerald-600 flex items-center justify-center flex-shrink-0 border border-emerald-500/20">
<Wand2 className="h-3 w-3" />
</div>
<div className="bg-muted/40 border border-border/40 p-3 rounded-2xl rounded-tl-sm flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-600" />
<span className="text-xs text-muted-foreground">{t('ai.resource.enriching') || 'Traitement IA...'}</span>
</div>
</div>
)}
{/* Inline preview from inject buttons */}
{resourcePreview && !resourceEnriching && (
<div className="rounded-xl border border-primary/30 bg-primary/5 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-primary/20">
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
{resourcePreview.source === 'chat' ? (t('ai.resource.fromChat') || 'Remplacement')
: resourcePreview.source === 'replace' ? (t('ai.resource.replacement') || 'Remplacement')
: resourcePreview.source === 'complete' ? (t('ai.resource.completedByAI') || 'Complété par IA')
: (t('ai.resource.mergedByAI') || 'Fusionné par IA')}
</span>
<button onClick={() => setResourcePreview(null)} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
</div>
<div className="px-3 py-2 max-h-48 overflow-y-auto">
<MarkdownContent content={resourcePreview.text} />
</div>
<div className="flex gap-2 px-3 py-2 border-t border-primary/20">
<button
onClick={() => setResourcePreview(null)}
className="flex-1 text-[11px] py-1.5 rounded-lg border border-border/60 text-muted-foreground hover:bg-muted transition-colors"
>
{t('common.cancel') || 'Annuler'}
</button>
<button
onClick={() => { if (onApplyToNote && resourcePreview) { onApplyToNote(resourcePreview.text); setResourcePreview(null); setResourceText(''); toast.success(t('ai.appliedToNote')) } }}
disabled={!onApplyToNote}
className="flex-1 text-[11px] py-1.5 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
>
<Check className="h-3 w-3" />
{t('ai.resource.applyToNote') || 'Appliquer'}
</button>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* ══ Inject Preview Panel — fixed, always visible, NO scroll needed ══ */}
{resourceEnriching && (
<div className="shrink-0 mx-3 mb-2 flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-50/50 dark:bg-emerald-950/20 p-3">
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-600 shrink-0" />
<span className="text-xs text-emerald-700 dark:text-emerald-400">Traitement IA en cours...</span>
</div>
)}
{resourcePreview && !resourceEnriching && (
<div className="shrink-0 mx-3 mb-2 rounded-xl border border-primary/30 bg-primary/5 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-primary/20">
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
{resourcePreview.source === 'complete' ? 'Complété par IA'
: resourcePreview.source === 'merge' ? 'Fusionné par IA'
: 'Aperçu'}
</span>
<button onClick={() => setResourcePreview(null)} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
</div>
<div className="px-3 py-2 max-h-64 overflow-y-auto text-sm">
<MarkdownContent content={resourcePreview.text} />
</div>
<div className="flex gap-2 px-3 py-2 border-t border-primary/20">
<button
onClick={() => setResourcePreview(null)}
className="flex-1 text-[11px] py-1.5 rounded-lg border border-border/60 text-muted-foreground hover:bg-muted transition-colors"
>
Annuler
</button>
<button
onClick={() => {
if (onApplyToNote && resourcePreview) {
onApplyToNote(resourcePreview.text)
setResourcePreview(null)
setResourceText('')
toast.success('Appliqué à la note')
}
}}
disabled={!onApplyToNote}
className="flex-1 text-[11px] py-1.5 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
>
<Check className="h-3 w-3" />
Appliquer à la note
</button>
</div>
</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-[10px] 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-2 rounded-lg border text-[11px] font-semibold transition-all flex flex-col items-center gap-1',
sel
? 'border-foreground bg-foreground text-background'
: 'border-border/60 text-muted-foreground hover:border-foreground/40 hover:text-foreground bg-background',
)}
>
<Icon className="h-4 w-4" />
{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>
{isLoading ? (
<Button
size="icon"
className="h-7 w-7 rounded-lg bg-red-500 text-white shadow-sm hover:bg-red-600"
onClick={() => stop()}
>
<Square className="h-3.5 w-3.5" />
</Button>
) : (
<Button
size="icon"
className="h-7 w-7 rounded-lg bg-primary text-primary-foreground shadow-sm disabled:opacity-50"
onClick={handleSend}
disabled={!input.trim()}
>
<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>
{/* Image actions — shown when note has images */}
{noteImages && noteImages.length > 0 && ACTION_IDS.filter(a => a.isImageAction).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" />
}
<div className="flex flex-col">
<span>{t(action.i18nKey)}</span>
{noteImages.length > 1 && (
<span className="text-[10px] text-muted-foreground">{t('ai.imagesCount', { count: noteImages.length })}</span>
)}
</div>
{loading && <span className="ml-auto text-[10px] text-muted-foreground">{t('ai.processingAction')}</span>}
</button>
)
})}
{/* Text actions — shown when note has sufficient text */}
{!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.filter(a => !a.isImageAction).map(action => {
const Icon = action.icon
const loading = actionLoading === action.id
if (action.id === 'translate') {
return (
<div key={action.id} className="flex flex-col gap-1.5">
<button
onClick={() => setShowLangPicker(v => !v)}
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>
{translateTarget && <span className="ml-auto text-xs text-primary/70 font-normal">{translateTarget}</span>}
</button>
{showLangPicker && (
<div className="flex flex-col gap-2 px-3 py-3 rounded-xl border border-border/40 bg-muted/30">
<div className="flex flex-wrap gap-1.5">
{['Francais','English','Espanol','Deutsch','Persan','Portugais','Italiano','Chinois','Japonais'].map(l => (
<button
key={l}
className={`text-xs px-2.5 py-1 rounded-lg border transition-colors ${translateTarget === l ? 'bg-primary text-primary-foreground border-primary' : 'bg-card border-border hover:bg-accent'}`}
onClick={() => { setTranslateTarget(l); setCustomLangInput(l); }}
>{l}</button>
))}
</div>
<input
className="text-xs px-3 py-1.5 rounded-lg border border-border bg-card outline-none focus:border-primary w-full"
placeholder={t('ai.action.customLang') || 'Autre langue...'}
value={customLangInput}
onChange={e => { const val = e.target.value; setCustomLangInput(val); setTranslateTarget(val); }}
onKeyDown={e => { if (e.key === 'Enter' && translateTarget.trim()) { handleAction(action, translateTarget.trim()); } }}
/>
<button
disabled={!!actionLoading || !translateTarget}
onClick={() => handleAction(action, translateTarget)}
className="flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50 hover:bg-primary/90 transition-colors"
>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Languages className="h-3 w-3" />}
{t('ai.action.translate')} {translateTarget}
</button>
</div>
)}
</div>
)
}
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>
)
})
)}
{/* ── Generate slides / diagram ─────────────────────── */}
{noteId && (
<div className="mt-1">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5 mt-4">
{t('ai.generate.sectionLabel') || 'Générer depuis cette note'}
</p>
{/* ── Slides ── */}
<div className="mb-3 rounded-xl border-2 border-purple-200 dark:border-purple-800 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/40 dark:to-pink-950/40 overflow-hidden">
{/* Options */}
<div className="grid grid-cols-2 gap-1.5 px-3 pt-2.5 pb-1">
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-purple-500 dark:text-purple-400 block mb-0.5">{t('ai.generate.theme') || 'Thème'}</label>
<select value={slideTheme} onChange={e => setSlideTheme(e.target.value)} disabled={generateLoading === 'slides'}
className="w-full text-[11px] rounded-md border border-purple-200 dark:border-purple-700 bg-white/70 dark:bg-purple-950/60 text-purple-800 dark:text-purple-200 px-1.5 py-1 focus:outline-none">
{[['vibrant_tech','Vibrant Tech'],['business_authority','Business'],['soft_creative','Créatif'],['modern_wellness','Wellness'],['tech_night','Dark Tech'],['education_charts','Éducation'],['elegant_fashion','Élégant'],['art_food','Art & Food'],['nature_outdoors','Nature'],['vintage_academic','Académique'],['platinum_white_gold','Premium']].map(([v,l]) =>
<option key={v} value={v}>{l}</option>
)}
</select>
</div>
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-purple-500 dark:text-purple-400 block mb-0.5">{t('ai.generate.style') || 'Style'}</label>
<select value={slideStyle} onChange={e => setSlideStyle(e.target.value)} disabled={generateLoading === 'slides'}
className="w-full text-[11px] rounded-md border border-purple-200 dark:border-purple-700 bg-white/70 dark:bg-purple-950/60 text-purple-800 dark:text-purple-200 px-1.5 py-1 focus:outline-none">
<option value="soft">{t('ai.generate.styleSoft') || 'Soft'}</option>
<option value="sharp">{t('ai.generate.styleSharp') || 'Sharp'}</option>
<option value="rounded">{t('ai.generate.styleRounded') || 'Arrondi'}</option>
<option value="pill">{t('ai.generate.stylePill') || 'Pill'}</option>
</select>
</div>
</div>
{/* Button */}
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading || !!actionLoading}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium text-purple-700 dark:text-purple-300 hover:bg-purple-100/60 dark:hover:bg-purple-900/30 transition-all text-left disabled:opacity-60">
{generateLoading === 'slides' ? <Loader2 className="h-4 w-4 shrink-0 animate-spin" /> : <Presentation className="h-4 w-4 shrink-0" />}
<div className="flex flex-col flex-1 min-w-0">
<span>{t('ai.generate.slides') || 'Générer une présentation'}</span>
{generateLoading === 'slides' && <span className="text-[10px] text-purple-500">{t('ai.generate.loading') || 'Génération en cours…'}</span>}
</div>
</button>
</div>
{/* Slides result */}
{generateResult?.type === 'slides' && generateResult.canvasId && (
<a href={`/api/canvas/download?id=${generateResult.canvasId}`} target="_blank" rel="noopener noreferrer"
className="w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all mb-2">
<Download className="h-4 w-4 shrink-0" />
{t('ai.generate.downloadPptx') || 'Télécharger le .pptx'}
<ExternalLink className="h-3 w-3 ml-auto opacity-60" />
</a>
)}
{/* ── Diagram ── */}
<div className="rounded-xl border-2 border-cyan-200 dark:border-cyan-800 bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-950/40 dark:to-blue-950/40 overflow-hidden">
<div className="grid grid-cols-2 gap-1.5 px-3 pt-2.5 pb-1">
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-cyan-500 dark:text-cyan-400 block mb-0.5">{t('ai.generate.diagramType') || 'Type'}</label>
<select value={diagramType} onChange={e => setDiagramType(e.target.value)} disabled={generateLoading === 'diagram'}
className="w-full text-[11px] rounded-md border border-cyan-200 dark:border-cyan-700 bg-white/70 dark:bg-cyan-950/60 text-cyan-800 dark:text-cyan-200 px-1.5 py-1 focus:outline-none">
<option value="auto">{t('ai.generate.typeAuto') || 'Auto'}</option>
<option value="flowchart">Flowchart</option>
<option value="mindmap">Mind map</option>
<option value="timeline">Timeline</option>
<option value="org-chart">Org chart</option>
<option value="architecture-cloud">Architecture</option>
<option value="process-map">Process map</option>
</select>
</div>
<div>
<label className="text-[9px] font-semibold uppercase tracking-wider text-cyan-500 dark:text-cyan-400 block mb-0.5">{t('ai.generate.style') || 'Style'}</label>
<select value={diagramStyle} onChange={e => setDiagramStyle(e.target.value)} disabled={generateLoading === 'diagram'}
className="w-full text-[11px] rounded-md border border-cyan-200 dark:border-cyan-700 bg-white/70 dark:bg-cyan-950/60 text-cyan-800 dark:text-cyan-200 px-1.5 py-1 focus:outline-none">
<option value="default">{t('ai.generate.styleSketchy') || 'Sketchy'}</option>
<option value="austere">{t('ai.generate.styleAustere') || 'Austère'}</option>
<option value="sketch-plus">Sketch+</option>
</select>
</div>
</div>
<button onClick={() => handleGenerate('diagram')} disabled={!!generateLoading || !!actionLoading}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium text-cyan-700 dark:text-cyan-300 hover:bg-cyan-100/60 dark:hover:bg-cyan-900/30 transition-all text-left disabled:opacity-60">
{generateLoading === 'diagram' ? <Loader2 className="h-4 w-4 shrink-0 animate-spin" /> : <PenTool className="h-4 w-4 shrink-0" />}
<div className="flex flex-col flex-1 min-w-0">
<span>{t('ai.generate.diagram') || 'Générer un diagramme'}</span>
{generateLoading === 'diagram' && <span className="text-[10px] text-cyan-500">{t('ai.generate.loading') || 'Génération en cours…'}</span>}
</div>
</button>
</div>
{/* Diagram result */}
{generateResult?.type === 'diagram' && generateResult.canvasId && (
<div className="mt-2 flex flex-col gap-2">
<a href={`/lab?id=${generateResult.canvasId}`}
className="w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all">
<PenTool className="h-4 w-4 shrink-0" />
{t('ai.generate.openInExcalidraw')}
</a>
<button
type="button"
disabled={diagramEmbedLoading}
onClick={() => void handleEmbedDiagramInNote(generateResult.canvasId!)}
className="w-full flex items-center gap-2 rounded-xl border border-cyan-200 bg-cyan-50 dark:border-cyan-800 dark:bg-cyan-950/30 px-4 py-2.5 text-sm font-medium text-cyan-700 dark:text-cyan-300 hover:bg-cyan-100 dark:hover:bg-cyan-900/40 transition-all disabled:opacity-60 text-left"
>
{diagramEmbedLoading
? <Loader2 className="h-4 w-4 shrink-0 animate-spin" />
: <ImagePlus className="h-4 w-4 shrink-0" />}
{t('ai.generate.insertDiagramInNote')}
</button>
</div>
)}
</div>
)}
{/* 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>
)}
{/* ══════════════════════════════════════════════════════════ */}
{/* ── TAB: RESSOURCE ──────────────────────────────────── */}
{/* ══════════════════════════════════════════════════════════ */}
{activeTab === 'resource' && (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Preview panel */}
{resourcePreview ? (
<div className="flex flex-col flex-1 min-h-0">
{/* Preview header */}
<div className="px-4 py-2.5 border-b border-border/40 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<p className="text-xs font-semibold text-foreground">
{resourcePreview.source === 'chat' ? t('ai.resource.fromChat')
: resourcePreview.source === 'replace' ? t('ai.resource.replacement')
: resourcePreview.source === 'complete' ? t('ai.resource.completedByAI')
: t('ai.resource.mergedByAI')}
</p>
{/* Format toggle */}
<div className="flex rounded-md border border-border/40 overflow-hidden">
<button
onClick={() => setResourcePreviewFormat('rendered')}
className={cn(
'flex items-center gap-1 px-2 py-1 text-[10px] font-medium transition-colors',
resourcePreviewFormat === 'rendered'
? 'bg-primary text-primary-foreground'
: 'bg-card text-muted-foreground hover:bg-muted',
)}
>
<Eye className="h-2.5 w-2.5" /> {t('ai.resource.rendered')}
</button>
<button
onClick={() => setResourcePreviewFormat('markdown')}
className={cn(
'flex items-center gap-1 px-2 py-1 text-[10px] font-medium transition-colors',
resourcePreviewFormat === 'markdown'
? 'bg-primary text-primary-foreground'
: 'bg-card text-muted-foreground hover:bg-muted',
)}
>
<Code className="h-2.5 w-2.5" /> Markdown
</button>
</div>
</div>
<button onClick={() => setResourcePreview(null)} className="text-muted-foreground hover:text-foreground">
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* Preview content */}
<div className="flex-1 overflow-y-auto p-3">
{resourcePreviewFormat === 'rendered' ? (
<div className="text-sm leading-relaxed text-foreground bg-muted/20 border border-border/30 rounded-xl p-3">
<MarkdownContent content={resourcePreview.text} />
</div>
) : (
<pre className="text-xs leading-relaxed text-foreground bg-muted/20 border border-border/30 rounded-xl p-3 whitespace-pre-wrap font-mono overflow-x-auto">
{resourcePreview.text}
</pre>
)}
</div>
{/* Apply / Discard */}
<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={() => setResourcePreview(null)}
>
<X className="h-3.5 w-3.5" /> {t('ai.resource.cancel')}
</Button>
<Button
size="sm"
className="flex-1 text-xs gap-1.5 bg-primary"
onClick={handleApplyResourcePreview}
disabled={!onApplyToNote}
>
<Check className="h-3.5 w-3.5" /> {t('ai.resource.applyToNote')}
</Button>
</div>
</div>
) : (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="p-3 space-y-3">
{/* URL loader */}
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
<Link2 className="h-3 w-3 inline mr-1" />{t('ai.resource.urlLabel')}
</label>
<div className="flex gap-1.5">
<input
type="url"
value={resourceUrl}
onChange={e => setResourceUrl(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleScrapeUrl()}
placeholder="https://..."
className="flex-1 h-8 px-2.5 text-xs bg-card border border-border/60 rounded-lg focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all"
/>
<Button
size="sm"
variant="outline"
className="h-8 px-3 text-xs shrink-0"
onClick={handleScrapeUrl}
disabled={resourceScraping || !resourceUrl.trim()}
>
{resourceScraping
? <Loader2 className="h-3 w-3 animate-spin" />
: <Download className="h-3 w-3" />}
</Button>
</div>
</div>
{/* Text area */}
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
<FileText className="h-3 w-3 inline mr-1" />{t('ai.resource.resourceText')}
</label>
<textarea
value={resourceText}
onChange={e => setResourceText(e.target.value)}
placeholder={t('ai.resource.resourcePlaceholder')}
rows={8}
className="w-full px-2.5 py-2 text-xs bg-card border border-border/60 rounded-lg focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 resize-none transition-all font-mono leading-relaxed"
/>
{resourceText && (
<p className="text-[9px] text-muted-foreground mt-0.5 text-right">
{resourceText.split(/\s+/).filter(Boolean).length} {t('ai.resource.words')}
</p>
)}
</div>
{/* Mode selector */}
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
{t('ai.resource.integrationMode')}
</label>
<div className="grid grid-cols-3 gap-1.5">
{([
{ id: 'replace', label: t('ai.resource.modeReplace'), desc: t('ai.resource.modeReplaceDesc'), icon: Download, color: 'primary' },
{ id: 'complete', label: t('ai.resource.modeComplete'), desc: t('ai.resource.modeCompleteDesc'), icon: PlusCircle, color: 'emerald' },
{ id: 'merge', label: t('ai.resource.modeMerge'), desc: t('ai.resource.modeMergeDesc'), icon: GitMerge, color: 'violet' },
] as const).map(m => {
const Icon = m.icon
const sel = resourceMode === m.id
return (
<button
key={m.id}
onClick={() => setResourceMode(m.id)}
className={cn(
'flex flex-col items-center gap-1 py-2 px-1 rounded-lg border text-center transition-all',
sel
? m.color === 'primary'
? 'border-primary bg-primary/10 text-primary'
: m.color === 'emerald'
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30'
: 'border-violet-500 bg-violet-50 text-violet-700 dark:bg-violet-950/30'
: 'border-border/40 text-muted-foreground bg-card hover:bg-muted',
)}
>
<Icon className="h-3.5 w-3.5" />
<span className="text-[10px] font-semibold leading-tight">{m.label}</span>
<span className="text-[9px] leading-tight opacity-70">{m.desc}</span>
</button>
)
})}
</div>
</div>
{/* Preview button */}
<Button
className="w-full gap-2 text-sm"
onClick={handleResourcePreview}
disabled={!resourceText.trim() || resourceEnriching}
>
{resourceEnriching
? <><Loader2 className="h-4 w-4 animate-spin" /> {t('ai.resource.aiProcessing')}</>
: resourceMode === 'replace'
? <><Eye className="h-4 w-4" /> {t('ai.resource.preview')}</>
: <><Sparkles className="h-4 w-4" /> {t('ai.resource.generatePreview')}</>
}
</Button>
{/* Hint */}
{!noteContent && resourceMode !== 'replace' && (
<p className="text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
{t('ai.resource.emptyNoteHint')}
</p>
)}
</div>
</div>
)}
</div>
)}
</aside>
)
}