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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' 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 { 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 { interface MemoryEchoInsight {
id: string id: string
@@ -38,7 +42,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null) const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isDismissed, setIsDismissed] = 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 [demoMode, setDemoMode] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null) const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -58,8 +63,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
if (data.insight) { if (data.insight) {
setInsight(data.insight) setInsight(data.insight)
// Check if user is in demo mode by looking at frequency settings // If we got an insight, check if user is in demo mode
setDemoMode(true) // If we got an insight after dismiss, assume demo mode setDemoMode(true)
} }
} catch (error) { } catch (error) {
console.error('[MemoryEcho] Failed to fetch insight:', 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 // Start polling in demo mode after first dismiss
useEffect(() => { useEffect(() => {
if (isDismissed && !pollingRef.current) { if (isDismissed && demoMode && !pollingRef.current) {
pollingRef.current = setInterval(async () => { pollingRef.current = setInterval(async () => {
try { try {
const res = await fetch('/api/ai/echo') const res = await fetch('/api/ai/echo')
@@ -90,25 +95,20 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
pollingRef.current = null pollingRef.current = null
} }
} }
}, [isDismissed]) }, [isDismissed, demoMode])
const handleView = async () => { const handleView = async () => {
if (!insight) return if (!insight) return
try { try {
// Mark as viewed
await fetch('/api/ai/echo', { await fetch('/api/ai/echo', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ action: 'view', insightId: insight.id })
action: 'view',
insightId: insight.id
})
}) })
// Show success message and open modal
toast.success(t('toast.openingConnection')) toast.success(t('toast.openingConnection'))
setShowModal(true) setShowComparison(true)
} catch (error) { } catch (error) {
console.error('[MemoryEcho] Failed to view connection:', error) console.error('[MemoryEcho] Failed to view connection:', error)
toast.error(t('toast.openConnectionFailed')) toast.error(t('toast.openConnectionFailed'))
@@ -122,21 +122,15 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
await fetch('/api/ai/echo', { await fetch('/api/ai/echo', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ action: 'feedback', insightId: insight.id, feedback })
action: 'feedback',
insightId: insight.id,
feedback
})
}) })
// Show feedback toast
if (feedback === 'thumbs_up') { if (feedback === 'thumbs_up') {
toast.success(t('toast.thanksFeedback')) toast.success(t('toast.thanksFeedback'))
} else { } else {
toast.success(t('toast.thanksFeedbackImproving')) toast.success(t('toast.thanksFeedbackImproving'))
} }
// Dismiss notification
setIsDismissed(true) setIsDismissed(true)
// Stop polling after explicit feedback // Stop polling after explicit feedback
if (pollingRef.current) { if (pollingRef.current) {
@@ -149,131 +143,88 @@ 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 = () => { const handleDismiss = () => {
setIsDismissed(true) setIsDismissed(true)
} }
// Don't render notification if dismissed, loading, or no insight if (isLoading || !insight) {
if (isDismissed || isLoading || !insight) {
return null return null
} }
// Calculate values for both notification and modal
const note1Title = insight.note1.title || t('notification.untitled') const note1Title = insight.note1.title || t('notification.untitled')
const note2Title = insight.note2.title || t('notification.untitled') const note2Title = insight.note2.title || t('notification.untitled')
const similarityPercentage = Math.round(insight.similarityScore * 100) const similarityPercentage = Math.round(insight.similarityScore * 100)
// Render modal if requested const comparisonNotes: Array<Partial<Note>> = [
if (showModal && insight) { { 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 ( 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"> {/* Comparison Modal */}
{/* Header */} <ComparisonModal
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700"> isOpen={showComparison}
<div className="flex items-center gap-3"> onClose={() => {
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full"> setShowComparison(false)
<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) setIsDismissed(true)
}} }}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" notes={comparisonNotes}
> similarity={insight.similarityScore}
<X className="h-6 w-6" /> onOpenNote={(noteId) => {
</button> setShowComparison(false)
</div> setIsDismissed(true)
onOpenNote?.(noteId)
{/* 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" onMergeNotes={handleMergeNotes}
> />
<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 */} {/* Fusion Modal */}
<div {fusionNotes.length > 0 && (
onClick={() => { <FusionModal
if (onOpenNote) { isOpen={fusionNotes.length > 0}
onOpenNote(insight.note2.id) onClose={() => setFusionNotes([])}
setShowModal(false) 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)
}} }}
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 */} {/* Notification Card — hidden when dismissed or modal open */}
<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"> {!isDismissed && (
<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>
)
}
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"> <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"> <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"> <CardHeader className="pb-3">
@@ -337,6 +288,16 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
{t('memoryEcho.viewConnection')} {t('memoryEcho.viewConnection')}
</Button> </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"> <div className="flex items-center gap-1 border-l pl-2">
<Button <Button
variant="ghost" variant="ghost"
@@ -369,5 +330,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
)}
</>
) )
} }

View File

@@ -1,5 +1,6 @@
import { getAIProvider } from '../factory' import { getAIProvider } from '../factory'
import { cosineSimilarity } from '@/lib/utils' import { cosineSimilarity } from '@/lib/utils'
import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
export interface NoteConnection { export interface NoteConnection {
@@ -52,10 +53,53 @@ export class MemoryEchoService {
private readonly MIN_DAYS_APART_DEMO = 0 // No delay for demo mode private readonly MIN_DAYS_APART_DEMO = 0 // No delay for demo mode
private readonly MAX_INSIGHTS_PER_USER = 100 // Prevent spam 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<void> {
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 * Find meaningful connections between user's notes
*/ */
async findConnections(userId: string, demoMode: boolean = false): Promise<NoteConnection[]> { async findConnections(userId: string, demoMode: boolean = false): Promise<NoteConnection[]> {
// Ensure all notes have embeddings before searching for connections
await this.ensureEmbeddings(userId)
// Get all user's notes with embeddings // Get all user's notes with embeddings
const notes = await prisma.note.findMany({ const notes = await prisma.note.findMany({
where: { where: {
@@ -151,8 +195,8 @@ export class MemoryEchoService {
note2Content: string note2Content: string
): Promise<string> { ): Promise<string> {
try { try {
const config = await prisma.systemConfig.findFirst() const config = await getSystemConfig()
const provider = getAIProvider(config || undefined) const provider = getAIProvider(config)
const note1Desc = note1Title || 'Untitled note' const note1Desc = note1Title || 'Untitled note'
const note2Desc = note2Title || '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 * Get all connections for a specific note
*/ */
async getConnectionsForNote(noteId: string, userId: string): Promise<NoteConnection[]> { async getConnectionsForNote(noteId: string, userId: string): Promise<NoteConnection[]> {
// Ensure all notes have embeddings before searching
await this.ensureEmbeddings(userId)
// Get the note with embedding // Get the note with embedding
const targetNote = await prisma.note.findUnique({ const targetNote = await prisma.note.findUnique({
where: { id: noteId }, where: { id: noteId },