Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
246 lines
8.2 KiB
TypeScript
246 lines
8.2 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from './ui/dialog'
|
|
import { Sparkles, CheckCircle2, Loader2, Tag } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
|
import type { AutoLabelSuggestion, SuggestedLabel } from '@/lib/ai/services'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface AutoLabelSuggestionDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
notebookId: string | null
|
|
onLabelsCreated: () => void
|
|
}
|
|
|
|
export function AutoLabelSuggestionDialog({
|
|
open,
|
|
onOpenChange,
|
|
notebookId,
|
|
onLabelsCreated,
|
|
}: AutoLabelSuggestionDialogProps) {
|
|
const { t } = useLanguage()
|
|
const { requestAiConsent } = useAiConsent()
|
|
const [suggestions, setSuggestions] = useState<AutoLabelSuggestion | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [creating, setCreating] = useState(false)
|
|
const [selectedLabels, setSelectedLabels] = useState<Set<string>>(new Set())
|
|
|
|
useEffect(() => {
|
|
if (open && notebookId) {
|
|
fetchSuggestions()
|
|
} else {
|
|
setSuggestions(null)
|
|
setSelectedLabels(new Set())
|
|
}
|
|
}, [open, notebookId])
|
|
|
|
const fetchSuggestions = async () => {
|
|
if (!notebookId) return
|
|
|
|
const consented = await requestAiConsent()
|
|
if (!consented) return
|
|
|
|
setLoading(true)
|
|
try {
|
|
const response = await fetch('/api/ai/auto-labels', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
notebookId,
|
|
language: document.documentElement.lang || 'en',
|
|
}),
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.success && data.data) {
|
|
setSuggestions(data.data)
|
|
const allLabelNames = new Set<string>(data.data.suggestedLabels.map((l: SuggestedLabel) => l.name as string))
|
|
setSelectedLabels(allLabelNames)
|
|
} else {
|
|
if (data.message) {
|
|
toast.info(data.message)
|
|
} else {
|
|
toast.info(t('ai.autoLabels.noSuggestions') || 'Pas assez de notes pour générer des labels (minimum 15)')
|
|
}
|
|
onOpenChange(false)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch label suggestions:', error)
|
|
toast.error(t('ai.autoLabels.error'))
|
|
onOpenChange(false)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const toggleLabelSelection = (labelName: string) => {
|
|
const newSelected = new Set(selectedLabels)
|
|
if (newSelected.has(labelName)) {
|
|
newSelected.delete(labelName)
|
|
} else {
|
|
newSelected.add(labelName)
|
|
}
|
|
setSelectedLabels(newSelected)
|
|
}
|
|
|
|
const handleCreateLabels = async () => {
|
|
if (!suggestions || selectedLabels.size === 0) {
|
|
toast.error(t('ai.autoLabels.noLabelsSelected'))
|
|
return
|
|
}
|
|
|
|
setCreating(true)
|
|
try {
|
|
const response = await fetch('/api/ai/auto-labels', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
suggestions,
|
|
selectedLabels: Array.from(selectedLabels),
|
|
}),
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.success) {
|
|
toast.success(t('ai.autoLabels.created', { count: data.data.createdCount }))
|
|
onLabelsCreated()
|
|
onOpenChange(false)
|
|
} else {
|
|
toast.error(data.error || t('ai.autoLabels.error'))
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to create labels:', error)
|
|
toast.error(t('ai.autoLabels.error'))
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="sr-only">{t('ai.autoLabels.analyzing')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex flex-col items-center justify-center py-12">
|
|
<div className="w-16 h-16 rounded-full border border-dashed border-brand-accent/20 flex items-center justify-center mb-4">
|
|
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
|
{t('ai.autoLabels.analyzing')}
|
|
</p>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
if (!suggestions) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-brand-accent" />
|
|
{t('ai.autoLabels.title')}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t('ai.autoLabels.description', {
|
|
notebook: suggestions.notebookName,
|
|
count: suggestions.totalNotes,
|
|
})}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-2 py-4">
|
|
{suggestions.suggestedLabels.map((label) => {
|
|
const isSelected = selectedLabels.has(label.name)
|
|
return (
|
|
<div
|
|
key={label.name}
|
|
className={cn(
|
|
"flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-all",
|
|
isSelected
|
|
? "bg-brand-accent/5 border-brand-accent/30 hover:bg-brand-accent/10"
|
|
: "border-border hover:bg-muted/50"
|
|
)}
|
|
onClick={() => toggleLabelSelection(label.name)}
|
|
>
|
|
<div className={cn(
|
|
"w-5 h-5 rounded-full border-2 flex items-center justify-center mt-0.5 transition-all shrink-0",
|
|
isSelected
|
|
? "bg-brand-accent border-brand-accent"
|
|
: "border-border"
|
|
)}>
|
|
{isSelected && <CheckCircle2 className="h-3.5 w-3.5 text-white" />}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<Tag className="h-3.5 w-3.5 text-brand-accent/60" />
|
|
<span className="font-medium text-sm">{label.name}</span>
|
|
<Sparkles className="h-3 w-3 text-brand-accent/40" />
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1.5">
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t('ai.autoLabels.notesCount', { count: label.count })}
|
|
</span>
|
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-brand-accent/10 text-brand-accent font-bold">
|
|
{Math.round(label.confidence * 100)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2">
|
|
<button
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={creating}
|
|
className="flex-1 py-3 border border-border rounded-xl text-[10px] font-bold uppercase tracking-widest hover:bg-muted transition-all"
|
|
>
|
|
{t('general.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleCreateLabels}
|
|
disabled={selectedLabels.size === 0 || creating}
|
|
className="flex-1 py-3 bg-brand-accent text-white rounded-xl text-[10px] font-bold uppercase tracking-widest hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 disabled:opacity-50"
|
|
>
|
|
{creating ? (
|
|
<span className="flex items-center justify-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t('ai.autoLabels.creating')}
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center justify-center gap-2">
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
{t('ai.autoLabels.create')}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|