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<string,string>.
- 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 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-19 22:05:19 +02:00
parent 25529a24b8
commit 389f85937a
2 changed files with 232 additions and 222 deletions

View File

@@ -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<MemoryEchoInsight | null>(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<Array<Partial<Note>>>([])
const [demoMode, setDemoMode] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | 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<Partial<Note>>)
}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h2 className="text-xl font-semibold">{t('memoryEcho.title')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('connection.similarityInfo', { similarity: similarityPercentage })}
</p>
</div>
</div>
<button
onClick={() => {
setShowModal(false)
setIsDismissed(true)
}}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<X className="h-6 w-6" />
</button>
</div>
{/* AI-generated insight */}
<div className="px-6 py-4 bg-amber-50 dark:bg-amber-950/20 border-b dark:border-zinc-700">
<p className="text-sm text-gray-700 dark:text-gray-300">
{insight.insight}
</p>
</div>
{/* Notes Grid */}
<div className="grid grid-cols-2 gap-6 p-6">
{/* Note 1 */}
<div
onClick={() => {
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"
>
<h3 className="font-semibold text-primary dark:text-primary-foreground mb-2">
{note1Title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
{insight.note1.content}
</p>
<p className="text-xs text-gray-500 mt-2">{t('memoryEcho.clickToView')}</p>
</div>
{/* Note 2 */}
<div
onClick={() => {
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"
>
<h3 className="font-semibold text-purple-600 dark:text-purple-400 mb-2">
{note2Title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-4">
{insight.note2.content}
</p>
<p className="text-xs text-gray-500 mt-2">{t('memoryEcho.clickToView')}</p>
</div>
</div>
{/* Feedback Section */}
<div className="flex items-center justify-between px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('connection.isHelpful')}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => handleFeedback('thumbs_up')}
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_up'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'hover:bg-green-50 text-green-600 dark:hover:bg-green-950/20'
}`}
>
<ThumbsUp className="h-4 w-4" />
{t('memoryEcho.helpful')}
</button>
<button
onClick={() => handleFeedback('thumbs_down')}
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${insight.feedback === 'thumbs_down'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'hover:bg-red-50 text-red-600 dark:hover:bg-red-950/20'
}`}
>
<ThumbsDown className="h-4 w-4" />
{t('memoryEcho.notHelpful')}
</button>
</div>
</div>
</div>
</div>
)
}
const comparisonNotes: Array<Partial<Note>> = [
{ 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 (
<div className="fixed bottom-4 right-4 z-50 max-w-md w-full animate-in slide-in-from-bottom-4 fade-in duration-500">
<Card className="border-amber-200 dark:border-amber-900 shadow-lg bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/20 dark:to-background">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Lightbulb className="h-5 w-5 text-amber-600 dark:text-amber-400" />
<>
{/* Comparison Modal */}
<ComparisonModal
isOpen={showComparison}
onClose={() => {
setShowComparison(false)
setIsDismissed(true)
}}
notes={comparisonNotes}
similarity={insight.similarityScore}
onOpenNote={(noteId) => {
setShowComparison(false)
setIsDismissed(true)
onOpenNote?.(noteId)
}}
onMergeNotes={handleMergeNotes}
/>
{/* Fusion Modal */}
{fusionNotes.length > 0 && (
<FusionModal
isOpen={fusionNotes.length > 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 && (
<div className="fixed bottom-4 right-4 z-50 max-w-md w-full animate-in slide-in-from-bottom-4 fade-in duration-500">
<Card className="border-amber-200 dark:border-amber-900 shadow-lg bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/20 dark:to-background">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Lightbulb className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<CardTitle className="text-base flex items-center gap-2">
{t('memoryEcho.title')}
<Sparkles className="h-4 w-4 text-amber-500" />
</CardTitle>
<CardDescription className="text-xs mt-1">
{t('memoryEcho.description')}
</CardDescription>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -mr-2 -mt-2"
onClick={handleDismiss}
>
<X className="h-4 w-4" />
</Button>
</div>
<div>
<CardTitle className="text-base flex items-center gap-2">
{t('memoryEcho.title')}
<Sparkles className="h-4 w-4 text-amber-500" />
</CardTitle>
<CardDescription className="text-xs mt-1">
{t('memoryEcho.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* AI-generated insight */}
<div className="bg-white dark:bg-zinc-900 rounded-lg p-3 border border-amber-100 dark:border-amber-900/30">
<p className="text-sm text-gray-700 dark:text-gray-300">
{insight.insight}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -mr-2 -mt-2"
onClick={handleDismiss}
>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* AI-generated insight */}
<div className="bg-white dark:bg-zinc-900 rounded-lg p-3 border border-amber-100 dark:border-amber-900/30">
<p className="text-sm text-gray-700 dark:text-gray-300">
{insight.insight}
</p>
</div>
{/* Connected notes */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="border-primary/20 text-primary dark:border-primary/30 dark:text-primary-foreground">
{note1Title}
</Badge>
<ArrowRight className="h-3 w-3 text-gray-400" />
<Badge variant="outline" className="border-purple-200 text-purple-700 dark:border-purple-900 dark:text-purple-300">
{note2Title}
</Badge>
<Badge variant="secondary" className="ml-auto text-xs">
{t('memoryEcho.match', { percentage: similarityPercentage })}
</Badge>
</div>
</div>
{/* Connected notes */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="border-primary/20 text-primary dark:border-primary/30 dark:text-primary-foreground">
{note1Title}
</Badge>
<ArrowRight className="h-3 w-3 text-gray-400" />
<Badge variant="outline" className="border-purple-200 text-purple-700 dark:border-purple-900 dark:text-purple-300">
{note2Title}
</Badge>
<Badge variant="secondary" className="ml-auto text-xs">
{t('memoryEcho.match', { percentage: similarityPercentage })}
</Badge>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
onClick={handleView}
>
{t('memoryEcho.viewConnection')}
</Button>
{/* Action buttons */}
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
onClick={handleView}
>
{t('memoryEcho.viewConnection')}
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 border-purple-200 text-purple-700 hover:bg-purple-50 dark:border-purple-800 dark:text-purple-400 dark:hover:bg-purple-950/20"
onClick={() => handleMergeNotes([insight.note1.id, insight.note2.id])}
>
<GitMerge className="h-4 w-4 mr-1" />
{t('memoryEcho.editorSection.merge')}
</Button>
<div className="flex items-center gap-1 border-l pl-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
onClick={() => handleFeedback('thumbs_up')}
title={t('memoryEcho.helpful')}
<div className="flex items-center gap-1 border-l pl-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
onClick={() => handleFeedback('thumbs_up')}
title={t('memoryEcho.helpful')}
>
<ThumbsUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => handleFeedback('thumbs_down')}
title={t('memoryEcho.notHelpful')}
>
<ThumbsDown className="h-4 w-4" />
</Button>
</div>
</div>
{/* Dismiss link */}
<button
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
onClick={handleDismiss}
>
<ThumbsUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => handleFeedback('thumbs_down')}
title={t('memoryEcho.notHelpful')}
>
<ThumbsDown className="h-4 w-4" />
</Button>
</div>
</div>
{/* Dismiss link */}
<button
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
onClick={handleDismiss}
>
{t('memoryEcho.dismiss')}
</button>
</CardContent>
</Card>
</div>
{t('memoryEcho.dismiss')}
</button>
</CardContent>
</Card>
</div>
)}
</>
)
}