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

@@ -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() {
<p className="text-xs text-muted-foreground font-medium">{t('ai.poweredByMomento')}</p>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={() => { setMessages([]); setConversationId(undefined) }} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted" title={t('ai.newDiscussion')}>
<Plus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)} className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
{isExpanded ? (
@@ -217,28 +230,33 @@ export function AIChat() {
)}
{/* Messages */}
{messages.map((msg: UIMessage) => (
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
msg.role === 'user'
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
: 'bg-primary/10 text-primary border-primary/20',
)}>
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
{messages.map((msg: UIMessage) => {
const text = getTextContent(msg)
// Skip empty assistant messages (thinking/reasoning phase)
if (msg.role === 'assistant' && !text) return null
return (
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
msg.role === 'user'
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
: 'bg-primary/10 text-primary border-primary/20',
)}>
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
</div>
<div className={cn(
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
msg.role === 'user'
? 'bg-primary text-primary-foreground rounded-tr-sm'
: 'bg-muted/30 border border-border/50 rounded-tl-sm text-foreground',
)}>
{msg.role === 'assistant'
? <MarkdownContent content={text} />
: <p>{text}</p>}
</div>
</div>
<div className={cn(
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
msg.role === 'user'
? 'bg-primary text-primary-foreground rounded-tr-sm'
: 'bg-muted/30 border border-border/50 rounded-tl-sm text-foreground',
)}>
{msg.role === 'assistant'
? <MarkdownContent content={msg.parts?.map(p => 'text' in p ? p.text : '').join('') || ''} />
: <p>{msg.parts?.map(p => 'text' in p ? p.text : '').join('') || ''}</p>}
</div>
</div>
))}
)
})}
{isLoading && (
<div className="flex gap-3">
@@ -317,10 +335,7 @@ export function AIChat() {
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.discussionContextLabel')}</span>
<Select value={chatScope} onValueChange={setChatScope}>
<SelectTrigger className="h-8 text-xs bg-card border-border/60">
<div className="flex items-center gap-2">
{chatScope === 'all' ? <Layers className="h-3.5 w-3.5 text-primary" /> : <BookOpen className="h-3.5 w-3.5 text-primary" />}
<SelectValue placeholder={t('ai.selectNotebook')} />
</div>
<SelectValue placeholder={t('ai.selectNotebook')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
@@ -393,14 +408,24 @@ export function AIChat() {
<Globe className="h-3.5 w-3.5" />
Web{webSearchAvailable ? '' : ' ⚠'}
</Button>
<Button
size="icon"
className="h-8 w-8 rounded-lg bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-all"
onClick={handleSend}
disabled={isLoading || !input.trim()}
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4 ml-0.5" />}
</Button>
{isLoading ? (
<Button
size="icon"
className="h-8 w-8 rounded-lg bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all"
onClick={() => stop()}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
size="icon"
className="h-8 w-8 rounded-lg bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-all"
onClick={handleSend}
disabled={!input.trim()}
>
<Send className="h-4 w-4 ml-0.5" />
</Button>
)}
</div>
</div>
</div>