All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s
- Renamed 'AI Copilot' / 'Assistant IA' → 'IA Note' everywhere in UI - Added 3rd 'Ressource' tab in IA Note panel with: * Optional URL field that scrapes page text (via new scrapePageText action) * Textarea for paste (markdown, HTML, plain text) * 3 integration modes: Remplacer / Compléter (AI) / Fusionner (AI) * Dual-format preview: Rendu + Markdown brut before applying - Added hover-actions on assistant chat messages: * Remplacer / Compléter / Fusionner appear on hover * Triggers same preview/apply flow via resource tab - New API route: POST /api/ai/enrich-from-resource * Supports complete and merge modes with language-aware prompts - Extended scrape.ts with scrapePageText() (full content extraction)
944 lines
44 KiB
TypeScript
944 lines
44 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,
|
|
} from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { MarkdownContent } from '@/components/markdown-content'
|
|
import { toast } from 'sonner'
|
|
import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { getNotebookIcon } from '@/lib/notebook-icon'
|
|
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: '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 ContextualAIChatProps {
|
|
onClose: () => void
|
|
noteTitle?: string
|
|
noteContent?: string
|
|
noteImages?: string[]
|
|
/** Called when an action result should be injected into the note */
|
|
onApplyToNote?: (newContent: string) => void
|
|
/** Called when the user wants to undo the last injected action */
|
|
onUndoLastAction?: () => void
|
|
/** Whether the last action has been applied (so we can show undo) */
|
|
lastActionApplied?: boolean
|
|
/** Notebooks available for scope selection */
|
|
notebooks?: Array<{ id: string; name: string }>
|
|
/** Extra classes forwarded to the aside root element */
|
|
className?: string
|
|
}
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
|
|
export function ContextualAIChat({
|
|
onClose,
|
|
noteTitle,
|
|
noteContent,
|
|
noteImages,
|
|
onApplyToNote,
|
|
onUndoLastAction,
|
|
lastActionApplied = false,
|
|
notebooks = [],
|
|
className,
|
|
}: ContextualAIChatProps) {
|
|
const { t, language } = useLanguage()
|
|
const webSearchAvailable = useWebSearchAvailable()
|
|
|
|
const [activeTab, setActiveTab] = useState<'chat' | 'actions' | '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)
|
|
|
|
// 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 isLoading = status === 'submitted' || status === 'streaming'
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages])
|
|
|
|
useEffect(() => {
|
|
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
|
|
return () => {
|
|
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false }))
|
|
}
|
|
}, [])
|
|
|
|
// ── Chat send ───────────────────────────────────────────────────────────────
|
|
const handleSend = async () => {
|
|
const text = input.trim()
|
|
if (!text || isLoading) return
|
|
setInput('')
|
|
await sendMessage({ text }, { body: buildChatBody() })
|
|
}
|
|
|
|
// ── Action execution ────────────────────────────────────────────────────────
|
|
const handleAction = async (action: ActionDef) => {
|
|
// 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, 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)
|
|
|
|
// ── Resource tab handlers ────────────────────────────────────────────────────
|
|
|
|
const handleScrapeUrl = async () => {
|
|
if (!resourceUrl.trim()) return
|
|
setResourceScraping(true)
|
|
try {
|
|
const result = await scrapePageText(resourceUrl.trim())
|
|
if (!result) { toast.error('Impossible de charger cette URL'); return }
|
|
setResourceText(result.text)
|
|
toast.success(`Page chargée : ${result.title.slice(0, 40)}`)
|
|
} catch {
|
|
toast.error('Erreur lors du chargement de la page')
|
|
} finally {
|
|
setResourceScraping(false)
|
|
}
|
|
}
|
|
|
|
const handleResourcePreview = async () => {
|
|
if (!resourceText.trim()) { toast.error('Collez du texte ou chargez une URL d\'abord'); 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 || 'Erreur IA')
|
|
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
|
|
} catch (e: any) {
|
|
toast.error(e.message || 'Erreur lors de l\'enrichissement')
|
|
} finally {
|
|
setResourceEnriching(false)
|
|
}
|
|
}
|
|
|
|
const handleApplyResourcePreview = () => {
|
|
if (!resourcePreview || !onApplyToNote) return
|
|
onApplyToNote(resourcePreview.text)
|
|
setResourcePreview(null)
|
|
setResourceText('')
|
|
setResourceUrl('')
|
|
toast.success('Contenu appliqué à la note ✓')
|
|
}
|
|
|
|
/** Called from chat hover-actions: inject a chat message into the note */
|
|
const handleInjectFromChat = async (msgText: string, mode: 'replace' | 'complete' | 'merge') => {
|
|
if (mode === 'replace') {
|
|
setResourceText(msgText)
|
|
setResourceMode('replace')
|
|
setResourcePreview({ text: msgText, source: 'chat' })
|
|
setActiveTab('resource')
|
|
return
|
|
}
|
|
setResourceText(msgText)
|
|
setResourceMode(mode)
|
|
setActiveTab('resource')
|
|
// Auto-launch enrichment
|
|
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 || 'Erreur enrichissement')
|
|
} 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-4 py-3 border-b border-border/40 flex items-center justify-between shrink-0">
|
|
<div className="min-w-0">
|
|
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
|
|
<Sparkles className="h-4 w-4 text-primary shrink-0" />
|
|
IA Note
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
{lastActionApplied && onUndoLastAction && (
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/30"
|
|
onClick={onUndoLastAction}
|
|
title={t('ai.undoLastAction')}
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
{/* Expand / Shrink */}
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
|
onClick={() => setExpanded(e => !e)}
|
|
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
|
|
>
|
|
{expanded
|
|
? <Minimize2 className="h-3.5 w-3.5" />
|
|
: <Maximize2 className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7 text-muted-foreground hover:text-foreground">
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Tabs ─────────────────────────────────────────────────── */}
|
|
<div className="flex border-b border-border/40 shrink-0">
|
|
{([
|
|
{ id: 'chat', icon: Bot, label: t('ai.chatTab') },
|
|
{ id: 'actions', icon: Wand2, label: t('ai.noteActions') },
|
|
{ id: 'resource', icon: ArrowDownToLine, label: 'Ressource' },
|
|
] as const).map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id as any)}
|
|
className={cn(
|
|
'flex-1 py-2.5 border-b-2 text-xs font-semibold flex items-center justify-center gap-1.5 transition-all',
|
|
activeTab === tab.id
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
<tab.icon className="h-3 w-3" />
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ══════════════════════════════════════════════════════════ */}
|
|
{/* ── TAB: CHAT ─────────────────────────────────────────── */}
|
|
{/* ══════════════════════════════════════════════════════════ */}
|
|
{activeTab === 'chat' && (
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
{/* Messages */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3 flex flex-col">
|
|
{messages.length === 0 && (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-center opacity-60">
|
|
<div className="w-14 h-14 rounded-full bg-primary/5 flex items-center justify-center border border-primary/10 mb-4">
|
|
<Sparkles className="h-6 w-6 text-primary/50" />
|
|
</div>
|
|
<p className="text-sm text-muted-foreground max-w-[220px]">
|
|
{t('ai.askToStart')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((msg: UIMessage) => {
|
|
const content = getMessageContent(msg)
|
|
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>
|
|
{/* Hover-actions — visible only on assistant messages */}
|
|
{isAssistant && onApplyToNote && (
|
|
<div className={cn(
|
|
'flex gap-1 transition-all duration-150',
|
|
isHovered ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-1 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="Remplacer le contenu de la note par ce message"
|
|
>
|
|
<Download className="h-2.5 w-2.5" /> Remplacer
|
|
</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="Compléter la note avec ce message (IA)"
|
|
>
|
|
<PlusCircle className="h-2.5 w-2.5" /> Compléter
|
|
</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="Fusionner avec la note (IA)"
|
|
>
|
|
<GitMerge className="h-2.5 w-2.5" /> Fusionner
|
|
</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>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Scope & Tone Control Area */}
|
|
<div className="border-t border-border/20 shrink-0 bg-muted/10">
|
|
{/* Scope bar */}
|
|
<div className="px-3 py-2 border-b border-border/10 flex flex-col gap-1.5">
|
|
<label className="text-xs font-bold tracking-wider text-muted-foreground uppercase">
|
|
{t('ai.contextLabel')}
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<Select value={chatScope} onValueChange={setChatScope}>
|
|
<SelectTrigger className="h-8 flex-1 text-sm bg-card">
|
|
<SelectValue placeholder={t('ai.selectContext')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="note" className="text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="h-3 w-3 text-muted-foreground" /> {t('ai.thisNote')}
|
|
</div>
|
|
</SelectItem>
|
|
<SelectItem value="all" className="text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<Bot className="h-3 w-3 text-muted-foreground" /> {t('ai.allMyNotes')}
|
|
</div>
|
|
</SelectItem>
|
|
{notebooks.map(nb => (
|
|
<SelectItem key={nb.id} value={nb.id} className="text-xs">
|
|
<div className="flex items-center gap-2">
|
|
{(() => {
|
|
const Icon = getNotebookIcon((nb as any).icon)
|
|
return <Icon className="w-3 h-3 text-muted-foreground" />
|
|
})()}
|
|
{nb.name.length > 25 ? nb.name.slice(0, 25) + '…' : nb.name}
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tone selector */}
|
|
<div className="px-3 py-2 flex flex-col gap-1.5">
|
|
<label className="text-xs font-bold tracking-wider text-muted-foreground uppercase">
|
|
{t('ai.writingTone')}
|
|
</label>
|
|
<div className="grid grid-cols-4 gap-1">
|
|
{TONES.map(tone => {
|
|
const Icon = tone.icon
|
|
const sel = selectedTone === tone.id
|
|
return (
|
|
<button
|
|
key={tone.id}
|
|
onClick={() => setSelectedTone(tone.id)}
|
|
title={tone.full}
|
|
className={cn(
|
|
'py-1.5 rounded border text-xs font-medium transition-all flex flex-col items-center gap-1',
|
|
sel
|
|
? 'border-primary bg-primary/10 text-primary'
|
|
: 'border-border/40 text-muted-foreground hover:bg-muted bg-card',
|
|
)}
|
|
>
|
|
<Icon className="h-3 w-3" />
|
|
{tone.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="p-3 border-t border-border/40 shrink-0 bg-card">
|
|
<div className="relative bg-card border border-border/60 rounded-xl p-1 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20 transition-all">
|
|
<textarea
|
|
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/60 p-2 min-h-[64px] max-h-[120px]"
|
|
placeholder={
|
|
chatScope === 'note'
|
|
? t('ai.askAboutThisNote')
|
|
: t('ai.askAboutYourNotes')
|
|
}
|
|
value={input}
|
|
onChange={e => setInput(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
|
}}
|
|
disabled={isLoading}
|
|
/>
|
|
<div className="flex justify-between items-center px-1 pb-1 pt-1">
|
|
<div className="flex items-center gap-2">
|
|
{webSearchAvailable && (
|
|
<button
|
|
onClick={() => setWebSearch(w => !w)}
|
|
title={t('ai.webSearchLabel')}
|
|
className={cn(
|
|
'flex items-center justify-center gap-1.5 h-8 px-3 rounded-md border transition-all text-xs font-medium',
|
|
webSearch
|
|
? 'border-emerald-500 bg-emerald-50 text-emerald-600 dark:bg-emerald-950/30'
|
|
: 'border-border/50 text-muted-foreground hover:bg-muted bg-card',
|
|
)}
|
|
>
|
|
<Globe className="h-3 w-3" />
|
|
Web
|
|
</button>
|
|
)}
|
|
</div>
|
|
{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">{noteImages.length} images</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
|
|
return (
|
|
<button
|
|
key={action.id}
|
|
onClick={() => handleAction(action)}
|
|
disabled={!!actionLoading}
|
|
className="w-full flex items-center gap-3 rounded-xl border border-border/60 bg-card px-4 py-3 text-sm font-medium text-foreground hover:bg-muted hover:border-primary/40 transition-all text-left disabled:opacity-60"
|
|
>
|
|
{loading
|
|
? <Loader2 className="h-4 w-4 text-primary animate-spin shrink-0" />
|
|
: <Icon className="h-4 w-4 text-primary shrink-0" />
|
|
}
|
|
<span>{t(action.i18nKey)}</span>
|
|
{loading && <span className="ml-auto text-[10px] text-muted-foreground">{t('ai.processingAction')}</span>}
|
|
</button>
|
|
)
|
|
})
|
|
)}
|
|
|
|
{/* Undo last action shortcut */}
|
|
{lastActionApplied && onUndoLastAction && (
|
|
<button
|
|
onClick={onUndoLastAction}
|
|
className="w-full mt-2 flex items-center gap-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 px-4 py-2.5 text-sm font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-950/50 transition-all text-left"
|
|
>
|
|
<RotateCcw className="h-4 w-4 shrink-0" />
|
|
{t('ai.undoLastAction')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ══════════════════════════════════════════════════════════ */}
|
|
{/* ── 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' ? '💬 Depuis le chat'
|
|
: resourcePreview.source === 'replace' ? '↓ Remplacement'
|
|
: resourcePreview.source === 'complete' ? '✦ Complété par IA'
|
|
: '⟳ Fusionné par IA'}
|
|
</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" /> Rendu
|
|
</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" /> Annuler
|
|
</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" /> Appliquer à la note
|
|
</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" />URL (optionnel)
|
|
</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" />Texte de la ressource
|
|
</label>
|
|
<textarea
|
|
value={resourceText}
|
|
onChange={e => setResourceText(e.target.value)}
|
|
placeholder="Collez votre texte ici (markdown, HTML, texte brut…)"
|
|
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} mots
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mode selector */}
|
|
<div>
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
|
|
Mode d'intégration
|
|
</label>
|
|
<div className="grid grid-cols-3 gap-1.5">
|
|
{([
|
|
{ id: 'replace', label: 'Remplacer', desc: 'Direct, sans IA', icon: Download, color: 'primary' },
|
|
{ id: 'complete', label: 'Compléter', desc: 'Ajoute sans réécrire', icon: PlusCircle, color: 'emerald' },
|
|
{ id: 'merge', label: 'Fusionner', desc: 'Réécrit et intègre', 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" /> IA en cours…</>
|
|
: resourceMode === 'replace'
|
|
? <><Eye className="h-4 w-4" /> Aperçu</>
|
|
: <><Sparkles className="h-4 w-4" /> Générer l'aperçu</>
|
|
}
|
|
</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">
|
|
💡 La note est vide — le contenu de la ressource sera intégré directement.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</aside>
|
|
)
|
|
}
|