- 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>
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
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, 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
|
|
note1Id: string
|
|
note2Id: string
|
|
note1: {
|
|
id: string
|
|
title: string | null
|
|
content: string
|
|
}
|
|
note2: {
|
|
id: string
|
|
title: string | null
|
|
content: string
|
|
}
|
|
similarityScore: number
|
|
insight: string
|
|
insightDate: Date
|
|
viewed: boolean
|
|
feedback: string | null
|
|
}
|
|
|
|
interface MemoryEchoNotificationProps {
|
|
onOpenNote?: (noteId: string) => void
|
|
}
|
|
|
|
export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationProps) {
|
|
const { t } = useLanguage()
|
|
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [isDismissed, setIsDismissed] = 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)
|
|
|
|
// Fetch insight on mount
|
|
useEffect(() => {
|
|
fetchInsight()
|
|
return () => {
|
|
if (pollingRef.current) clearInterval(pollingRef.current)
|
|
}
|
|
}, [])
|
|
|
|
const fetchInsight = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const res = await fetch('/api/ai/echo')
|
|
const data = await res.json()
|
|
|
|
if (data.insight) {
|
|
setInsight(data.insight)
|
|
// If we got an insight, check if user is in demo mode
|
|
setDemoMode(true)
|
|
}
|
|
} catch (error) {
|
|
console.error('[MemoryEcho] Failed to fetch insight:', error)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
// Start polling in demo mode after first dismiss
|
|
useEffect(() => {
|
|
if (isDismissed && demoMode && !pollingRef.current) {
|
|
pollingRef.current = setInterval(async () => {
|
|
try {
|
|
const res = await fetch('/api/ai/echo')
|
|
const data = await res.json()
|
|
if (data.insight) {
|
|
setInsight(data.insight)
|
|
setIsDismissed(false)
|
|
}
|
|
} catch {
|
|
// silent
|
|
}
|
|
}, 15000) // Poll every 15s
|
|
}
|
|
return () => {
|
|
if (pollingRef.current) {
|
|
clearInterval(pollingRef.current)
|
|
pollingRef.current = null
|
|
}
|
|
}
|
|
}, [isDismissed, demoMode])
|
|
|
|
const handleView = async () => {
|
|
if (!insight) return
|
|
|
|
try {
|
|
await fetch('/api/ai/echo', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action: 'view', insightId: insight.id })
|
|
})
|
|
|
|
toast.success(t('toast.openingConnection'))
|
|
setShowComparison(true)
|
|
} catch (error) {
|
|
console.error('[MemoryEcho] Failed to view connection:', error)
|
|
toast.error(t('toast.openConnectionFailed'))
|
|
}
|
|
}
|
|
|
|
const handleFeedback = async (feedback: 'thumbs_up' | 'thumbs_down') => {
|
|
if (!insight) return
|
|
|
|
try {
|
|
await fetch('/api/ai/echo', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action: 'feedback', insightId: insight.id, feedback })
|
|
})
|
|
|
|
if (feedback === 'thumbs_up') {
|
|
toast.success(t('toast.thanksFeedback'))
|
|
} else {
|
|
toast.success(t('toast.thanksFeedbackImproving'))
|
|
}
|
|
|
|
setIsDismissed(true)
|
|
// Stop polling after explicit feedback
|
|
if (pollingRef.current) {
|
|
clearInterval(pollingRef.current)
|
|
pollingRef.current = null
|
|
}
|
|
} catch (error) {
|
|
console.error('[MemoryEcho] Failed to submit feedback:', error)
|
|
toast.error(t('toast.feedbackFailed'))
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if (isLoading || !insight) {
|
|
return null
|
|
}
|
|
|
|
const note1Title = insight.note1.title || t('notification.untitled')
|
|
const note2Title = insight.note2.title || t('notification.untitled')
|
|
const similarityPercentage = Math.round(insight.similarityScore * 100)
|
|
|
|
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 (
|
|
<>
|
|
{/* 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>
|
|
</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>
|
|
|
|
{/* 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')}
|
|
>
|
|
<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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|