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:
@@ -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 (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
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
|
||||||
|
|
||||||
const response = await fetch(targetUrl, {
|
const controller = new AbortController()
|
||||||
headers: {
|
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||||
// 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',
|
let response: Response;
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
try {
|
||||||
'Accept-Language': 'en-US,en;q=0.5'
|
response = await fetch(targetUrl, {
|
||||||
},
|
headers: {
|
||||||
next: { revalidate: 3600 } // Cache for 1 hour
|
'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) {
|
if (!response.ok) {
|
||||||
|
console.error(`[Scrape] HTTP ${response.status} for ${url}`)
|
||||||
return null;
|
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 html = await response.text();
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
const getMeta = (prop: string) =>
|
const getMeta = (prop: string) =>
|
||||||
$(`meta[property="${prop}"]`).attr('content') ||
|
$(`meta[property="${prop}"]`).attr('content') ||
|
||||||
$(`meta[name="${prop}"]`).attr('content');
|
$(`meta[name="${prop}"]`).attr('content');
|
||||||
|
|
||||||
// Robust extraction with fallbacks
|
const title = getMeta('og:title') || $('title').text()?.trim() || getMeta('twitter:title') || url;
|
||||||
const title = getMeta('og:title') || $('title').text() || getMeta('twitter:title') || url;
|
|
||||||
const description = getMeta('og:description') || getMeta('description') || getMeta('twitter:description') || '';
|
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') || '';
|
const siteName = getMeta('og:site_name') || '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { auth } from '@/auth'
|
|||||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||||
import { toolRegistry } from '@/lib/ai/tools'
|
import { toolRegistry } from '@/lib/ai/tools'
|
||||||
import { stepCountIs } from 'ai'
|
import { stepCountIs } from 'ai'
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export const maxDuration = 60
|
export const maxDuration = 60
|
||||||
|
|
||||||
@@ -47,13 +49,14 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport
|
// 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport
|
||||||
const body = await req.json()
|
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 {
|
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext } = body as {
|
||||||
messages: UIMessage[]
|
messages: UIMessage[]
|
||||||
conversationId?: string
|
conversationId?: string
|
||||||
notebookId?: string
|
notebookId?: string
|
||||||
language?: string
|
language?: string
|
||||||
webSearch?: boolean
|
webSearch?: boolean
|
||||||
noteContext?: { title: string; content: string; tone: string }
|
noteContext?: { title: string; content: string; tone: string; images?: string[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert UIMessages to CoreMessages for streamText
|
// Convert UIMessages to CoreMessages for streamText
|
||||||
@@ -115,12 +118,17 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Also run semantic search for the specific query
|
// Also run semantic search for the specific query
|
||||||
const searchResults = await semanticSearchService.search(currentMessage, {
|
let searchResults: any[] = []
|
||||||
notebookId,
|
try {
|
||||||
limit: notebookId ? 10 : 5,
|
searchResults = await semanticSearchService.search(currentMessage, {
|
||||||
threshold: notebookId ? 0.3 : 0.5,
|
notebookId,
|
||||||
defaultTitle: untitledText,
|
limit: notebookId ? 10 : 5,
|
||||||
})
|
threshold: notebookId ? 0.3 : 0.5,
|
||||||
|
defaultTitle: untitledText,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Search failure should not block chat
|
||||||
|
}
|
||||||
|
|
||||||
const searchNotes = searchResults
|
const searchNotes = searchResults
|
||||||
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
.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.contextWithNotes
|
||||||
: prompts.contextNoNotes
|
: 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 = ''
|
let copilotContext = ''
|
||||||
if (noteContext) {
|
if (noteContext) {
|
||||||
copilotContext = `\n\n## Current Note Context
|
copilotContext = `\n\n## Current Note Context
|
||||||
@@ -328,8 +357,9 @@ Title: ${noteContext.title || 'Untitled'}
|
|||||||
|
|
||||||
Content:
|
Content:
|
||||||
${noteContext.content || '(empty)'}
|
${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.`
|
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}
|
${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
|
// 6. Build message history from DB + current messages
|
||||||
const dbHistory = conversation.messages.map((m: { role: string; content: string }) => ({
|
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.role !== 'user' ||
|
||||||
currentDbMessage.content !== lastIncoming.content)
|
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, { role: lastIncoming.role, content: lastIncoming.content }]
|
||||||
: dbHistory
|
: 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
|
// 7. Get chat provider model
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const provider = getChatProvider(config)
|
const provider = getChatProvider(config)
|
||||||
@@ -389,7 +436,7 @@ ${lang === 'en' ? 'Respond in the user\'s language.' : lang === 'fr' ? 'Réponds
|
|||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
system: systemPrompt,
|
system: systemPrompt,
|
||||||
messages: allMessages,
|
messages: allMessages as any,
|
||||||
tools: chatTools,
|
tools: chatTools,
|
||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
async onFinish({ text }) {
|
async onFinish({ text }) {
|
||||||
|
|||||||
@@ -33,9 +33,16 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer())
|
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)) {
|
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}`
|
const filename = `${randomUUID()}${ext}`
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,21 @@ import { DefaultChatTransport } from 'ai'
|
|||||||
import type { UIMessage } from 'ai'
|
import type { UIMessage } from 'ai'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { useLanguage } from '@/lib/i18n'
|
||||||
import { MarkdownContent } from '@/components/markdown-content'
|
import { MarkdownContent } from '@/components/markdown-content'
|
||||||
import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
|
import { useWebSearchAvailable } from '@/hooks/use-web-search-available'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
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 = [
|
const TONES = [
|
||||||
{ id: 'professional', label: 'Professional', icon: Briefcase },
|
{ id: 'professional', label: 'Professional', icon: Briefcase },
|
||||||
@@ -45,10 +54,11 @@ export function AIChat() {
|
|||||||
|
|
||||||
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
|
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
|
||||||
|
|
||||||
const { messages, setMessages, sendMessage, status } = useChat({
|
const { messages, setMessages, sendMessage, status, stop } = useChat({
|
||||||
transport,
|
transport,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Chat error:', 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>
|
<p className="text-xs text-muted-foreground font-medium">{t('ai.poweredByMomento')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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">
|
<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">
|
<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 ? (
|
{isExpanded ? (
|
||||||
@@ -217,28 +230,33 @@ export function AIChat() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{messages.map((msg: UIMessage) => (
|
{messages.map((msg: UIMessage) => {
|
||||||
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
|
const text = getTextContent(msg)
|
||||||
<div className={cn(
|
// Skip empty assistant messages (thinking/reasoning phase)
|
||||||
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
|
if (msg.role === 'assistant' && !text) return null
|
||||||
msg.role === 'user'
|
return (
|
||||||
? 'bg-slate-100 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300'
|
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
|
||||||
: 'bg-primary/10 text-primary border-primary/20',
|
<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' ? 'U' : <Bot className="h-4 w-4" />}
|
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>
|
||||||
<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 && (
|
{isLoading && (
|
||||||
<div className="flex gap-3">
|
<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>
|
<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}>
|
<Select value={chatScope} onValueChange={setChatScope}>
|
||||||
<SelectTrigger className="h-8 text-xs bg-card border-border/60">
|
<SelectTrigger className="h-8 text-xs bg-card border-border/60">
|
||||||
<div className="flex items-center gap-2">
|
<SelectValue placeholder={t('ai.selectNotebook')} />
|
||||||
{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>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">
|
<SelectItem value="all">
|
||||||
@@ -393,14 +408,24 @@ export function AIChat() {
|
|||||||
<Globe className="h-3.5 w-3.5" />
|
<Globe className="h-3.5 w-3.5" />
|
||||||
Web{webSearchAvailable ? '' : ' ⚠'}
|
Web{webSearchAvailable ? '' : ' ⚠'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{isLoading ? (
|
||||||
size="icon"
|
<Button
|
||||||
className="h-8 w-8 rounded-lg bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-all"
|
size="icon"
|
||||||
onClick={handleSend}
|
className="h-8 w-8 rounded-lg bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all"
|
||||||
disabled={isLoading || !input.trim()}
|
onClick={() => stop()}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4 ml-0.5" />}
|
<Square className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
status,
|
status,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
stop,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
transport,
|
transport,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -49,6 +50,15 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
|
|||||||
|
|
||||||
const isLoading = status === 'submitted' || status === 'streaming'
|
const isLoading = status === 'submitted' || status === 'streaming'
|
||||||
|
|
||||||
|
// Timeout warning: show toast if response takes > 30s
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) return
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
toast.warning(t('chat.timeoutWarning') || 'Response is taking longer than expected...')
|
||||||
|
}, 30000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [isLoading, t])
|
||||||
|
|
||||||
// Sync historyMessages after each completed streaming response
|
// Sync historyMessages after each completed streaming response
|
||||||
// so the display doesn't revert to stale history
|
// so the display doesn't revert to stale history
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -190,6 +200,7 @@ export function ChatContainer({ initialConversations, notebooks, webSearchAvaila
|
|||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
onStop={stop}
|
||||||
notebooks={notebooks}
|
notebooks={notebooks}
|
||||||
currentNotebookId={selectedNotebook || null}
|
currentNotebookId={selectedNotebook || null}
|
||||||
webSearchEnabled={webSearchEnabled}
|
webSearchEnabled={webSearchEnabled}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { Send, BookOpen, X, Globe } from 'lucide-react'
|
import { Send, BookOpen, X, Globe, Square } from 'lucide-react'
|
||||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
@@ -19,6 +19,7 @@ import { useLanguage } from '@/lib/i18n'
|
|||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (message: string, notebookId?: string) => void
|
onSend: (message: string, notebookId?: string) => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
onStop?: () => void
|
||||||
notebooks: any[]
|
notebooks: any[]
|
||||||
currentNotebookId?: string | null
|
currentNotebookId?: string | null
|
||||||
webSearchEnabled?: boolean
|
webSearchEnabled?: boolean
|
||||||
@@ -26,7 +27,7 @@ interface ChatInputProps {
|
|||||||
webSearchAvailable?: boolean
|
webSearchAvailable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
|
export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
|
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
|
||||||
@@ -123,18 +124,28 @@ export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId, web
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Send Button */}
|
{/* Send / Stop Button */}
|
||||||
<Button
|
{isLoading && onStop ? (
|
||||||
disabled={!input.trim() || isLoading}
|
<Button
|
||||||
onClick={handleSend}
|
onClick={onStop}
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className="rounded-full h-8 w-8 bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all duration-200"
|
||||||
"rounded-full h-8 w-8 transition-all duration-200",
|
>
|
||||||
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
|
<Square className="h-3.5 w-3.5" />
|
||||||
)}
|
</Button>
|
||||||
>
|
) : (
|
||||||
<Send className="h-4 w-4 ml-0.5" />
|
<Button
|
||||||
</Button>
|
disabled={!input.trim() || isLoading}
|
||||||
|
onClick={handleSend}
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"rounded-full h-8 w-8 transition-all duration-200",
|
||||||
|
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4 ml-0.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { UIMessage } from 'ai'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
X, Bot, Sparkles, Send, Loader2,
|
X, Bot, Sparkles, Send, Loader2, Square,
|
||||||
Briefcase, Palette, GraduationCap, Coffee,
|
Briefcase, Palette, GraduationCap, Coffee,
|
||||||
Lightbulb, Minimize2, AlignLeft, Wand2,
|
Lightbulb, Minimize2, AlignLeft, Wand2,
|
||||||
Globe, BookOpen, FileText, RotateCcw, Check,
|
Globe, BookOpen, FileText, RotateCcw, Check,
|
||||||
@@ -70,6 +70,7 @@ interface ContextualAIChatProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
noteTitle?: string
|
noteTitle?: string
|
||||||
noteContent?: string
|
noteContent?: string
|
||||||
|
noteImages?: string[]
|
||||||
/** Called when an action result should be injected into the note */
|
/** Called when an action result should be injected into the note */
|
||||||
onApplyToNote?: (newContent: string) => void
|
onApplyToNote?: (newContent: string) => void
|
||||||
/** Called when the user wants to undo the last injected action */
|
/** Called when the user wants to undo the last injected action */
|
||||||
@@ -88,6 +89,7 @@ export function ContextualAIChat({
|
|||||||
onClose,
|
onClose,
|
||||||
noteTitle,
|
noteTitle,
|
||||||
noteContent,
|
noteContent,
|
||||||
|
noteImages,
|
||||||
onApplyToNote,
|
onApplyToNote,
|
||||||
onUndoLastAction,
|
onUndoLastAction,
|
||||||
lastActionApplied = false,
|
lastActionApplied = false,
|
||||||
@@ -115,7 +117,12 @@ export function ContextualAIChat({
|
|||||||
const buildChatBody = () => {
|
const buildChatBody = () => {
|
||||||
const body: Record<string, any> = { language, webSearch }
|
const body: Record<string, any> = { language, webSearch }
|
||||||
if (chatScope === 'note') {
|
if (chatScope === 'note') {
|
||||||
body.noteContext = { title: noteTitle || '', content: noteContent || '', tone: selectedTone }
|
body.noteContext = {
|
||||||
|
title: noteTitle || '',
|
||||||
|
content: noteContent || '',
|
||||||
|
tone: selectedTone,
|
||||||
|
images: noteImages || [],
|
||||||
|
}
|
||||||
} else if (chatScope !== 'all') {
|
} else if (chatScope !== 'all') {
|
||||||
// scope is a notebook ID
|
// scope is a notebook ID
|
||||||
body.notebookId = chatScope
|
body.notebookId = chatScope
|
||||||
@@ -123,7 +130,7 @@ export function ContextualAIChat({
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messages, sendMessage, status } = useChat({ transport })
|
const { messages, sendMessage, status, stop } = useChat({ transport })
|
||||||
|
|
||||||
const isLoading = status === 'submitted' || status === 'streaming'
|
const isLoading = status === 'submitted' || status === 'streaming'
|
||||||
|
|
||||||
@@ -273,6 +280,8 @@ export function ContextualAIChat({
|
|||||||
|
|
||||||
{messages.map((msg: UIMessage) => {
|
{messages.map((msg: UIMessage) => {
|
||||||
const content = getMessageContent(msg)
|
const content = getMessageContent(msg)
|
||||||
|
// Skip empty assistant messages (thinking/reasoning phase)
|
||||||
|
if (msg.role === 'assistant' && !content) return null
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className={cn('flex gap-2', msg.role === 'user' && 'flex-row-reverse')}>
|
<div key={msg.id} className={cn('flex gap-2', msg.role === 'user' && 'flex-row-reverse')}>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -414,14 +423,24 @@ export function ContextualAIChat({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{isLoading ? (
|
||||||
size="icon"
|
<Button
|
||||||
className="h-7 w-7 rounded-lg bg-primary text-primary-foreground shadow-sm disabled:opacity-50"
|
size="icon"
|
||||||
onClick={handleSend}
|
className="h-7 w-7 rounded-lg bg-red-500 text-white shadow-sm hover:bg-red-600"
|
||||||
disabled={isLoading || !input.trim()}
|
onClick={() => stop()}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5 ml-0.5" />}
|
<Square className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 rounded-lg bg-primary text-primary-foreground shadow-sm disabled:opacity-50"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim()}
|
||||||
|
>
|
||||||
|
<Send className="h-3.5 w-3.5 ml-0.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[9px] text-muted-foreground/40 text-center mt-1">{t('ai.newLineHint')}</p>
|
<p className="text-[9px] text-muted-foreground/40 text-center mt-1">{t('ai.newLineHint')}</p>
|
||||||
|
|||||||
@@ -173,24 +173,23 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
|
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const uploadImageFile = async (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const response = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||||
|
if (!response.ok) throw new Error('Upload failed')
|
||||||
|
const data = await response.json()
|
||||||
|
return data.url
|
||||||
|
}
|
||||||
|
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
if (!files) return
|
if (!files) return
|
||||||
|
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/upload', {
|
const url = await uploadImageFile(file)
|
||||||
method: 'POST',
|
setImages(prev => [...prev, url])
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Upload failed')
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
setImages(prev => [...prev, data.url])
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error)
|
console.error('Upload error:', error)
|
||||||
toast.error(t('notes.uploadFailed', { filename: file.name }))
|
toast.error(t('notes.uploadFailed', { filename: file.name }))
|
||||||
@@ -198,6 +197,29 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paste handler: upload clipboard images
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) continue
|
||||||
|
try {
|
||||||
|
const url = await uploadImageFile(file)
|
||||||
|
setImages(prev => [...prev, url])
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('paste', handlePaste)
|
||||||
|
return () => document.removeEventListener('paste', handlePaste)
|
||||||
|
}, [t])
|
||||||
|
|
||||||
const handleRemoveImage = (index: number) => {
|
const handleRemoveImage = (index: number) => {
|
||||||
const removedUrl = images[index]
|
const removedUrl = images[index]
|
||||||
setImages(images.filter((_, i) => i !== index))
|
setImages(images.filter((_, i) => i !== index))
|
||||||
@@ -597,21 +619,6 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold">{readOnly ? t('notes.view') : t('notes.edit')}</h2>
|
<h2 className="text-lg font-semibold">{readOnly ? t('notes.view') : t('notes.edit')}</h2>
|
||||||
{/* AI Copilot Toggle Button next to title */}
|
|
||||||
{note.type === 'text' && !readOnly && aiAssistantEnabled && (
|
|
||||||
<Button
|
|
||||||
variant="ghost" size="sm"
|
|
||||||
className={cn(
|
|
||||||
'h-8 gap-1.5 px-2 text-xs transition-colors ml-2',
|
|
||||||
aiOpen && 'bg-primary/10 text-primary'
|
|
||||||
)}
|
|
||||||
onClick={() => setAiOpen(!aiOpen)}
|
|
||||||
title="Toggle AI Copilot"
|
|
||||||
>
|
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
|
||||||
<span className="hidden sm:inline">Assistant IA</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{readOnly && (
|
{readOnly && (
|
||||||
<Badge variant="secondary" className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground">
|
<Badge variant="secondary" className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground">
|
||||||
@@ -978,6 +985,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
onClose={() => setAiOpen(false)}
|
onClose={() => setAiOpen(false)}
|
||||||
noteTitle={title}
|
noteTitle={title}
|
||||||
noteContent={content}
|
noteContent={content}
|
||||||
|
noteImages={images}
|
||||||
onApplyToNote={(newContent) => {
|
onApplyToNote={(newContent) => {
|
||||||
setPreviousContentForCopilot(content)
|
setPreviousContentForCopilot(content)
|
||||||
setContent(newContent)
|
setContent(newContent)
|
||||||
|
|||||||
@@ -348,13 +348,9 @@ export function NoteInlineEditor({
|
|||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
if (!files) return
|
if (!files) return
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
const url = await uploadImageFile(file)
|
||||||
if (!res.ok) throw new Error('Upload failed')
|
const newImages = [...(note.images || []), url]
|
||||||
const data = await res.json()
|
|
||||||
const newImages = [...(note.images || []), data.url]
|
|
||||||
onChange?.(note.id, { images: newImages })
|
onChange?.(note.id, { images: newImages })
|
||||||
await updateNote(note.id, { images: newImages })
|
await updateNote(note.id, { images: newImages })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -364,6 +360,40 @@ export function NoteInlineEditor({
|
|||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadImageFile = async (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||||
|
if (!res.ok) throw new Error('Upload failed')
|
||||||
|
const data = await res.json()
|
||||||
|
return data.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste handler: upload clipboard images
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) continue
|
||||||
|
try {
|
||||||
|
const url = await uploadImageFile(file)
|
||||||
|
const newImages = [...(note.images || []), url]
|
||||||
|
onChange?.(note.id, { images: newImages })
|
||||||
|
await updateNote(note.id, { images: newImages })
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('paste', handlePaste)
|
||||||
|
return () => document.removeEventListener('paste', handlePaste)
|
||||||
|
}, [note.id, note.images, onChange, t])
|
||||||
|
|
||||||
const handleRemoveImage = async (index: number) => {
|
const handleRemoveImage = async (index: number) => {
|
||||||
const newImages = (note.images || []).filter((_, i) => i !== index)
|
const newImages = (note.images || []).filter((_, i) => i !== index)
|
||||||
onChange?.(note.id, { images: newImages })
|
onChange?.(note.id, { images: newImages })
|
||||||
@@ -783,6 +813,7 @@ export function NoteInlineEditor({
|
|||||||
onClose={() => setAiOpen(false)}
|
onClose={() => setAiOpen(false)}
|
||||||
noteTitle={title}
|
noteTitle={title}
|
||||||
noteContent={content}
|
noteContent={content}
|
||||||
|
noteImages={note.images || undefined}
|
||||||
onApplyToNote={(newContent) => {
|
onApplyToNote={(newContent) => {
|
||||||
setPreviousContent(content)
|
setPreviousContent(content)
|
||||||
changeContent(newContent)
|
changeContent(newContent)
|
||||||
|
|||||||
@@ -434,6 +434,38 @@ export function NoteInput({
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadImageFile = async (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const response = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||||
|
if (!response.ok) throw new Error('Upload failed')
|
||||||
|
const data = await response.json()
|
||||||
|
return data.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste handler: upload clipboard images
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) continue
|
||||||
|
try {
|
||||||
|
const url = await uploadImageFile(file)
|
||||||
|
setImages(prev => [...prev, url])
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('paste', handlePaste)
|
||||||
|
return () => document.removeEventListener('paste', handlePaste)
|
||||||
|
}, [t])
|
||||||
|
|
||||||
const handleAddLink = async () => {
|
const handleAddLink = async () => {
|
||||||
if (!linkUrl) return
|
if (!linkUrl) return
|
||||||
|
|
||||||
@@ -927,6 +959,7 @@ export function NoteInput({
|
|||||||
onClose={() => setAiOpen(false)}
|
onClose={() => setAiOpen(false)}
|
||||||
noteTitle={title}
|
noteTitle={title}
|
||||||
noteContent={content}
|
noteContent={content}
|
||||||
|
noteImages={images}
|
||||||
onApplyToNote={(newContent) => setContent(newContent)}
|
onApplyToNote={(newContent) => setContent(newContent)}
|
||||||
lastActionApplied={false}
|
lastActionApplied={false}
|
||||||
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ export class SemanticSearchService {
|
|||||||
|
|
||||||
// Build Prisma OR clauses for each keyword
|
// Build Prisma OR clauses for each keyword
|
||||||
const searchConditions = searchTerms.flatMap(term => [
|
const searchConditions = searchTerms.flatMap(term => [
|
||||||
{ title: { contains: term } },
|
{ title: { contains: term, mode: 'insensitive' as const } },
|
||||||
{ content: { contains: term } }
|
{ content: { contains: term, mode: 'insensitive' as const } }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
@@ -145,9 +145,10 @@ export class SemanticSearchService {
|
|||||||
const content = note.content || ''
|
const content = note.content || ''
|
||||||
const queryLower = query.toLowerCase()
|
const queryLower = query.toLowerCase()
|
||||||
|
|
||||||
// Count occurrences
|
// Count occurrences — escape regex special chars to avoid crashes
|
||||||
const titleMatches = (title.match(new RegExp(queryLower, 'gi')) || []).length
|
const escaped = queryLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
const contentMatches = (content.match(new RegExp(queryLower, 'gi')) || []).length
|
const titleMatches = (title.match(new RegExp(escaped, 'gi')) || []).length
|
||||||
|
const contentMatches = (content.match(new RegExp(escaped, 'gi')) || []).length
|
||||||
|
|
||||||
// Boost title matches significantly
|
// Boost title matches significantly
|
||||||
const titlePosition = title.toLowerCase().indexOf(queryLower)
|
const titlePosition = title.toLowerCase().indexOf(queryLower)
|
||||||
|
|||||||
@@ -1524,7 +1524,8 @@
|
|||||||
"welcome": "I'm here to help you synthesize your notes, generate new ideas, or discuss your notebooks.",
|
"welcome": "I'm here to help you synthesize your notes, generate new ideas, or discuss your notebooks.",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
"noNotesFoundForContext": "No relevant notes found for this question. Answer with your general knowledge.",
|
"noNotesFoundForContext": "No relevant notes found for this question. Answer with your general knowledge.",
|
||||||
"webSearch": "Web Search"
|
"webSearch": "Web Search",
|
||||||
|
"timeoutWarning": "Response is taking longer than expected..."
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "The Lab",
|
"title": "The Lab",
|
||||||
|
|||||||
@@ -1520,7 +1520,8 @@
|
|||||||
"welcome": "Je suis à votre écoute pour synthétiser vos notes, générer de nouvelles idées ou discuter de vos carnets.",
|
"welcome": "Je suis à votre écoute pour synthétiser vos notes, générer de nouvelles idées ou discuter de vos carnets.",
|
||||||
"searching": "Recherche en cours...",
|
"searching": "Recherche en cours...",
|
||||||
"noNotesFoundForContext": "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
"noNotesFoundForContext": "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
||||||
"webSearch": "Recherche web"
|
"webSearch": "Recherche web",
|
||||||
|
"timeoutWarning": "La réponse met plus de temps que prévu..."
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "Le Lab",
|
"title": "Le Lab",
|
||||||
|
|||||||
Reference in New Issue
Block a user