From ae89f8a01431b3e841eb2a321b8bd563c1d9e063 Mon Sep 17 00:00:00 2001 From: sepehr Date: Mon, 27 Apr 2026 22:34:07 +0200 Subject: [PATCH] feat: chat stop button, image paste, vision AI, search fixes - Add stop button to all chat interfaces (floating, contextual, full-page) - Add conversation sliding window (50 messages) to prevent context overflow - Add chat timeout warning (30s toast) - Force response language in chat system prompt (mandatory per-locale) - Add image paste from clipboard in all note editors (card, list, input) - Fix upload API to infer extension from MIME type for clipboard images - Add image description support in note AI chat (base64 vision) - Fix search regex crash on special characters (escape user input) - Fix search case-insensitivity on PostgreSQL (mode: 'insensitive') - Add try/catch around semantic search in chat route (prevent blocking) - Add new chat button to floating AI assistant - Fix empty thinking bubbles for reasoning models (filter non-text parts) - Remove duplicate AI assistant toggle from note editor header - Improve link metadata scraping (timeout, content-type check, relative URLs) Co-Authored-By: Claude Opus 4.7 --- memento-note/app/actions/scrape.ts | 60 +++++++++--- memento-note/app/api/chat/route.ts | 69 +++++++++++--- memento-note/app/api/upload/route.ts | 11 ++- memento-note/components/ai-chat.tsx | 95 ++++++++++++------- .../components/chat/chat-container.tsx | 11 +++ memento-note/components/chat/chat-input.tsx | 39 +++++--- .../components/contextual-ai-chat.tsx | 41 +++++--- memento-note/components/note-editor.tsx | 62 ++++++------ .../components/note-inline-editor.tsx | 43 +++++++-- memento-note/components/note-input.tsx | 33 +++++++ .../ai/services/semantic-search.service.ts | 11 ++- memento-note/locales/en.json | 3 +- memento-note/locales/fr.json | 3 +- 13 files changed, 354 insertions(+), 127 deletions(-) diff --git a/memento-note/app/actions/scrape.ts b/memento-note/app/actions/scrape.ts index f9e9d07..a63c410 100644 --- a/memento-note/app/actions/scrape.ts +++ b/memento-note/app/actions/scrape.ts @@ -26,31 +26,63 @@ export async function fetchLinkMetadata(url: string): Promise controller.abort(), 10000) + + 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,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + }, + signal: controller.signal, + redirect: 'follow', + }); + } catch (fetchError: any) { + clearTimeout(timeoutId) + if (fetchError.name === 'AbortError') { + console.error(`[Scrape] Timeout fetching ${url} (10s)`) + } else { + console.error(`[Scrape] Network error fetching ${url}:`, fetchError.message) + } + return null + } + + clearTimeout(timeoutId) if (!response.ok) { + console.error(`[Scrape] HTTP ${response.status} for ${url}`) return null; } + const contentType = response.headers.get('content-type') || '' + if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) { + // Not HTML — return basic metadata + return { url: targetUrl, title: targetUrl }; + } + const html = await response.text(); const $ = cheerio.load(html); - const getMeta = (prop: string) => - $(`meta[property="${prop}"]`).attr('content') || + const getMeta = (prop: string) => + $(`meta[property="${prop}"]`).attr('content') || $(`meta[name="${prop}"]`).attr('content'); - // Robust extraction with fallbacks - const title = getMeta('og:title') || $('title').text() || getMeta('twitter:title') || url; + const title = getMeta('og:title') || $('title').text()?.trim() || getMeta('twitter:title') || url; const description = getMeta('og:description') || getMeta('description') || getMeta('twitter:description') || ''; - const imageUrl = getMeta('og:image') || getMeta('twitter:image') || $('link[rel="image_src"]').attr('href'); + let imageUrl = getMeta('og:image') || getMeta('twitter:image') || $('link[rel="image_src"]').attr('href'); + + // Resolve relative image URLs + if (imageUrl && !imageUrl.startsWith('http')) { + try { + imageUrl = new URL(imageUrl, targetUrl).href + } catch { + imageUrl = undefined + } + } + const siteName = getMeta('og:site_name') || ''; return { diff --git a/memento-note/app/api/chat/route.ts b/memento-note/app/api/chat/route.ts index 432fa45..f160bfa 100644 --- a/memento-note/app/api/chat/route.ts +++ b/memento-note/app/api/chat/route.ts @@ -7,6 +7,8 @@ import { auth } from '@/auth' import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n' import { toolRegistry } from '@/lib/ai/tools' import { stepCountIs } from 'ai' +import { readFile } from 'fs/promises' +import path from 'path' export const maxDuration = 60 @@ -47,13 +49,14 @@ export async function POST(req: Request) { // 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport const body = await req.json() + console.log('[Chat] body keys:', Object.keys(body), 'noteContext?', !!body.noteContext, 'images?', body.noteContext?.images?.length) const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext } = body as { messages: UIMessage[] conversationId?: string notebookId?: string language?: string webSearch?: boolean - noteContext?: { title: string; content: string; tone: string } + noteContext?: { title: string; content: string; tone: string; images?: string[] } } // Convert UIMessages to CoreMessages for streamText @@ -115,12 +118,17 @@ export async function POST(req: Request) { } // Also run semantic search for the specific query - const searchResults = await semanticSearchService.search(currentMessage, { - notebookId, - limit: notebookId ? 10 : 5, - threshold: notebookId ? 0.3 : 0.5, - defaultTitle: untitledText, - }) + let searchResults: any[] = [] + try { + searchResults = await semanticSearchService.search(currentMessage, { + notebookId, + limit: notebookId ? 10 : 5, + threshold: notebookId ? 0.3 : 0.5, + defaultTitle: untitledText, + }) + } catch { + // Search failure should not block chat + } const searchNotes = searchResults .map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`) @@ -320,6 +328,27 @@ Tu as accès à ces outils pour des recherches approfondies : ? prompts.contextWithNotes : prompts.contextNoNotes + // Load note images as base64 for vision-capable models + let imageContextParts: Array<{ type: 'image'; image: string }> = [] + if (noteContext?.images && noteContext.images.length > 0) { + console.log('[Chat] noteContext.images:', noteContext.images) + for (const imgPath of noteContext.images.slice(0, 4)) { + try { + const fullPath = path.join(process.cwd(), 'public', imgPath) + console.log('[Chat] reading image:', fullPath) + const buffer = await readFile(fullPath) + const ext = path.extname(imgPath).toLowerCase() + const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg' + const base64 = `data:${mime};base64,${buffer.toString('base64')}` + imageContextParts.push({ type: 'image', image: base64 }) + console.log('[Chat] image loaded, size:', buffer.length, 'bytes') + } catch (err) { + console.error('[Chat] failed to read image:', imgPath, err) + } + } + console.log('[Chat] total image parts:', imageContextParts.length) + } + let copilotContext = '' if (noteContext) { copilotContext = `\n\n## Current Note Context @@ -328,8 +357,9 @@ Title: ${noteContext.title || 'Untitled'} Content: ${noteContext.content || '(empty)'} +${imageContextParts.length > 0 ? `\nImages: ${imageContextParts.length} image(s) attached. When the user asks about images, describe what you see in them.` : ''} -The user wants you to write in a **${noteContext.tone || 'professional'}** tone. +The user wants you to write in a **${noteContext.tone || 'professional'}** tone. Keep your suggestions tailored to this note and tone. You can suggest rewrites, answer questions about the note, or draft new sections.` } @@ -338,7 +368,9 @@ ${copilotContext} ${contextBlock} -${lang === 'en' ? 'Respond in the user\'s language.' : lang === 'fr' ? 'Réponds dans la langue de l\'utilisateur.' : 'Respond in the user\'s language.'}` +## LANGUAGE RULE (MANDATORY) +You MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : lang === 'de' ? 'German' : lang === 'it' ? 'Italian' : 'English'}. +Never switch to another language. Even if the user writes in a different language, respond in the configured language.` // 6. Build message history from DB + current messages const dbHistory = conversation.messages.map((m: { role: string; content: string }) => ({ @@ -355,10 +387,25 @@ ${lang === 'en' ? 'Respond in the user\'s language.' : lang === 'fr' ? 'Réponds currentDbMessage.role !== 'user' || currentDbMessage.content !== lastIncoming.content) - const allMessages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }> = isNewMessage + let allMessages: Array<{ role: 'user' | 'assistant' | 'system'; content: string | Array }> = isNewMessage ? [...dbHistory, { role: lastIncoming.role, content: lastIncoming.content }] : dbHistory + // Inject note images as a context message for vision models + if (imageContextParts.length > 0) { + allMessages = [ + { role: 'user', content: [{ type: 'text' as const, text: '[Attached note images — use these when the user asks about images]' }, ...imageContextParts] }, + { role: 'assistant', content: 'Understood. I can see the attached images and will describe or analyze them when asked.' }, + ...allMessages, + ] + } + + // Sliding window: keep first 2 messages (context) + last 48 to avoid context overflow + const WINDOW = 50 + if (allMessages.length > WINDOW) { + allMessages = [...allMessages.slice(0, 2), ...allMessages.slice(-(WINDOW - 2))] + } + // 7. Get chat provider model const config = await getSystemConfig() const provider = getChatProvider(config) @@ -389,7 +436,7 @@ ${lang === 'en' ? 'Respond in the user\'s language.' : lang === 'fr' ? 'Réponds const result = streamText({ model, system: systemPrompt, - messages: allMessages, + messages: allMessages as any, tools: chatTools, stopWhen: stepCountIs(5), async onFinish({ text }) { diff --git a/memento-note/app/api/upload/route.ts b/memento-note/app/api/upload/route.ts index cd067de..463577b 100644 --- a/memento-note/app/api/upload/route.ts +++ b/memento-note/app/api/upload/route.ts @@ -33,9 +33,16 @@ export async function POST(request: NextRequest) { } const buffer = Buffer.from(await file.arrayBuffer()) - const ext = path.extname(file.name).toLowerCase() + // Resolve extension from file name, falling back to MIME type (e.g. clipboard pastes) + let ext = path.extname(file.name).toLowerCase() if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) { - return NextResponse.json({ error: 'Invalid file extension' }, { status: 400 }) + const mimeToExt: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + } + ext = mimeToExt[file.type] || '.png' } const filename = `${randomUUID()}${ext}` diff --git a/memento-note/components/ai-chat.tsx b/memento-note/components/ai-chat.tsx index feacc52..63732c2 100644 --- a/memento-note/components/ai-chat.tsx +++ b/memento-note/components/ai-chat.tsx @@ -6,12 +6,21 @@ import { DefaultChatTransport } from 'ai' import type { UIMessage } from 'ai' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' -import { X, Bot, Sparkles, History, Send, Globe, Briefcase, Palette, GraduationCap, Coffee, Loader2, BookOpen, Layers } from 'lucide-react' +import { X, Bot, Sparkles, History, Send, Globe, Briefcase, Palette, GraduationCap, Coffee, Loader2, BookOpen, Layers, Square, Plus } from 'lucide-react' import { useLanguage } from '@/lib/i18n' import { MarkdownContent } from '@/components/markdown-content' import { useWebSearchAvailable } from '@/hooks/use-web-search-available' import { useNotebooks } from '@/context/notebooks-context' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { toast } from 'sonner' + +function getTextContent(msg: UIMessage): string { + if (msg.parts && Array.isArray(msg.parts)) { + return msg.parts.filter((p: any) => p.type === 'text' && typeof p.text === 'string').map((p: any) => p.text).join('') + } + if (typeof (msg as any).content === 'string') return (msg as any).content + return '' +} const TONES = [ { id: 'professional', label: 'Professional', icon: Briefcase }, @@ -45,10 +54,11 @@ export function AIChat() { const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current - const { messages, setMessages, sendMessage, status } = useChat({ + const { messages, setMessages, sendMessage, status, stop } = useChat({ transport, onError: (error) => { console.error('Chat error:', error) + toast.error(t('chat.assistantError') || 'Chat error') } }) @@ -142,6 +152,9 @@ export function AIChat() {

{t('ai.poweredByMomento')}

+