Comprehensive UI/UX updates including agent card redesign, chat container improvements, note editor enhancements, memory echo notifications, and updated translations for all 15 locales. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
338 lines
12 KiB
TypeScript
338 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,
|
|
aiProvider: 'fusion',
|
|
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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|