From 389f85937ac23904280342e5aa0335d512d986f2 Mon Sep 17 00:00:00 2001 From: Sepehr Ramezani Date: Sun, 19 Apr 2026 22:05:19 +0200 Subject: [PATCH] fix(memory-echo): fix broken AI provider config and auto-generate missing embeddings - Fix critical bug: used prisma.systemConfig.findFirst() which returned a single {key,value} record instead of the full config object needed by getAIProvider(). Replaced with getSystemConfig() that returns all config as a proper Record. - Add ensureEmbeddings() to auto-generate embeddings for notes that lack them before searching for connections. This fixes the case where notes created without an AI provider configured never got embeddings, making Memory Echo silently return zero connections. - Restore demo mode polling (15s interval after dismiss) in the notification component. - Integrate ComparisonModal and FusionModal in the notification card with merge button for direct note fusion from the notification. Co-Authored-By: Claude Opus 4.5 --- .../components/memory-echo-notification.tsx | 403 ++++++++---------- .../lib/ai/services/memory-echo.service.ts | 51 ++- 2 files changed, 232 insertions(+), 222 deletions(-) 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 },