diff --git a/memento-note/app/actions/scrape.ts b/memento-note/app/actions/scrape.ts index a63c410..1a0bd2d 100644 --- a/memento-note/app/actions/scrape.ts +++ b/memento-note/app/actions/scrape.ts @@ -97,3 +97,107 @@ export async function fetchLinkMetadata(url: string): Promise { + 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 + } +} diff --git a/memento-note/app/api/ai/enrich-from-resource/route.ts b/memento-note/app/api/ai/enrich-from-resource/route.ts new file mode 100644 index 0000000..0ce145b --- /dev/null +++ b/memento-note/app/api/ai/enrich-from-resource/route.ts @@ -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 } + ) + } +} diff --git a/memento-note/components/contextual-ai-chat.tsx b/memento-note/components/contextual-ai-chat.tsx index 5461aa8..dfe377d 100644 --- a/memento-note/components/contextual-ai-chat.tsx +++ b/memento-note/components/contextual-ai-chat.tsx @@ -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(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(null) + const messagesEndRef = useRef(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 (