feat: chat stop button, image paste, vision AI, search fixes
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 43s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 22:34:07 +02:00
parent 3b8152c7c0
commit ae89f8a014
13 changed files with 354 additions and 127 deletions

View File

@@ -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<any> }> = 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 }) {

View File

@@ -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<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
}
ext = mimeToExt[file.type] || '.png'
}
const filename = `${randomUUID()}${ext}`