Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
1312 lines
70 KiB
TypeScript
1312 lines
70 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,
|
||
ChevronRight, MessageSquare, History, Scissors, Zap, Layout, ArrowRightLeft, Copy, CheckCircle,
|
||
Tag as TagIcon, RefreshCw,
|
||
} from 'lucide-react'
|
||
import { motion, AnimatePresence } from 'motion/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 { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||
import { InlinePaywall } from './settings/inline-paywall'
|
||
|
||
// ── Custom Toast Helper ──────────────────────────────────────────────────────
|
||
const mToast = {
|
||
success: (msg: string, options?: any) => toast.success(msg, {
|
||
...options,
|
||
className: 'memento-toast memento-toast-success',
|
||
}),
|
||
error: (msg: string, options?: any) => toast.error(msg, {
|
||
...options,
|
||
className: 'memento-toast memento-toast-error',
|
||
}),
|
||
loading: (msg: string, options?: any) => toast.loading(msg, {
|
||
...options,
|
||
className: 'memento-toast memento-toast-info',
|
||
}),
|
||
}
|
||
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 { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
|
||
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
|
||
import { scrapePageText } from '@/app/actions/scrape'
|
||
import { PersonasPanel } from '@/components/personas-panel'
|
||
|
||
// ── 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 TONE_ICONS = [
|
||
{ id: 'professional', icon: Briefcase },
|
||
{ id: 'creative', icon: Palette },
|
||
{ id: 'academic', icon: GraduationCap },
|
||
{ id: 'casual', icon: Coffee },
|
||
] as const
|
||
|
||
interface ActionDef {
|
||
id: string
|
||
icon: any
|
||
apiPath: string
|
||
body: (content: string, images?: string[], lang?: string, format?: string) => any
|
||
resultKey: string
|
||
i18nKey: string
|
||
isImageAction?: boolean
|
||
}
|
||
|
||
const ACTION_IDS = [
|
||
{ id: 'clarify', icon: Lightbulb, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'clarify', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.clarify' },
|
||
{ id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'shorten', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' },
|
||
{ id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'improve', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' },
|
||
{ id: 'translate', icon: Languages, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'translate', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.translate' },
|
||
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string, _images?: string[], _lang?: string, _format?: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
|
||
{ id: 'toRichText', icon: Wand2, apiPath: '/api/ai/convert-markdown', body: (content: string, _images?: string[], _lang?: string, _format?: string) => ({ content }), resultKey: 'html', i18nKey: 'ai.action.toRichText' },
|
||
{ id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string, _format?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true },
|
||
]
|
||
|
||
/** API language names sent to `/api/ai/reformulate` for translate targets */
|
||
const TRANSLATE_LANGUAGE_OPTIONS: { api: string; labelKey: string }[] = [
|
||
{ api: 'French', labelKey: 'languages.targets.french' },
|
||
{ api: 'English', labelKey: 'languages.targets.english' },
|
||
{ api: 'Spanish', labelKey: 'languages.targets.spanish' },
|
||
{ api: 'German', labelKey: 'languages.targets.german' },
|
||
{ api: 'Persian', labelKey: 'languages.targets.persian' },
|
||
{ api: 'Portuguese', labelKey: 'languages.targets.portuguese' },
|
||
{ api: 'Italian', labelKey: 'languages.targets.italian' },
|
||
{ api: 'Chinese', labelKey: 'languages.targets.chinese' },
|
||
{ api: 'Japanese', labelKey: 'languages.targets.japanese' },
|
||
]
|
||
|
||
// ── 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; parentId?: string | null; trashedAt?: any }>
|
||
/** Extra classes forwarded to the aside root element */
|
||
className?: string
|
||
/** How to embed generated diagram images (markdown vs rich text HTML) */
|
||
diagramInsertFormat?: 'markdown' | 'html'
|
||
/** Called to trigger AI title generation for the note */
|
||
onGenerateTitle?: () => void
|
||
/** Notebook ID for label regeneration */
|
||
notebookId?: string
|
||
/** Notebook name for display */
|
||
notebookName?: string
|
||
}
|
||
|
||
function CopyPreviewButton({ text }: { text: string }) {
|
||
const { t } = useLanguage()
|
||
const [copied, setCopied] = useState(false)
|
||
const handleCopy = () => {
|
||
if (!text) return
|
||
const ta = document.createElement('textarea')
|
||
ta.value = text
|
||
ta.style.position = 'fixed'
|
||
ta.style.left = '-9999px'
|
||
ta.style.top = '-9999px'
|
||
ta.style.opacity = '0'
|
||
document.body.appendChild(ta)
|
||
ta.focus()
|
||
ta.setSelectionRange(0, ta.value.length)
|
||
let ok = false
|
||
try { ok = document.execCommand('copy') } catch { }
|
||
document.body.removeChild(ta)
|
||
if (!ok) {
|
||
try { navigator.clipboard.writeText(text) } catch { }
|
||
}
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
}
|
||
return (
|
||
<button
|
||
onClick={handleCopy}
|
||
className="flex-1 py-3.5 border border-border rounded-xl text-[10px] font-bold uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-muted transition-all"
|
||
>
|
||
{copied ? <CheckCircle size={14} className="text-primary" /> : <Copy size={14} />}
|
||
{copied ? t('ai.copied') : t('ai.copy')}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// ── Component ─────────────────────────────────────────────────────────────────
|
||
|
||
export function ContextualAIChat({
|
||
onClose,
|
||
noteTitle,
|
||
noteContent,
|
||
noteImages,
|
||
noteId,
|
||
onApplyToNote,
|
||
onUndoLastAction,
|
||
lastActionApplied = false,
|
||
notebooks = [],
|
||
className,
|
||
diagramInsertFormat = 'markdown',
|
||
onGenerateTitle,
|
||
notebookId,
|
||
notebookName,
|
||
}: ContextualAIChatProps) {
|
||
const { t, language } = useLanguage()
|
||
const webSearchAvailable = useWebSearchAvailable()
|
||
const { requestAiConsent } = useAiConsent()
|
||
|
||
const [activeTab, setActiveTab] = useState<'chat' | 'actions' | 'resource'>('actions')
|
||
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)
|
||
const [quotaExceededFeature, setQuotaExceededFeature] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
setQuotaExceededFeature(null)
|
||
}, [activeTab])
|
||
|
||
// 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 [generateProgress, setGenerateProgress] = useState(0)
|
||
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
|
||
const [customLangInput, setCustomLangInput] = useState('')
|
||
// Generation options
|
||
const [slideTheme, setSlideTheme] = useState('auto')
|
||
const [slideStyle, setSlideStyle] = useState('professional')
|
||
const [slideTemplate, setSlideTemplate] = useState('auto')
|
||
const [diagramType, setDiagramType] = useState('logic_flow')
|
||
const [diagramStyle, setDiagramStyle] = useState('polished')
|
||
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)
|
||
|
||
// Label regeneration state
|
||
const [regenerateLabelsLoading, setRegenerateLabelsLoading] = useState(false)
|
||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||
|
||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||
|
||
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
|
||
|
||
const buildChatBody = () => {
|
||
const body: Record<string, any> = {
|
||
language,
|
||
webSearch,
|
||
format: diagramInsertFormat || 'markdown'
|
||
}
|
||
if (chatScope === 'note') {
|
||
body.noteContext = {
|
||
title: noteTitle || '',
|
||
content: noteContent || '',
|
||
tone: selectedTone,
|
||
images: noteImages || [],
|
||
}
|
||
body.noteId = noteId
|
||
} else if (chatScope !== 'all') {
|
||
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
|
||
|
||
// Dispatch quota refresh quand le streaming se termine
|
||
useEffect(() => {
|
||
if (status === 'ready' && messages.length > 0) {
|
||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||
}
|
||
}, [status])
|
||
|
||
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
|
||
|
||
// GDPR AI Consent Check
|
||
const consented = await requestAiConsent()
|
||
if (!consented) return
|
||
|
||
try {
|
||
await sendMessage({ text }, { body: buildChatBody() })
|
||
} catch (error: any) {
|
||
console.error('Chat send error:', error)
|
||
const isQuota = error?.status === 402 || (error?.message && error.message.includes('402')) || (error?.message && error.message.includes('quota'));
|
||
if (isQuota) {
|
||
setQuotaExceededFeature('chat')
|
||
} else {
|
||
toast.error(t('chat.assistantError') || 'Failed to send message')
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Action execution ────────────────────────────────────────────────────────
|
||
const handleAction = async (action: ActionDef, targetLang?: string) => {
|
||
// GDPR AI Consent Check
|
||
const consented = await requestAiConsent()
|
||
if (!consented) return
|
||
|
||
if (action.isImageAction) {
|
||
if (!noteImages || noteImages.length === 0) {
|
||
mToast.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)),
|
||
})
|
||
if (res.status === 402) {
|
||
setQuotaExceededFeature('reformulate')
|
||
return
|
||
}
|
||
const data = await res.json()
|
||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
||
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.inlineSummaryMarkdown')} ${data.combinedSummary}`
|
||
}
|
||
setActionPreview({ label: t(action.i18nKey), text: resultText })
|
||
} catch (e: any) {
|
||
mToast.error(e.message || t('ai.actionError'))
|
||
} finally {
|
||
setActionLoading(null)
|
||
}
|
||
return
|
||
}
|
||
|
||
const wc = (noteContent || '').split(/\s+/).filter(Boolean).length
|
||
if (!noteContent || wc < 5) {
|
||
mToast.error(t('ai.minWordsError'))
|
||
return
|
||
}
|
||
setActionLoading(action.id)
|
||
setActionPreview(null)
|
||
try {
|
||
const format = diagramInsertFormat || 'markdown'
|
||
const res = await fetch(action.apiPath, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(action.body(noteContent, undefined, targetLang || language, format)),
|
||
})
|
||
if (res.status === 402) {
|
||
setQuotaExceededFeature('reformulate')
|
||
return
|
||
}
|
||
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) {
|
||
mToast.error(e.message || t('ai.actionError'))
|
||
} finally {
|
||
setActionLoading(null)
|
||
}
|
||
}
|
||
|
||
const handleApplyPreview = () => {
|
||
if (!actionPreview || !onApplyToNote) return
|
||
onApplyToNote(actionPreview.text)
|
||
setActionPreview(null)
|
||
mToast.success(t('ai.appliedToNote'))
|
||
}
|
||
|
||
const handleDiscardPreview = () => setActionPreview(null)
|
||
|
||
// ── Generate slides / diagram ────────────────────────────────────────────────
|
||
|
||
const generatePollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||
const generateProgressRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||
|
||
// Fake progress bar: fast up to 30%, then slows, caps at 90% until done
|
||
const startProgressBar = () => {
|
||
setGenerateProgress(0)
|
||
let current = 0
|
||
if (generateProgressRef.current) clearInterval(generateProgressRef.current)
|
||
generateProgressRef.current = setInterval(() => {
|
||
current += current < 30 ? 3 : current < 60 ? 1.2 : current < 80 ? 0.4 : 0.1
|
||
if (current >= 90) { clearInterval(generateProgressRef.current!); current = 90 }
|
||
setGenerateProgress(Math.min(current, 90))
|
||
}, 300)
|
||
}
|
||
const finishProgressBar = () => {
|
||
if (generateProgressRef.current) clearInterval(generateProgressRef.current)
|
||
setGenerateProgress(100)
|
||
setTimeout(() => setGenerateProgress(0), 600)
|
||
}
|
||
|
||
const handleGenerate = async (type: 'slides' | 'diagram') => {
|
||
if (!noteId) {
|
||
mToast.error(t('ai.generate.noNoteId') || 'Note non sauvegardée')
|
||
return
|
||
}
|
||
setGenerateLoading(type)
|
||
setGenerateResult(null)
|
||
startProgressBar()
|
||
|
||
const toastId = mToast.loading(
|
||
type === 'slides' ? t('ai.generateSlidesLoading') : t('ai.generateDiagramLoading'),
|
||
{ duration: Infinity }
|
||
)
|
||
|
||
try {
|
||
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,
|
||
language: language === 'fr' ? 'French' : 'English',
|
||
template: type === 'slides' ? slideTemplate : undefined,
|
||
}),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok || !data.success) {
|
||
mToast.error(data.error || t('ai.errorShort'), { id: toastId })
|
||
setGenerateLoading(null)
|
||
return
|
||
}
|
||
|
||
const { agentId } = data as { agentId: string }
|
||
|
||
if (generatePollRef.current) clearInterval(generatePollRef.current)
|
||
let pollCount = 0
|
||
const MAX_POLLS = 200 // 200 × 3s = 10 min safety timeout
|
||
generatePollRef.current = setInterval(async () => {
|
||
pollCount++
|
||
if (pollCount > MAX_POLLS) {
|
||
clearInterval(generatePollRef.current!)
|
||
generatePollRef.current = null
|
||
finishProgressBar()
|
||
setGenerateLoading(null)
|
||
mToast.error(t('ai.errorShort'), { id: toastId })
|
||
return
|
||
}
|
||
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
|
||
finishProgressBar()
|
||
setGenerateLoading(null)
|
||
setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId })
|
||
mToast.success(t('ai.readyToast'), { id: toastId })
|
||
} else if (poll.status === 'failure') {
|
||
clearInterval(generatePollRef.current!)
|
||
generatePollRef.current = null
|
||
finishProgressBar()
|
||
setGenerateLoading(null)
|
||
mToast.error(poll.error || t('ai.errorShort'), { id: toastId })
|
||
}
|
||
} catch { }
|
||
}, 3000)
|
||
} catch {
|
||
mToast.error(t('ai.errorShort'), { id: toastId })
|
||
finishProgressBar()
|
||
setGenerateLoading(null)
|
||
}
|
||
}
|
||
|
||
const buildDiagramImageSnippet = (imageUrl: string, alt: string) => {
|
||
if (diagramInsertFormat === 'html') {
|
||
return `\n<p><img src="${imageUrl}" alt="${alt}" loading="lazy" style="max-width:100%;height:auto;border-radius:8px;" /></p>\n`
|
||
}
|
||
return `\n\n\n\n`
|
||
}
|
||
|
||
const handleEmbedDiagramInNote = async (canvasId: string) => {
|
||
if (!onApplyToNote) return
|
||
setDiagramEmbedLoading(true)
|
||
try {
|
||
const res = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`)
|
||
const data = await res.json()
|
||
if (!res.ok || !data.canvas?.data) throw new Error()
|
||
const blob = await exportExcalidrawSceneToPngBlob(data.canvas.data)
|
||
if (!blob) throw new Error()
|
||
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) throw new Error()
|
||
const alt = t('ai.generate.diagramImageAlt')
|
||
onApplyToNote(`${noteContent ?? ''}${buildDiagramImageSnippet(upJson.url as string, alt)}`)
|
||
mToast.success(t('ai.generate.insertedInNote'))
|
||
} catch (e) {
|
||
mToast.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) { mToast.error(t('ai.resource.failedToLoadUrl')); return }
|
||
setResourceText(result.text)
|
||
mToast.success(t('ai.resource.pageLoaded', { title: result.title.slice(0, 40) }))
|
||
} catch {
|
||
mToast.error(t('ai.resource.pageLoadError'))
|
||
} finally {
|
||
setResourceScraping(false)
|
||
}
|
||
}
|
||
|
||
const handleResourcePreview = async () => {
|
||
if (!resourceText.trim()) { mToast.error(t('ai.resource.pasteOrUrlFirst')); return }
|
||
if (resourceMode === 'replace') {
|
||
setResourcePreview({ text: resourceText, source: 'paste' })
|
||
return
|
||
}
|
||
|
||
// GDPR AI Consent Check
|
||
const consented = await requestAiConsent()
|
||
if (!consented) 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,
|
||
format: diagramInsertFormat || 'markdown',
|
||
}),
|
||
})
|
||
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) {
|
||
mToast.error(e.message || t('ai.resource.enrichError'))
|
||
} finally {
|
||
setResourceEnriching(false)
|
||
}
|
||
}
|
||
|
||
const handleApplyResourcePreview = () => {
|
||
if (!resourcePreview || !onApplyToNote) return
|
||
onApplyToNote(resourcePreview.text)
|
||
setResourcePreview(null)
|
||
setResourceText('')
|
||
setResourceUrl('')
|
||
mToast.success(t('ai.resource.contentApplied'))
|
||
}
|
||
|
||
const handleInjectFromChat = async (msgText: string, mode: 'replace' | 'complete' | 'merge') => {
|
||
setResourceText(msgText)
|
||
setResourceMode(mode)
|
||
|
||
if (mode === 'replace') {
|
||
setResourcePreview({ text: msgText, source: 'chat' })
|
||
return
|
||
}
|
||
|
||
// GDPR AI Consent Check
|
||
const consented = await requestAiConsent()
|
||
if (!consented) 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: msgText,
|
||
mode,
|
||
language,
|
||
format: diagramInsertFormat || 'markdown',
|
||
}),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
||
setResourcePreview({ text: data.enrichedContent, source: mode })
|
||
} catch (e: any) {
|
||
mToast.error(e.message || t('ai.resource.enrichErrorShort'))
|
||
} finally {
|
||
setResourceEnriching(false)
|
||
}
|
||
}
|
||
|
||
const handleRegenerateLabels = () => {
|
||
if (!notebookId) {
|
||
mToast.error(t('ai.autoLabels.noNotebook') || 'Aucun carnet sélectionné')
|
||
return
|
||
}
|
||
setAutoLabelOpen(true)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{expanded && (
|
||
<div
|
||
className="fixed inset-0 z-[199] bg-foreground/40 backdrop-blur-sm"
|
||
onClick={() => setExpanded(false)}
|
||
/>
|
||
)}
|
||
<aside className={cn(
|
||
'border-l border-border bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col z-10 transition-all duration-300 shadow-2xl overflow-hidden',
|
||
expanded
|
||
? 'fixed right-0 top-0 h-screen w-[640px] z-[200]'
|
||
: 'self-stretch h-full w-[400px]',
|
||
!expanded && className,
|
||
)}>
|
||
|
||
<div className="p-6 border-b border-border/60 space-y-1.5 bg-white/50 dark:bg-black/20 backdrop-blur-md shrink-0">
|
||
<div className="flex items-center justify-between">
|
||
<div className="min-w-0 flex-1">
|
||
<h3 className="flex items-center gap-2 font-serif text-xl font-medium text-ink">
|
||
<Sparkles size={18} className="text-brand-accent shrink-0" />
|
||
{t('ai.assistantTitle') || 'IA Assistant'}
|
||
</h3>
|
||
<p className="text-[11px] text-concrete uppercase tracking-wider font-medium opacity-60 truncate">
|
||
"{noteTitle || t('ai.currentNote')}"
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||
<button
|
||
onClick={() => setExpanded(e => !e)}
|
||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
||
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
|
||
>
|
||
{expanded ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
||
title={t('general.close') || 'Fermer'}
|
||
>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex border-b border-border shrink-0 px-2 flex-shrink-0 h-12 items-stretch">
|
||
{[
|
||
{ id: 'actions' as const, label: t('ai.assistantTabActions'), icon: <Sparkles size={16} /> },
|
||
{ id: 'chat' as const, label: t('ai.chatTab'), icon: <MessageSquare size={16} /> },
|
||
{ id: 'resource' as const, label: t('ai.resourceTab'), icon: <Link2 size={16} /> },
|
||
].map(tab => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id as any)}
|
||
className={cn(
|
||
"flex-1 text-[10px] font-bold uppercase tracking-[0.2em] transition-all relative whitespace-nowrap focus:outline-none",
|
||
activeTab === tab.id ? 'text-brand-accent' : 'text-concrete hover:text-ink/60'
|
||
)}
|
||
>
|
||
{tab.label}
|
||
{activeTab === tab.id && (
|
||
<motion.div layoutId="activeTab" className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand-accent" />
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||
{quotaExceededFeature && (
|
||
<div className="absolute inset-x-4 top-4 z-30 animate-in fade-in slide-in-from-top-4 duration-300">
|
||
<InlinePaywall
|
||
feature={quotaExceededFeature}
|
||
onDismiss={() => setQuotaExceededFeature(null)}
|
||
/>
|
||
</div>
|
||
)}
|
||
{actionPreview && (
|
||
<div className="absolute inset-0 z-20 flex flex-col bg-[#FDFCFB]/95 dark:bg-[#0D0D0D]/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
|
||
<div className="px-6 py-4 border-b border-border flex items-center justify-between shrink-0">
|
||
<p className="text-[10px] font-bold uppercase tracking-widest text-brand-accent">{actionPreview.label}</p>
|
||
<button onClick={handleDiscardPreview} className="text-concrete hover:text-ink"><X size={18} /></button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||
<div className="bg-white/60 dark:bg-white/5 border border-border p-6 rounded-2xl leading-relaxed text-sm">
|
||
<MarkdownContent content={actionPreview.text} />
|
||
</div>
|
||
</div>
|
||
<div className="p-6 border-t border-border flex gap-3 shrink-0">
|
||
<button onClick={handleDiscardPreview} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all">{t('ai.cancel')}</button>
|
||
<CopyPreviewButton text={actionPreview.text} />
|
||
<button onClick={handleApplyPreview} className="flex-1 py-3.5 bg-ink text-paper rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg transition-all hover:opacity-90">{t('ai.applyToNote')}</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{resourcePreview && (
|
||
<div className="absolute inset-0 z-20 flex flex-col bg-[#FDFCFB]/95 dark:bg-[#0D0D0D]/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
|
||
<div className="px-6 py-4 border-b border-border flex items-center justify-between shrink-0">
|
||
<p className="text-[10px] font-bold uppercase tracking-widest text-brand-accent">
|
||
{resourcePreview.source === 'chat' ? t('ai.resourcePreviewInjectFromChat') : t('ai.resourcePreviewAiTitle')}
|
||
</p>
|
||
<button onClick={() => setResourcePreview(null)} className="text-concrete hover:text-ink">
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||
<div className="bg-white/60 dark:bg-white/5 border border-border p-6 rounded-2xl leading-relaxed text-sm">
|
||
<MarkdownContent content={resourcePreview.text} />
|
||
</div>
|
||
</div>
|
||
<div className="p-6 border-t border-border flex gap-3 shrink-0">
|
||
<button onClick={() => setResourcePreview(null)} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all">{t('ai.cancel')}</button>
|
||
<CopyPreviewButton text={resourcePreview.text} />
|
||
<button onClick={handleApplyResourcePreview} className="flex-1 py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg shadow-brand-accent/20 transition-all hover:opacity-90">{t('ai.applyToNote')}</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||
<AnimatePresence mode="wait">
|
||
{activeTab === 'chat' && (
|
||
<motion.div
|
||
key="chat"
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
className="flex flex-col flex-1 h-full min-h-0 overflow-hidden"
|
||
>
|
||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar space-y-6 min-h-0">
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.writingTone')}</label>
|
||
<div className="flex items-center gap-1">
|
||
{TONE_ICONS.map((tone) => {
|
||
const Icon = tone.icon
|
||
return (
|
||
<button
|
||
key={tone.id}
|
||
onClick={() => setSelectedTone(tone.id)}
|
||
title={t(`ai.tones.${tone.id}`)}
|
||
className={cn(
|
||
'w-8 h-8 rounded-lg flex items-center justify-center text-[9px] font-bold transition-all border',
|
||
selectedTone === tone.id
|
||
? 'bg-brand-accent text-white border-brand-accent shadow-sm'
|
||
: 'bg-white/50 dark:bg-white/5 border-border/40 text-concrete hover:border-brand-accent/40'
|
||
)}
|
||
>
|
||
<Icon className="h-3.5 w-3.5" />
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setChatScope('note')}
|
||
className={cn(
|
||
'w-full p-2.5 border rounded-xl text-[11px] flex items-center justify-between transition-all',
|
||
chatScope === 'note' ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white/50 dark:bg-white/5 border-border/40 hover:border-ink/20'
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-2.5">
|
||
<BookOpen size={14} className="text-brand-accent/60" />
|
||
<span className={cn('font-medium', chatScope === 'note' ? 'text-brand-accent' : 'text-concrete')}>{t('ai.thisNote')}</span>
|
||
</div>
|
||
{chatScope === 'note' && (
|
||
<span className="text-[8px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.5 rounded-full uppercase font-bold">{t('ai.scopeAutoBadge')}</span>
|
||
)}
|
||
</button>
|
||
|
||
<div className="flex items-center gap-2 px-2">
|
||
<div className="h-px flex-1 bg-border/40" />
|
||
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">+ Carnet</span>
|
||
<div className="h-px flex-1 bg-border/40" />
|
||
</div>
|
||
|
||
<HierarchicalNotebookSelector
|
||
notebooks={(notebooks || []).filter(nb => !nb.trashedAt)}
|
||
selectedId={chatScope !== 'note' && chatScope !== 'all' ? chatScope : null}
|
||
onSelect={(id) => setChatScope(id)}
|
||
placeholder={t('ai.chatNotebookSelectPlaceholder')}
|
||
className="w-full"
|
||
size="sm"
|
||
dropUp
|
||
/>
|
||
</div>
|
||
|
||
{messages.length === 0 && (
|
||
<div className="h-48 flex flex-col items-center justify-center text-center space-y-3 text-concrete/30">
|
||
<div className="w-12 h-12 rounded-full border border-dashed border-concrete/10 flex items-center justify-center">
|
||
<MessageSquare size={18} />
|
||
</div>
|
||
<p className="text-[11px] italic leading-relaxed px-6">{t('ai.welcomeMsg')}</p>
|
||
</div>
|
||
)}
|
||
|
||
{messages.map((msg: UIMessage) => {
|
||
const content = getMessageContent(msg)
|
||
if (msg.role === 'assistant' && !content) return null
|
||
const isAssistant = msg.role === 'assistant'
|
||
return (
|
||
<div key={msg.id} className={cn('flex gap-3', !isAssistant && 'flex-row-reverse')} onMouseEnter={() => isAssistant && setHoveredMsgId(msg.id)} onMouseLeave={() => setHoveredMsgId(null)}>
|
||
<div className={cn(
|
||
'w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0',
|
||
!isAssistant ? 'bg-ink text-paper' : 'bg-brand-accent/10 text-brand-accent',
|
||
)}>
|
||
{!isAssistant ? 'U' : <Bot className="h-4 w-4" />}
|
||
</div>
|
||
<div className="relative group/msg max-w-[85%]">
|
||
<div className={cn(
|
||
'p-3.5 rounded-2xl text-sm leading-relaxed',
|
||
!isAssistant
|
||
? 'bg-ink text-paper rounded-tr-sm'
|
||
: 'bg-white/60 dark:bg-white/5 border border-border rounded-tl-sm text-ink',
|
||
)}>
|
||
{isAssistant ? <MarkdownContent content={content} /> : <p>{content}</p>}
|
||
</div>
|
||
{isAssistant && onApplyToNote && (hoveredMsgId === msg.id || messages.at(-1)?.id === msg.id) && (
|
||
<div className="flex gap-2 mt-2 opacity-0 group-hover/msg:opacity-100 transition-all">
|
||
<button onClick={() => handleInjectFromChat(content, 'replace')} className="px-2.5 py-1 rounded-lg text-[8px] font-bold uppercase tracking-widest bg-ink text-paper hover:opacity-90">{t('ai.injectReplace')}</button>
|
||
<button onClick={() => handleInjectFromChat(content, 'complete')} className="px-2.5 py-1 rounded-lg text-[8px] font-bold uppercase tracking-widest bg-white/50 dark:bg-white/5 border border-border text-ink hover:bg-white dark:hover:bg-white/10">{t('ai.injectComplete')}</button>
|
||
<button onClick={() => handleInjectFromChat(content, 'merge')} className="px-2.5 py-1 rounded-lg text-[8px] font-bold uppercase tracking-widest bg-white/50 dark:bg-white/5 border border-border text-ink hover:bg-white dark:hover:bg-white/10">{t('ai.injectMerge')}</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
{isLoading && (
|
||
<div className="flex gap-3">
|
||
<div className="w-8 h-8 rounded-xl bg-brand-accent/10 text-brand-accent flex items-center justify-center flex-shrink-0">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
</div>
|
||
<div className="bg-white/60 dark:bg-white/5 border border-border p-3.5 rounded-2xl rounded-tl-sm">
|
||
<Loader2 className="h-4 w-4 animate-spin text-concrete" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
<div className="p-6 bg-white/40 dark:bg-black/20 border-t border-border backdrop-blur-xl shrink-0">
|
||
<div className="relative group/chat">
|
||
<textarea
|
||
rows={4}
|
||
placeholder={t('ai.chatPlaceholder')}
|
||
className="w-full bg-white/80 dark:bg-white/5 border border-border rounded-[24px] p-5 pr-14 text-sm outline-none focus:border-brand-accent focus:ring-4 ring-brand-accent/5 transition-all resize-none leading-relaxed font-light shadow-inner text-ink"
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||
}}
|
||
disabled={isLoading}
|
||
/>
|
||
<div className="absolute right-4 bottom-4 flex flex-col gap-2">
|
||
{isLoading ? (
|
||
<button onClick={() => stop()} className="p-2.5 bg-rose-500 text-white rounded-xl transition-all hover:scale-110 active:scale-95 shadow-lg shadow-rose-500/20">
|
||
<Square size={16} />
|
||
</button>
|
||
) : (
|
||
<button onClick={handleSend} disabled={!input.trim()} className="p-2.5 bg-brand-accent text-white rounded-xl transition-all hover:scale-110 active:scale-95 shadow-lg shadow-brand-accent/20 disabled:opacity-40 disabled:pointer-events-none">
|
||
<Send size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="absolute left-6 bottom-4 flex gap-3 text-concrete/40">
|
||
<button
|
||
onClick={() => webSearchAvailable && setWebSearch(!webSearch)}
|
||
className={cn("hover:text-brand-accent transition-colors", webSearch && "text-brand-accent")}
|
||
>
|
||
<Globe size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-center mt-4">
|
||
<p className="text-[9px] text-concrete/40 uppercase tracking-[0.3em] font-bold">{t('ai.newLineHint') || "Maj+Entrée = nouvelle ligne"}</p>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{activeTab === 'actions' && (
|
||
<motion.div
|
||
key="actions"
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
className="flex flex-col flex-1 h-full min-h-0 overflow-y-auto p-6 space-y-10 custom-scrollbar"
|
||
>
|
||
{notebookId && (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="h-px flex-1 bg-border/40" />
|
||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">{t('ai.organization') || 'Organisation'}</h4>
|
||
<div className="h-px flex-1 bg-border/40" />
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleRegenerateLabels}
|
||
className="w-full flex items-center gap-3 p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl transition-all hover:border-brand-accent/30 cursor-pointer"
|
||
>
|
||
<div className="p-2 bg-slate-50 dark:bg-white/10 rounded-lg text-brand-accent shrink-0"><TagIcon size={18} /></div>
|
||
<div className="flex-1 text-left">
|
||
<h5 className="text-[10px] font-bold text-ink">{t('ai.autoLabels.regenerate') || 'Labels IA'}</h5>
|
||
<p className="text-[8px] text-concrete uppercase tracking-tight">{notebookName || ''}</p>
|
||
</div>
|
||
<RefreshCw size={14} className="text-brand-accent shrink-0" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-6">
|
||
<div className="flex items-center gap-2">
|
||
<div className="h-px flex-1 bg-border/40" />
|
||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">{t('ai.transformations')}</h4>
|
||
<div className="h-px flex-1 bg-border/40" />
|
||
</div>
|
||
|
||
{lastActionApplied && onUndoLastAction && (
|
||
<motion.button
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
onClick={onUndoLastAction}
|
||
className="w-full py-3.5 bg-brand-accent/20 border border-brand-accent/50 rounded-xl flex items-center justify-center gap-2 text-[11px] font-bold text-brand-accent uppercase tracking-[0.2em] hover:bg-brand-accent/30 transition-all shadow-md"
|
||
>
|
||
<RotateCcw size={12} /> {t('ai.undoLastAction')}
|
||
</motion.button>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{ACTION_IDS.filter(a => a.id !== 'markdown').map((action, i) => {
|
||
const loading = actionLoading === action.id
|
||
const isActive = action.id === 'translate' && showLangPicker
|
||
const Icon = action.icon
|
||
return (
|
||
<button key={i} onClick={() => action.id === 'translate' ? setShowLangPicker(v => !v) : handleAction(action)} disabled={!!actionLoading} className={cn("flex flex-col items-center gap-3 p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl transition-all group hover:border-ink/20 shadow-sm", isActive ? "border-brand-accent bg-brand-accent/5" : "")}>
|
||
<div className={cn("p-2 rounded-lg bg-slate-50 dark:bg-white/10 transition-colors group-hover:bg-brand-accent group-hover:text-white shadow-sm text-concrete", loading && "animate-pulse", isActive && "bg-brand-accent text-white")}>
|
||
{loading ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
|
||
</div>
|
||
<span className={cn("text-[10px] font-bold text-ink/80 uppercase tracking-widest", isActive ? "text-brand-accent" : "")}>{t(action.i18nKey)}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
|
||
<AnimatePresence mode="wait">
|
||
{showLangPicker && (
|
||
<motion.div
|
||
key="lang-picker"
|
||
initial={{ height: 0, opacity: 0 }}
|
||
animate={{ height: 'auto', opacity: 1 }}
|
||
exit={{ height: 0, opacity: 0 }}
|
||
className="col-span-2 overflow-hidden"
|
||
>
|
||
<div className="mt-2 p-5 bg-white/50 dark:bg-white/5 border border-brand-accent/30 rounded-2xl space-y-5">
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{TRANSLATE_LANGUAGE_OPTIONS.map(({ api, labelKey }) => (
|
||
<button
|
||
key={api}
|
||
onClick={() => setTranslateTarget(api)}
|
||
className={cn(
|
||
"py-2 px-1 rounded-lg border text-[10px] font-bold uppercase tracking-tighter transition-all",
|
||
translateTarget === api
|
||
? "bg-brand-accent border-brand-accent text-white shadow-md shadow-brand-accent/20"
|
||
: "bg-white/50 dark:bg-white/5 border-border text-concrete hover:border-ink/20"
|
||
)}
|
||
>
|
||
{t(labelKey)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-concrete px-1">{t('ai.otherLanguage')}</span>
|
||
<input
|
||
type="text"
|
||
value={customLangInput}
|
||
onChange={(e) => {
|
||
setCustomLangInput(e.target.value)
|
||
setTranslateTarget(e.target.value)
|
||
}}
|
||
placeholder={t('languages.customPlaceholder')}
|
||
className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl px-4 py-2.5 text-[11px] outline-none focus:border-brand-accent transition-all text-ink"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => handleAction(ACTION_IDS.find(a => a.id === 'translate')!, translateTarget)}
|
||
disabled={!translateTarget || !!actionLoading}
|
||
className="w-full py-3 bg-brand-accent text-white rounded-xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:opacity-90 disabled:opacity-50 shadow-lg shadow-brand-accent/20"
|
||
>
|
||
<Languages size={14} /> {t('ai.translateNow')}
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{diagramInsertFormat === 'html' ? (
|
||
<button
|
||
onClick={() => handleAction(ACTION_IDS.find(a => a.id === 'markdown')!)}
|
||
disabled={!!actionLoading}
|
||
className="col-span-2 flex items-center justify-center gap-3 py-3.5 bg-white/50 dark:bg-white/5 border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-white dark:hover:bg-white/10 transition-all hover:border-ink/20 uppercase tracking-[0.2em] shadow-sm disabled:opacity-50"
|
||
>
|
||
<Code size={14} className="text-concrete" />
|
||
{actionLoading === 'markdown' ? <Loader2 size={14} className="animate-spin" /> : t('ai.action.toMarkdown')}
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => handleAction(ACTION_IDS.find(a => a.id === 'toRichText')!)}
|
||
disabled={!!actionLoading}
|
||
className="col-span-2 flex items-center justify-center gap-3 py-3.5 bg-white/50 dark:bg-white/5 border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-white dark:hover:bg-white/10 transition-all hover:border-ink/20 uppercase tracking-[0.2em] shadow-sm disabled:opacity-50"
|
||
>
|
||
<Wand2 size={14} className="text-concrete" />
|
||
{actionLoading === 'toRichText' ? <Loader2 size={14} className="animate-spin" /> : t('ai.action.toRichText')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
<div className="flex items-center gap-2">
|
||
<div className="h-px flex-1 bg-border/40" />
|
||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">{t('ai.generationTools')}</h4>
|
||
<div className="h-px flex-1 bg-border/40" />
|
||
</div>
|
||
|
||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
|
||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||
<Layout size={80} className="text-brand-accent" />
|
||
</div>
|
||
<div className="relative space-y-5">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><Layout size={18} /></div>
|
||
<div className="space-y-0.5">
|
||
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.slides')}</h5>
|
||
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.sectionLabel')}</p>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1.5">
|
||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.theme')}</span>
|
||
<select value={slideTheme} onChange={e => setSlideTheme(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||
<option value="auto">{t('ai.generate.themeAuto')}</option>
|
||
<option value="Architectural SaaS">Architectural SaaS — SaaS, Produit</option>
|
||
<option value="Midnight Cathedral">Midnight Cathedral — Finance, Luxe</option>
|
||
<option value="Aurora Borealis">Aurora Borealis — Tech, IA</option>
|
||
<option value="Tokyo Neon">Tokyo Neon — Gaming</option>
|
||
<option value="Sunlit Gallery">Sunlit Gallery — Art, Culture</option>
|
||
<option value="Clinical Precision">Clinical Precision — Santé, Science</option>
|
||
<option value="Venture Pitch">Venture Pitch — Startup</option>
|
||
<option value="Forest Floor">Forest Floor — ESG, Nature</option>
|
||
<option value="Steel & Glass">Steel & Glass — Architecture</option>
|
||
<option value="Cyberpunk Terminal">Cyberpunk Terminal — Dev, Cyber</option>
|
||
<option value="Editorial Ink">Editorial Ink — Journalisme</option>
|
||
<option value="Coastal Morning">Coastal Morning — Éducation</option>
|
||
<option value="Paper Studio">Paper Studio — Research</option>
|
||
</select>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.style')}</span>
|
||
<select value={slideStyle} onChange={e => setSlideStyle(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||
<option value="professional">{t('ai.generate.styleProfessional')}</option>
|
||
<option value="creative">{t('ai.generate.styleCreative')}</option>
|
||
<option value="brutalist">{t('ai.generate.styleBrutalist')}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
|
||
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||
<option value="auto">{t('ai.generate.templateAuto')}</option>
|
||
<option value="board-update">{t('ai.generate.templateBoard')}</option>
|
||
<option value="project-status">{t('ai.generate.templateProject')}</option>
|
||
<option value="strategy-review">{t('ai.generate.templateStrategy')}</option>
|
||
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
|
||
</select>
|
||
</div>
|
||
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading} className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50">
|
||
{generateLoading === 'slides' ? <Loader2 size={14} className="animate-spin" /> : <><Presentation size={14} className="opacity-80" /> {t('ai.generating')}</>}
|
||
</button>
|
||
|
||
{/* Progress bar — visible only during slides generation */}
|
||
{generateLoading === 'slides' && (
|
||
<div className="space-y-1.5">
|
||
<div className="flex items-center justify-between text-[9px] text-foreground/40">
|
||
<span className="uppercase tracking-widest">⏳ Génération en cours…</span>
|
||
<span className="font-mono tabular-nums">{Math.round(generateProgress)}%</span>
|
||
</div>
|
||
<div className="w-full h-1.5 rounded-full bg-foreground/10 overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full bg-brand-accent transition-all duration-300 ease-out"
|
||
style={{ width: `${generateProgress}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{generateResult?.type === 'slides' && generateResult.canvasId && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="mt-4 p-4 bg-brand-accent/10 border border-brand-accent/20 rounded-xl space-y-3"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest flex items-center gap-1.5">
|
||
<Check size={12} /> {t('ai.presentationReadyBadge')}
|
||
</span>
|
||
<a
|
||
href={`/lab?id=${generateResult.canvasId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="p-1.5 bg-card/60 rounded-lg text-foreground/50 hover:text-foreground hover:bg-card transition-colors"
|
||
title={t('ai.openInLabTitle')}
|
||
>
|
||
<ExternalLink size={12} />
|
||
</a>
|
||
</div>
|
||
<a
|
||
href={`/lab?id=${generateResult.canvasId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex items-center justify-center gap-2 w-full py-2.5 bg-brand-accent text-white rounded-lg text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 transition-opacity shadow-sm"
|
||
>
|
||
<Presentation size={13} />
|
||
{t('ai.viewSlidesButton')}
|
||
</a>
|
||
</motion.div>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
|
||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||
<BookOpen size={80} className="text-brand-accent" />
|
||
</div>
|
||
<div className="relative space-y-5">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><BookOpen size={18} /></div>
|
||
<div className="space-y-0.5">
|
||
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.diagram')}</h5>
|
||
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.diagramReadyHint')}</p>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1.5">
|
||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.diagramType')}</span>
|
||
<select value={diagramType} onChange={e => setDiagramType(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||
<option value="auto">{t('ai.generate.typeAuto')}</option>
|
||
<option value="flowchart">{t('ai.generate.typeFlowchart')}</option>
|
||
<option value="mind_map">{t('ai.generate.typeMindMap')}</option>
|
||
<option value="timeline">{t('ai.generate.typeTimeline')}</option>
|
||
<option value="org_chart">{t('ai.generate.typeOrgChart')}</option>
|
||
<option value="architecture">{t('ai.generate.typeArchitecture')}</option>
|
||
<option value="process_map">{t('ai.generate.typeProcessMap')}</option>
|
||
</select>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.style')}</span>
|
||
<select value={diagramStyle} onChange={e => setDiagramStyle(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||
<option value="sketchy">{t('ai.generate.styleSketchy')}</option>
|
||
<option value="soft">{t('ai.generate.styleSoft')}</option>
|
||
<option value="minimal">{t('ai.generate.styleMinimal')}</option>
|
||
<option value="professional">{t('ai.generate.styleProfessional')}</option>
|
||
<option value="draft">{t('ai.generate.styleDraft')}</option>
|
||
<option value="polished">{t('ai.generate.stylePolished')}</option>
|
||
<option value="handwritten">{t('ai.generate.styleHandwritten')}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button onClick={() => handleGenerate('diagram')} disabled={!!generateLoading} className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50">
|
||
{generateLoading === 'diagram' ? <Loader2 size={14} className="animate-spin" /> : <>{t('ai.generating')} <ArrowRightLeft size={14} className="opacity-60" /></>}
|
||
</button>
|
||
|
||
{generateResult?.type === 'diagram' && generateResult.canvasId && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="mt-4 p-4 bg-brand-accent/10 border border-brand-accent/20 rounded-xl space-y-3"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest">{t('ai.generate.diagramReady')}</span>
|
||
<div className="flex gap-2">
|
||
<a
|
||
href={`/lab?id=${generateResult.canvasId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="p-2 bg-card/60 rounded-lg text-foreground hover:bg-card transition-colors"
|
||
title={t('ai.generate.openInExcalidraw')}
|
||
>
|
||
<ExternalLink size={14} />
|
||
</a>
|
||
<button
|
||
onClick={() => handleEmbedDiagramInNote(generateResult.canvasId!)}
|
||
disabled={diagramEmbedLoading}
|
||
className="p-2 bg-brand-accent text-white rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
|
||
title={t('ai.generate.insertDiagramInNote')}
|
||
>
|
||
{diagramEmbedLoading ? <Loader2 size={14} className="animate-spin" /> : <ImagePlus size={14} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Personas IA ── */}
|
||
<PersonasPanel noteTitle={noteTitle} noteContent={noteContent} />
|
||
|
||
</motion.div>
|
||
)}
|
||
|
||
{activeTab === 'resource' && (
|
||
<motion.div
|
||
key="resource"
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
className="flex flex-col flex-1 h-full min-h-0 overflow-y-auto p-6 space-y-8 custom-scrollbar"
|
||
>
|
||
<div className="space-y-6">
|
||
<div className="space-y-3">
|
||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resource.urlLabel')}</label>
|
||
<div className="relative">
|
||
<input type="text" placeholder="https://..." className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl pl-4 pr-12 py-3 text-xs outline-none focus:border-brand-accent transition-colors text-ink" value={resourceUrl} onChange={e => setResourceUrl(e.target.value)} />
|
||
<Globe size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-concrete/40" />
|
||
</div>
|
||
</div>
|
||
<div className="space-y-3">
|
||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resource.resourceText')}</label>
|
||
<textarea rows={10} placeholder={t('ai.resource.resourcePlaceholder')} className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl p-4 text-xs outline-none focus:border-brand-accent transition-colors resize-none leading-relaxed text-ink" value={resourceText} onChange={e => setResourceText(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-4">
|
||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resource.integrationMode')}</label>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{[
|
||
{ id: 'replace', label: t('ai.resource.modeReplace'), sub: t('ai.resource.modeReplaceDesc') },
|
||
{ id: 'complete', label: t('ai.resource.modeComplete'), sub: t('ai.resource.modeCompleteDesc') },
|
||
{ id: 'merge', label: t('ai.resource.modeMerge'), sub: t('ai.resource.modeMergeDesc') },
|
||
].map((mode) => (
|
||
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white border-border hover:bg-slate-50 dark:hover:bg-white/5')}>
|
||
<span className={cn("text-[10px] font-bold", resourceMode === mode.id ? 'text-brand-accent' : 'text-ink')}>{mode.label}</span>
|
||
<span className="text-[8px] text-concrete opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button onClick={handleResourcePreview} disabled={resourceEnriching || resourceScraping || !resourceText.trim()} className="w-full py-4 bg-brand-accent text-white rounded-xl text-[11px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-brand-accent/20 disabled:opacity-50">
|
||
{resourceEnriching || resourceScraping ? <Loader2 size={18} className="animate-spin" /> : <Sparkles size={18} />}
|
||
{t('ai.resource.generatePreview')}
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
{/* Shared footer for all tabs to ensure consistent bottom spacing */}
|
||
<div className="flex flex-col items-center gap-4 py-8 opacity-20 mt-auto shrink-0 border-t border-border/10">
|
||
<PenTool size={20} />
|
||
<span className="text-[9px] font-bold uppercase tracking-[0.3em] whitespace-nowrap italic text-center">
|
||
{t('nav.workspace') || 'Memento Workspace'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
{autoLabelOpen && notebookId && (
|
||
<AutoLabelSuggestionDialog
|
||
open={autoLabelOpen}
|
||
onOpenChange={setAutoLabelOpen}
|
||
notebookId={notebookId}
|
||
onLabelsCreated={() => {
|
||
mToast.success(t('ai.autoLabels.created', { count: 0 }) || 'Labels créés')
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|