diff --git a/keep-notes/components/memory-echo-notification.tsx b/keep-notes/components/memory-echo-notification.tsx index d7c7ee1..01066ef 100644 --- a/keep-notes/components/memory-echo-notification.tsx +++ b/keep-notes/components/memory-echo-notification.tsx @@ -5,8 +5,12 @@ import { useLanguage } from '@/lib/i18n/LanguageProvider' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Lightbulb, ThumbsUp, ThumbsDown, X, Sparkles, ArrowRight } from 'lucide-react' +import { Lightbulb, ThumbsUp, ThumbsDown, X, Sparkles, ArrowRight, GitMerge } from 'lucide-react' import { toast } from 'sonner' +import { ComparisonModal } from './comparison-modal' +import { FusionModal } from './fusion-modal' +import { createNote, updateNote } from '@/app/actions/notes' +import { Note } from '@/lib/types' interface MemoryEchoInsight { id: string @@ -38,7 +42,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro const [insight, setInsight] = useState(null) const [isLoading, setIsLoading] = useState(false) const [isDismissed, setIsDismissed] = useState(false) - const [showModal, setShowModal] = useState(false) + const [showComparison, setShowComparison] = useState(false) + const [fusionNotes, setFusionNotes] = useState>>([]) const [demoMode, setDemoMode] = useState(false) const pollingRef = useRef | null>(null) @@ -58,8 +63,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro if (data.insight) { setInsight(data.insight) - // Check if user is in demo mode by looking at frequency settings - setDemoMode(true) // If we got an insight after dismiss, assume demo mode + // If we got an insight, check if user is in demo mode + setDemoMode(true) } } catch (error) { console.error('[MemoryEcho] Failed to fetch insight:', error) @@ -70,7 +75,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro // Start polling in demo mode after first dismiss useEffect(() => { - if (isDismissed && !pollingRef.current) { + if (isDismissed && demoMode && !pollingRef.current) { pollingRef.current = setInterval(async () => { try { const res = await fetch('/api/ai/echo') @@ -90,25 +95,20 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro pollingRef.current = null } } - }, [isDismissed]) + }, [isDismissed, demoMode]) const handleView = async () => { if (!insight) return try { - // Mark as viewed await fetch('/api/ai/echo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'view', - insightId: insight.id - }) + body: JSON.stringify({ action: 'view', insightId: insight.id }) }) - // Show success message and open modal toast.success(t('toast.openingConnection')) - setShowModal(true) + setShowComparison(true) } catch (error) { console.error('[MemoryEcho] Failed to view connection:', error) toast.error(t('toast.openConnectionFailed')) @@ -122,21 +122,15 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro await fetch('/api/ai/echo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'feedback', - insightId: insight.id, - feedback - }) + body: JSON.stringify({ action: 'feedback', insightId: insight.id, feedback }) }) - // Show feedback toast if (feedback === 'thumbs_up') { toast.success(t('toast.thanksFeedback')) } else { toast.success(t('toast.thanksFeedbackImproving')) } - // Dismiss notification setIsDismissed(true) // Stop polling after explicit feedback if (pollingRef.current) { @@ -149,225 +143,194 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro } } + const handleMergeNotes = async (noteIds: string[]) => { + if (!insight) return + const fetched = await Promise.all(noteIds.map(async (id) => { + try { + const res = await fetch(`/api/notes/${id}`) + if (!res.ok) return null + const data = await res.json() + return data.success && data.data ? data.data : null + } catch { return null } + })) + setFusionNotes(fetched.filter((n: any) => n !== null) as Array>) + } + const handleDismiss = () => { setIsDismissed(true) } - // Don't render notification if dismissed, loading, or no insight - if (isDismissed || isLoading || !insight) { + if (isLoading || !insight) { return null } - // Calculate values for both notification and modal const note1Title = insight.note1.title || t('notification.untitled') const note2Title = insight.note2.title || t('notification.untitled') const similarityPercentage = Math.round(insight.similarityScore * 100) - // Render modal if requested - if (showModal && insight) { - return ( -
-
- {/* Header */} -
-
-
- -
-
-

{t('memoryEcho.title')}

-

- {t('connection.similarityInfo', { similarity: similarityPercentage })} -

-
-
- -
- - {/* AI-generated insight */} -
-

- {insight.insight} -

-
- - {/* Notes Grid */} -
- {/* Note 1 */} -
{ - if (onOpenNote) { - onOpenNote(insight.note1.id) - setShowModal(false) - } - }} - className="cursor-pointer border dark:border-zinc-700 rounded-lg p-4 hover:border-amber-300 dark:hover:border-amber-700 transition-colors" - > -

- {note1Title} -

-

- {insight.note1.content} -

-

{t('memoryEcho.clickToView')}

-
- - {/* Note 2 */} -
{ - if (onOpenNote) { - onOpenNote(insight.note2.id) - setShowModal(false) - } - }} - className="cursor-pointer border dark:border-zinc-700 rounded-lg p-4 hover:border-purple-300 dark:hover:border-purple-700 transition-colors" - > -

- {note2Title} -

-

- {insight.note2.content} -

-

{t('memoryEcho.clickToView')}

-
-
- - {/* Feedback Section */} -
-

- {t('connection.isHelpful')} -

-
- - -
-
-
-
- ) - } + const comparisonNotes: Array> = [ + { id: insight.note1.id, title: insight.note1.title, content: insight.note1.content }, + { id: insight.note2.id, title: insight.note2.title, content: insight.note2.content } + ] return ( -
- - -
-
-
- + <> + {/* Comparison Modal */} + { + setShowComparison(false) + setIsDismissed(true) + }} + notes={comparisonNotes} + similarity={insight.similarityScore} + onOpenNote={(noteId) => { + setShowComparison(false) + setIsDismissed(true) + onOpenNote?.(noteId) + }} + onMergeNotes={handleMergeNotes} + /> + + {/* Fusion Modal */} + {fusionNotes.length > 0 && ( + 0} + onClose={() => setFusionNotes([])} + notes={fusionNotes} + onConfirmFusion={async ({ title, content }, options) => { + await createNote({ + title, + content, + labels: options.keepAllTags + ? [...new Set(fusionNotes.flatMap(n => n.labels || []))] + : fusionNotes[0].labels || [], + color: fusionNotes[0].color, + type: 'text', + isMarkdown: true, + autoGenerated: true, + notebookId: fusionNotes[0].notebookId ?? undefined + }) + if (options.archiveOriginals) { + for (const n of fusionNotes) { + if (n.id) await updateNote(n.id, { isArchived: true }) + } + } + toast.success(t('toast.notesFusionSuccess')) + setFusionNotes([]) + setIsDismissed(true) + }} + /> + )} + + {/* Notification Card — hidden when dismissed or modal open */} + {!isDismissed && ( +
+ + +
+
+
+ +
+
+ + {t('memoryEcho.title')} + + + + {t('memoryEcho.description')} + +
+
+
-
- - {t('memoryEcho.title')} - - - - {t('memoryEcho.description')} - + + + + {/* AI-generated insight */} +
+

+ {insight.insight} +

-
- -
- - - {/* AI-generated insight */} -
-

- {insight.insight} -

-
+ {/* Connected notes */} +
+
+ + {note1Title} + + + + {note2Title} + + + {t('memoryEcho.match', { percentage: similarityPercentage })} + +
+
- {/* Connected notes */} -
-
- - {note1Title} - - - - {note2Title} - - - {t('memoryEcho.match', { percentage: similarityPercentage })} - -
-
+ {/* Action buttons */} +
+ - {/* Action buttons */} -
- + -
- + +
+
+ + {/* Dismiss link */} + - -
-
- - {/* Dismiss link */} - - - -
+ {t('memoryEcho.dismiss')} + + + +
+ )} + ) } diff --git a/keep-notes/lib/ai/services/memory-echo.service.ts b/keep-notes/lib/ai/services/memory-echo.service.ts index 6fa83b7..196aeb5 100644 --- a/keep-notes/lib/ai/services/memory-echo.service.ts +++ b/keep-notes/lib/ai/services/memory-echo.service.ts @@ -1,5 +1,6 @@ import { getAIProvider } from '../factory' import { cosineSimilarity } from '@/lib/utils' +import { getSystemConfig } from '@/lib/config' import prisma from '@/lib/prisma' export interface NoteConnection { @@ -52,10 +53,53 @@ export class MemoryEchoService { private readonly MIN_DAYS_APART_DEMO = 0 // No delay for demo mode private readonly MAX_INSIGHTS_PER_USER = 100 // Prevent spam + /** + * Generate embeddings for notes that don't have one yet + */ + private async ensureEmbeddings(userId: string): Promise { + const notesWithoutEmbeddings = await prisma.note.findMany({ + where: { + userId, + isArchived: false, + trashedAt: null, + noteEmbedding: { is: null } + }, + select: { id: true, content: true } + }) + + if (notesWithoutEmbeddings.length === 0) return + + try { + const config = await getSystemConfig() + const provider = getAIProvider(config) + + for (const note of notesWithoutEmbeddings) { + if (!note.content || note.content.trim().length === 0) continue + try { + const embedding = await provider.getEmbeddings(note.content) + if (embedding && embedding.length > 0) { + await prisma.noteEmbedding.upsert({ + where: { noteId: note.id }, + create: { noteId: note.id, embedding: JSON.stringify(embedding) }, + update: { embedding: JSON.stringify(embedding) } + }) + } + } catch { + // Skip this note, continue with others + } + } + } catch { + // Provider not configured — nothing we can do + } + } + /** * Find meaningful connections between user's notes */ async findConnections(userId: string, demoMode: boolean = false): Promise { + // Ensure all notes have embeddings before searching for connections + await this.ensureEmbeddings(userId) + // Get all user's notes with embeddings const notes = await prisma.note.findMany({ where: { @@ -151,8 +195,8 @@ export class MemoryEchoService { note2Content: string ): Promise { try { - const config = await prisma.systemConfig.findFirst() - const provider = getAIProvider(config || undefined) + const config = await getSystemConfig() + const provider = getAIProvider(config) const note1Desc = note1Title || 'Untitled note' const note2Desc = note2Title || 'Untitled note' @@ -366,6 +410,9 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu * Get all connections for a specific note */ async getConnectionsForNote(noteId: string, userId: string): Promise { + // Ensure all notes have embeddings before searching + await this.ensureEmbeddings(userId) + // Get the note with embedding const targetNote = await prisma.note.findUnique({ where: { id: noteId },