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

@@ -26,31 +26,63 @@ export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | nul
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 response = await fetch(targetUrl, {
headers: {
// Use a real browser User-Agent to avoid 403 Forbidden from strict sites
'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'
},
next: { revalidate: 3600 } // Cache for 1 hour
});
const controller = new AbortController()
const timeoutId = setTimeout(() => 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 {