feat: IA Note — rename panel, add Resource tab + chat hover-actions
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)
This commit is contained in:
2026-05-02 21:06:25 +02:00
parent 547c5ffecb
commit 99d0583871
4 changed files with 552 additions and 27 deletions

View File

@@ -97,3 +97,107 @@ export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | nul
return null;
}
}
/**
* Scrape full readable text content from a URL.
* Removes nav, header, footer, scripts, and ads — keeps main content only.
* Returns markdown-structured plain text (preserves paragraph/heading structure).
*/
export async function scrapePageText(url: string): Promise<{ text: string; title: string } | null> {
try {
let targetUrl = url
if (!url.startsWith('http://') && !url.startsWith('https://')) {
targetUrl = 'https://' + url
}
// SSRF protection
const parsed = new URL(targetUrl)
const hostname = parsed.hostname.toLowerCase()
const blockedHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '169.254.169.254']
if (blockedHosts.includes(hostname)) return null
if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.') || hostname.startsWith('fc') || hostname.startsWith('fd')) return null
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
let response: Response
try {
response = await fetch(targetUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'fr,en;q=0.8',
},
signal: controller.signal,
redirect: 'follow',
})
} catch (fetchError: any) {
clearTimeout(timeoutId)
console.error(`[ScrapeText] Fetch error for ${url}:`, fetchError.message)
return null
}
clearTimeout(timeoutId)
if (!response.ok) return null
const contentType = response.headers.get('content-type') || ''
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
// Plain text or other — return raw text
const text = await response.text()
return { text: text.slice(0, 50000), title: url }
}
const html = await response.text()
const $ = cheerio.load(html)
// Extract title
const getMeta = (prop: string) =>
$(`meta[property="${prop}"]`).attr('content') ||
$(`meta[name="${prop}"]`).attr('content')
const title = getMeta('og:title') || $('title').text()?.trim() || url
// Remove noise elements
$('script, style, noscript, nav, header, footer, aside, iframe, img, svg, figure, form, button, input, select, textarea, [role="navigation"], [role="banner"], [role="complementary"], .ads, .advertisement, .cookie-banner, .popup, .modal').remove()
// Try to find main content container
const mainSelectors = ['main', 'article', '[role="main"]', '.content', '.post-content', '.article-body', '.entry-content', '#content', '#main']
let mainEl = null
for (const sel of mainSelectors) {
if ($(sel).length) { mainEl = $(sel).first(); break }
}
const container = mainEl || $('body')
// Extract text preserving paragraph/heading structure as markdown
const lines: string[] = []
container.find('h1, h2, h3, h4, h5, h6, p, li, blockquote, pre, td, th').each((_, el) => {
const tag = (el as any).tagName?.toLowerCase()
const text = $(el).text().trim()
if (!text || text.length < 3) return
if (['h1', 'h2', 'h3'].includes(tag)) {
lines.push(`\n## ${text}`)
} else if (['h4', 'h5', 'h6'].includes(tag)) {
lines.push(`\n### ${text}`)
} else if (tag === 'li') {
lines.push(`- ${text}`)
} else if (tag === 'blockquote') {
lines.push(`> ${text}`)
} else if (tag === 'pre') {
lines.push(`\`\`\`\n${text}\n\`\`\``)
} else {
lines.push(text)
}
})
const text = lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
// Limit to ~50k characters to avoid token overflows
return { text: text.slice(0, 50000), title: title.trim() }
} catch (error) {
console.error(`[ScrapeText] Error for ${url}:`, error)
return null
}
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { existingContent, resourceText, mode, language } = await request.json()
if (!resourceText || typeof resourceText !== 'string') {
return NextResponse.json({ error: 'resourceText is required' }, { status: 400 })
}
if (!mode || !['complete', 'merge'].includes(mode)) {
return NextResponse.json({ error: 'mode must be "complete" or "merge"' }, { status: 400 })
}
const lang = language || 'fr'
const config = await getSystemConfig()
const provider = getTagsProvider(config)
let prompt: string
if (mode === 'complete') {
// Add missing info from resource without rewriting the existing note
prompt = `You are an expert note editor. Your task is to enrich an existing note by adding relevant information from a provided resource, WITHOUT modifying or rewriting the existing content.
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
EXISTING NOTE:
---
${existingContent || '(empty note)'}
---
RESOURCE TO INTEGRATE:
---
${resourceText}
---
INSTRUCTIONS:
- Keep ALL existing note content exactly as-is at the top
- Append ONLY new, non-redundant information from the resource below the existing content
- Use a clear separator (e.g., "---" or a new section heading) between existing and new content
- Skip information already covered in the existing note
- Format the new content consistently with the existing note style
- Respond ONLY with the enriched note content, no explanations`
} else {
// Merge: intelligently rewrite integrating both sources
prompt = `You are an expert note writer. Your task is to intelligently merge an existing note with a resource into a single, coherent, well-structured document.
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
EXISTING NOTE:
---
${existingContent || '(empty note)'}
---
RESOURCE TO INTEGRATE:
---
${resourceText}
---
INSTRUCTIONS:
- Combine both sources into one cohesive, well-organized document
- Eliminate redundancy — include each piece of information only once
- Preserve the key ideas from both sources
- Maintain a logical structure with clear headings if appropriate
- Keep the tone and style consistent
- Respond ONLY with the merged content, no meta-commentary or explanations`
}
const enrichedContent = await provider.generateText(prompt)
return NextResponse.json({ enrichedContent: enrichedContent.trim() })
} catch (error: any) {
console.error('[enrich-from-resource] Error:', error)
return NextResponse.json(
{ error: error.message || 'Failed to enrich content' },
{ status: 500 }
)
}
}

View File

@@ -11,7 +11,8 @@ import {
Briefcase, Palette, GraduationCap, Coffee,
Lightbulb, Minimize2, AlignLeft, Wand2,
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2, ImageIcon,
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
GitMerge, PlusCircle, Eye, Code,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
@@ -25,6 +26,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { scrapePageText } from '@/app/actions/scrape'
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -101,10 +103,10 @@ export function ContextualAIChat({
const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable()
const [activeTab, setActiveTab] = useState<'chat' | 'actions'>('chat')
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') // 'note', 'all', or notebook ID
const [chatScope, setChatScope] = useState<'note' | 'all' | string>('note')
const [webSearch, setWebSearch] = useState(false)
const [expanded, setExpanded] = useState(false)
@@ -112,6 +114,17 @@ export function ContextualAIChat({
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
@@ -230,12 +243,102 @@ export function ContextualAIChat({
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',
@@ -248,7 +351,7 @@ export function ContextualAIChat({
<div className="min-w-0">
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary shrink-0" />
{t('ai.assistantTitle')}
IA Note
</h2>
<p className="text-xs text-muted-foreground truncate">
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
@@ -284,20 +387,23 @@ export function ContextualAIChat({
{/* ── Tabs ─────────────────────────────────────────────────── */}
<div className="flex border-b border-border/40 shrink-0">
{(['chat', 'actions'] as const).map(tab => (
{([
{ 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}
onClick={() => setActiveTab(tab)}
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={cn(
'flex-1 py-3 border-b-2 text-sm font-semibold flex items-center justify-center gap-2 transition-all capitalize',
activeTab === tab
'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 === 'chat' && <Bot className="h-3.5 w-3.5" />}
{tab === 'actions' && <Wand2 className="h-3.5 w-3.5" />}
{tab === 'chat' ? t('ai.chatTab') : t('ai.noteActions')}
<tab.icon className="h-3 w-3" />
{tab.label}
</button>
))}
</div>
@@ -322,32 +428,70 @@ export function ContextualAIChat({
{messages.map((msg: UIMessage) => {
const content = getMessageContent(msg)
// Skip empty assistant messages (thinking/reasoning phase)
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', msg.role === 'user' && 'flex-row-reverse')}>
<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',
msg.role === 'user'
!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',
)}>
{msg.role === 'user' ? 'U' : <Bot className="h-3 w-3" />}
{!isAssistant ? 'U' : <Bot className="h-3 w-3" />}
</div>
<div className={cn(
'max-w-[88%] p-3 rounded-2xl text-sm leading-relaxed',
msg.role === 'user'
? 'bg-primary text-primary-foreground rounded-tr-sm'
: 'bg-muted/40 border border-border/40 rounded-tl-sm text-foreground',
)}>
{msg.role === 'assistant'
? <MarkdownContent content={content} />
: <p>{content}</p>}
<div 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">
@@ -603,6 +747,197 @@ export function ContextualAIChat({
)}
</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>
)
}

View File

@@ -552,11 +552,11 @@ export function NoteInlineEditor({
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)}
title={t('ai.aiCopilot')}>
title="IA Note">
{isProcessingAI
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Sparkles className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{t('ai.aiCopilot')}</span>
<span className="hidden sm:inline">IA Note</span>
</Button>
)}