feat: chat stop button, image paste, vision AI, search fixes
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 43s
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:
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user