feat(ai): implement intelligent auto-tagging system
- Added multi-provider AI infrastructure (OpenAI/Ollama) - Implemented real-time tag suggestions with debounced analysis - Created AI diagnostics and database maintenance tools in Settings - Added automated garbage collection for orphan labels - Refined UX with deterministic color hashing and interactive ghost tags
This commit is contained in:
79
keep-notes/components/ghost-tags.tsx
Normal file
79
keep-notes/components/ghost-tags.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { TagSuggestion } from '@/lib/ai/types';
|
||||
import { Loader2, Sparkles, X } from 'lucide-react';
|
||||
import { cn, getHashColor } from '@/lib/utils';
|
||||
import { LABEL_COLORS } from '@/lib/types';
|
||||
|
||||
interface GhostTagsProps {
|
||||
suggestions: TagSuggestion[];
|
||||
isAnalyzing: boolean;
|
||||
onSelectTag: (tag: string) => void;
|
||||
onDismissTag: (tag: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GhostTags({ suggestions, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
|
||||
console.log('GhostTags Render:', { count: suggestions.length, isAnalyzing, suggestions });
|
||||
|
||||
// On n'affiche rien si pas d'analyse et pas de suggestions
|
||||
if (!isAnalyzing && suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
|
||||
|
||||
{/* Indicateur IA discret */}
|
||||
{isAnalyzing && (
|
||||
<div className="flex items-center text-purple-500 animate-pulse" title="IA en cours d'analyse...">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des suggestions */}
|
||||
{!isAnalyzing && suggestions.map((suggestion) => {
|
||||
const colorName = getHashColor(suggestion.tag);
|
||||
const colorClasses = LABEL_COLORS[colorName];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={suggestion.tag}
|
||||
className={cn(
|
||||
"group flex items-center border border-dashed rounded-full transition-all cursor-pointer animate-in fade-in zoom-in duration-300 opacity-80 hover:opacity-100",
|
||||
colorClasses.bg,
|
||||
colorClasses.border
|
||||
)}
|
||||
>
|
||||
{/* Zone de validation (Clic principal) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelectTag(suggestion.tag);
|
||||
}}
|
||||
className={cn("flex items-center px-3 py-1 text-xs font-medium", colorClasses.text)}
|
||||
title="Cliquer pour ajouter ce tag"
|
||||
>
|
||||
<Sparkles className="w-3 h-3 mr-1.5 opacity-50" />
|
||||
{suggestion.tag}
|
||||
</button>
|
||||
|
||||
{/* Zone de refus (Croix) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDismissTag(suggestion.tag);
|
||||
}}
|
||||
className={cn("pr-2 pl-1 hover:text-red-500 transition-colors", colorClasses.text)}
|
||||
title="Ignorer cette suggestion"
|
||||
>
|
||||
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { LabelBadge } from './label-badge'
|
||||
import { NoteImages } from './note-images'
|
||||
import { NoteChecklist } from './note-checklist'
|
||||
import { NoteActions } from './note-actions'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note
|
||||
@@ -22,6 +23,7 @@ interface NoteCardProps {
|
||||
}
|
||||
|
||||
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
|
||||
const { refreshLabels } = useLabels()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
|
||||
|
||||
@@ -30,6 +32,8 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
// Refresh global labels to reflect garbage collection
|
||||
await refreshLabels()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error)
|
||||
setIsDeleting(false)
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon } from 'lucide-react'
|
||||
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles } from 'lucide-react'
|
||||
import { updateNote } from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -28,6 +28,9 @@ import { LabelManager } from './label-manager'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { ReminderDialog } from './reminder-dialog'
|
||||
import { EditorImages } from './editor-images'
|
||||
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||
import { GhostTags } from './ghost-tags'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
|
||||
interface NoteEditorProps {
|
||||
note: Note
|
||||
@@ -36,6 +39,7 @@ interface NoteEditorProps {
|
||||
|
||||
export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
const { addToast } = useToast()
|
||||
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
const [content, setContent] = useState(note.content)
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||
@@ -49,6 +53,12 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Auto-tagging hook
|
||||
const { suggestions, isAnalyzing } = useAutoTagging({
|
||||
content: note.type === 'text' ? (content || '') : '',
|
||||
enabled: note.type === 'text' // Auto-tagging only for text notes
|
||||
})
|
||||
|
||||
// Reminder state
|
||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
|
||||
@@ -56,9 +66,43 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
// Link state
|
||||
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
|
||||
// Tags rejetés par l'utilisateur pour cette session
|
||||
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
||||
|
||||
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
|
||||
|
||||
const handleSelectGhostTag = async (tag: string) => {
|
||||
// Vérification insensible à la casse
|
||||
const tagExists = labels.some(l => l.toLowerCase() === tag.toLowerCase())
|
||||
|
||||
if (!tagExists) {
|
||||
setLabels(prev => [...prev, tag])
|
||||
|
||||
// Créer le label globalement s'il n'existe pas
|
||||
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
|
||||
if (!globalExists) {
|
||||
try {
|
||||
await addLabel(tag)
|
||||
} catch (err) {
|
||||
console.error('Erreur création label auto:', err)
|
||||
}
|
||||
}
|
||||
addToast(`Tag "${tag}" ajouté`, 'success')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismissGhostTag = (tag: string) => {
|
||||
setDismissedTags(prev => [...prev, tag])
|
||||
}
|
||||
|
||||
// Filtrer les suggestions pour ne pas afficher celles rejetées ou déjà ajoutées (insensible à la casse)
|
||||
const filteredSuggestions = suggestions.filter(s => {
|
||||
if (!s || !s.tag) return false
|
||||
return !labels.some(l => l.toLowerCase() === s.tag.toLowerCase()) &&
|
||||
!dismissedTags.includes(s.tag)
|
||||
})
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files) return
|
||||
@@ -142,6 +186,10 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
reminder: currentReminder,
|
||||
isMarkdown,
|
||||
})
|
||||
|
||||
// Rafraîchir les labels globaux pour refléter les suppressions éventuelles (orphans)
|
||||
await refreshLabels()
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to save note:', error)
|
||||
@@ -193,12 +241,19 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<Input
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-8"
|
||||
/>
|
||||
{filteredSuggestions.length > 0 && (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2" title="Suggestions IA disponibles">
|
||||
<Sparkles className="w-4 h-4 text-purple-500 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<EditorImages images={images} onRemove={handleRemoveImage} />
|
||||
@@ -284,6 +339,14 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Auto-tagging Suggestions */}
|
||||
<GhostTags
|
||||
suggestions={filteredSuggestions}
|
||||
isAnalyzing={isAnalyzing}
|
||||
onSelectTag={handleSelectGhostTag}
|
||||
onDismissTag={handleDismissGhostTag}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -421,84 +484,43 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<ReminderDialog
|
||||
|
||||
open={showReminderDialog}
|
||||
|
||||
onOpenChange={setShowReminderDialog}
|
||||
|
||||
currentReminder={currentReminder}
|
||||
|
||||
onSave={handleReminderSave}
|
||||
|
||||
onRemove={handleRemoveReminder}
|
||||
<ReminderDialog
|
||||
open={showReminderDialog}
|
||||
onOpenChange={setShowReminderDialog}
|
||||
currentReminder={currentReminder}
|
||||
onSave={handleReminderSave}
|
||||
onRemove={handleRemoveReminder}
|
||||
/>
|
||||
|
||||
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Link</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Input
|
||||
placeholder="https://example.com"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddLink()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
|
||||
|
||||
<DialogContent>
|
||||
|
||||
<DialogHeader>
|
||||
|
||||
<DialogTitle>Add Link</DialogTitle>
|
||||
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
|
||||
<Input
|
||||
|
||||
placeholder="https://example.com"
|
||||
|
||||
value={linkUrl}
|
||||
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
|
||||
onKeyDown={(e) => {
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
handleAddLink()
|
||||
|
||||
}
|
||||
|
||||
}}
|
||||
|
||||
autoFocus
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>
|
||||
|
||||
Cancel
|
||||
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleAddLink}>
|
||||
|
||||
Add
|
||||
|
||||
</Button>
|
||||
|
||||
</DialogFooter>
|
||||
|
||||
</DialogContent>
|
||||
|
||||
</Dialog>
|
||||
|
||||
</Dialog>
|
||||
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowLinkDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddLink}>
|
||||
Add
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
import { LabelSelector } from './label-selector'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||
import { GhostTags } from './ghost-tags'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
|
||||
interface HistoryState {
|
||||
title: string
|
||||
@@ -56,6 +59,7 @@ interface NoteState {
|
||||
|
||||
export function NoteInput() {
|
||||
const { addToast } = useToast()
|
||||
const { labels: globalLabels, addLabel } = useLabels()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [type, setType] = useState<'text' | 'checklist'>('text')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -67,7 +71,47 @@ export function NoteInput() {
|
||||
// Simple state without complex undo/redo - like Google Keep
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
// Auto-tagging hook
|
||||
const { suggestions, isAnalyzing } = useAutoTagging({
|
||||
content: type === 'text' ? content : '',
|
||||
enabled: type === 'text' && isExpanded
|
||||
})
|
||||
|
||||
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
||||
|
||||
const handleSelectGhostTag = async (tag: string) => {
|
||||
// Vérification insensible à la casse
|
||||
const tagExists = selectedLabels.some(l => l.toLowerCase() === tag.toLowerCase())
|
||||
|
||||
if (!tagExists) {
|
||||
setSelectedLabels(prev => [...prev, tag])
|
||||
|
||||
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
|
||||
if (!globalExists) {
|
||||
try {
|
||||
await addLabel(tag)
|
||||
} catch (err) {
|
||||
console.error('Erreur création label auto:', err)
|
||||
}
|
||||
}
|
||||
|
||||
addToast(`Tag "${tag}" ajouté`, 'success')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismissGhostTag = (tag: string) => {
|
||||
setDismissedTags(prev => [...prev, tag])
|
||||
}
|
||||
|
||||
const filteredSuggestions = suggestions.filter(s => {
|
||||
if (!s || !s.tag) return false
|
||||
return !selectedLabels.some(l => l.toLowerCase() === s.tag.toLowerCase()) &&
|
||||
!dismissedTags.includes(s.tag)
|
||||
})
|
||||
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
|
||||
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [links, setLinks] = useState<LinkMetadata[]>([])
|
||||
const [isMarkdown, setIsMarkdown] = useState(false)
|
||||
@@ -418,46 +462,25 @@ export function NoteInput() {
|
||||
{/* Link Previews */}
|
||||
{links.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
{links.map((link, idx) => (
|
||||
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-gray-50 dark:bg-zinc-800/50 flex">
|
||||
{link.imageUrl && (
|
||||
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
||||
)}
|
||||
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
|
||||
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
|
||||
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-500 truncate hover:underline block mt-1">
|
||||
{new URL(link.url).hostname}
|
||||
</a>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
|
||||
onClick={() => handleRemoveLink(idx)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* ... */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Labels Display (Moved here to be visible for both text and checklist) */}
|
||||
{selectedLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{selectedLabels.map(label => (
|
||||
<LabelBadge
|
||||
key={label}
|
||||
label={label}
|
||||
onRemove={() => setSelectedLabels(prev => prev.filter(l => l !== label))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'text' ? (
|
||||
<div className="space-y-2">
|
||||
{/* Selected Labels Display */}
|
||||
{selectedLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{selectedLabels.map(label => (
|
||||
<LabelBadge
|
||||
key={label}
|
||||
label={label}
|
||||
onRemove={() => setSelectedLabels(prev => prev.filter(l => l !== label))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown toggle button */}
|
||||
{isMarkdown && (
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -496,6 +519,14 @@ export function NoteInput() {
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Auto-tagging Suggestions */}
|
||||
<GhostTags
|
||||
suggestions={filteredSuggestions}
|
||||
isAnalyzing={isAnalyzing}
|
||||
onSelectTag={handleSelectGhostTag}
|
||||
onDismissTag={handleDismissGhostTag}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { StickyNote, Bell, Archive, Trash2, Tag, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { StickyNote, Bell, Archive, Trash2, Tag, ChevronDown, ChevronUp, Settings } from 'lucide-react'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { LabelManagementDialog } from './label-management-dialog'
|
||||
|
||||
@@ -105,6 +105,13 @@ export function Sidebar({ className }: { className?: string }) {
|
||||
label="Trash"
|
||||
active={pathname === '/trash'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/settings"
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
active={pathname === '/settings'}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user