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
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
86
memento-note/app/api/ai/enrich-from-resource/route.ts
Normal file
86
memento-note/app/api/ai/enrich-from-resource/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user