All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup - Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders - Fix all SQL embedding queries: add ::vector cast on text columns - Fix embedding truncation to 15000 chars (under 8192 token limit) - Fix NoteEmbedding INSERT: remove non-existent updatedAt column - Fix billing page: show all quota stats in grid instead of single metric - Fix usage meter: accordion expand/collapse, per-feature detail - Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch - Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
import { fr } from 'date-fns/locale/fr'
|
|
import { enUS } from 'date-fns/locale/en-US'
|
|
import {
|
|
Play,
|
|
Trash2,
|
|
Loader2,
|
|
Globe,
|
|
Search,
|
|
Eye,
|
|
Settings,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Clock,
|
|
Pencil,
|
|
Activity,
|
|
Presentation,
|
|
ListChecks,
|
|
} from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { getNotebookIcon } from '@/lib/notebook-icon'
|
|
|
|
interface AgentCardProps {
|
|
agent: {
|
|
id: string
|
|
name: string
|
|
description?: string | null
|
|
type?: string | null
|
|
isEnabled: boolean
|
|
frequency: string
|
|
lastRun: string | Date | null
|
|
nextRun?: string | Date | null
|
|
createdAt: string | Date
|
|
updatedAt: string | Date
|
|
_count: { actions: number }
|
|
actions: { id: string; status: string; createdAt: string | Date }[]
|
|
notebook?: { id: string; name: string; icon?: string | null } | null
|
|
}
|
|
onEdit: (id: string) => void
|
|
onRefresh: () => void
|
|
onToggle: (id: string, isEnabled: boolean) => void
|
|
}
|
|
|
|
const typeConfig: Record<string, { icon: typeof Globe }> = {
|
|
scraper: { icon: Globe },
|
|
researcher: { icon: Search },
|
|
monitor: { icon: Eye },
|
|
custom: { icon: Settings },
|
|
'slide-generator': { icon: Presentation },
|
|
'excalidraw-generator': { icon: Pencil },
|
|
'task-extractor': { icon: ListChecks },
|
|
}
|
|
|
|
const frequencyKeys: Record<string, string> = {
|
|
manual: 'agents.frequencies.manual',
|
|
hourly: 'agents.frequencies.hourly',
|
|
daily: 'agents.frequencies.daily',
|
|
weekly: 'agents.frequencies.weekly',
|
|
monthly: 'agents.frequencies.monthly',
|
|
}
|
|
|
|
export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps) {
|
|
const { t, language } = useLanguage()
|
|
const [isRunning, setIsRunning] = useState(false)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
const [isToggling, setIsToggling] = useState(false)
|
|
const [mounted, setMounted] = useState(false)
|
|
|
|
useEffect(() => { setMounted(true) }, [])
|
|
|
|
const config = typeConfig[agent.type || 'scraper'] || typeConfig.custom
|
|
const Icon = config.icon
|
|
const lastAction = agent.actions[0]
|
|
const dateLocale = language === 'fr' ? fr : enUS
|
|
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
|
|
|
const handleRun = async () => {
|
|
setIsRunning(true)
|
|
const toastId = toast.loading(
|
|
t('agents.toasts.running') || `Agent "${agent.name}" en cours…`,
|
|
{ description: t('agents.toasts.runningDesc') || 'La génération peut prendre quelques minutes.', duration: Infinity }
|
|
)
|
|
try {
|
|
const { runAgent } = await import('@/app/actions/agent-actions')
|
|
const result = await runAgent(agent.id)
|
|
if (!result.success) {
|
|
toast.error(t('agents.toasts.runError', { error: result.error || t('agents.toasts.runFailed') }), { id: toastId })
|
|
setIsRunning(false)
|
|
return
|
|
}
|
|
if (pollRef.current) clearInterval(pollRef.current)
|
|
|
|
const startTime = Date.now()
|
|
const MAX_POLL_TIME = 10 * 60 * 1000 // 10 minutes
|
|
|
|
pollRef.current = setInterval(async () => {
|
|
// Safety timeout
|
|
if (Date.now() - startTime > MAX_POLL_TIME) {
|
|
if (pollRef.current) clearInterval(pollRef.current)
|
|
pollRef.current = null
|
|
setIsRunning(false)
|
|
toast.error(t('agents.toasts.runError', { error: 'Timeout after 10 minutes' }), { id: toastId, description: '' })
|
|
return
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/agents/run-for-note?agentId=${agent.id}`)
|
|
if (!res.ok) throw new Error('Poll failed')
|
|
|
|
const data = await res.json()
|
|
if (data.status === 'success') {
|
|
if (pollRef.current) clearInterval(pollRef.current)
|
|
pollRef.current = null
|
|
setIsRunning(false)
|
|
toast.success(t('agents.toasts.runSuccess', { name: agent.name }), {
|
|
id: toastId,
|
|
duration: 6000,
|
|
description: '' // Clear the loading description
|
|
})
|
|
onRefresh()
|
|
} else if (data.status === 'failure') {
|
|
if (pollRef.current) clearInterval(pollRef.current)
|
|
pollRef.current = null
|
|
setIsRunning(false)
|
|
toast.error(t('agents.toasts.runError', { error: data.error || t('agents.toasts.runFailed') }), {
|
|
id: toastId,
|
|
description: '' // Clear the loading description
|
|
})
|
|
onRefresh()
|
|
}
|
|
} catch (err) {
|
|
console.error('Polling error:', err)
|
|
// Keep polling until timeout
|
|
}
|
|
}, 3000)
|
|
} catch {
|
|
toast.error(t('agents.toasts.runGenericError'), { id: toastId })
|
|
setIsRunning(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirm(t('agents.actions.deleteConfirm', { name: agent.name }))) return
|
|
setIsDeleting(true)
|
|
try {
|
|
const { deleteAgent } = await import('@/app/actions/agent-actions')
|
|
await deleteAgent(agent.id)
|
|
toast.success(t('agents.toasts.deleted', { name: agent.name }))
|
|
} catch {
|
|
toast.error(t('agents.toasts.deleteError'))
|
|
} finally {
|
|
setIsDeleting(false)
|
|
onRefresh()
|
|
}
|
|
}
|
|
|
|
const handleToggle = async () => {
|
|
const newEnabled = !agent.isEnabled
|
|
setIsToggling(true)
|
|
onToggle(agent.id, newEnabled)
|
|
try {
|
|
const { toggleAgent } = await import('@/app/actions/agent-actions')
|
|
await toggleAgent(agent.id, newEnabled)
|
|
toast.success(newEnabled ? t('agents.actions.toggleOn') : t('agents.actions.toggleOff'))
|
|
} catch {
|
|
onToggle(agent.id, !newEnabled)
|
|
toast.error(t('agents.toasts.toggleError'))
|
|
} finally {
|
|
setIsToggling(false)
|
|
}
|
|
}
|
|
|
|
const nextRunLabel = (() => {
|
|
if (!agent.isEnabled) return '—'
|
|
if (agent.frequency === 'manual') return t('agents.frequencies.manual')
|
|
if (agent.nextRun && new Date(agent.nextRun) > new Date()) {
|
|
if (!mounted) return '...'
|
|
return formatDistanceToNow(new Date(agent.nextRun), { addSuffix: true, locale: dateLocale })
|
|
}
|
|
return t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')
|
|
})()
|
|
|
|
const statusLabel = lastAction
|
|
? lastAction.status === 'success' ? t('agents.status.success')
|
|
: lastAction.status === 'failure' ? t('agents.status.failure')
|
|
: lastAction.status === 'running' ? t('agents.status.running')
|
|
: t('agents.status.pending')
|
|
: '—'
|
|
|
|
return (
|
|
<div
|
|
className={`
|
|
bg-card border border-border rounded-2xl p-6 space-y-6
|
|
hover:border-foreground/20 transition-all group cursor-pointer
|
|
shadow-sm relative overflow-hidden
|
|
${!agent.isEnabled ? 'opacity-50' : ''}
|
|
`}
|
|
onClick={() => onEdit(agent.id)}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-3 bg-muted rounded-xl group-hover:bg-brand-accent group-hover:text-white transition-all">
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<h4 className="text-[13px] font-bold text-foreground">{agent.name}</h4>
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground opacity-60">
|
|
{t(`agents.types.${agent.type || 'custom'}`)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={handleToggle}
|
|
disabled={isToggling}
|
|
className="disabled:opacity-50"
|
|
title={agent.isEnabled ? t('agents.actions.toggleOff') : t('agents.actions.toggleOn')}
|
|
>
|
|
<div className="relative inline-flex items-center cursor-pointer">
|
|
<div className={`w-8 h-4 rounded-full transition-colors ${
|
|
agent.isEnabled ? 'bg-brand-accent' : 'bg-muted-foreground/30'
|
|
}`}>
|
|
<span className={`absolute top-0.5 left-[2px] bg-background border border-muted-foreground/30 rounded-full h-3 w-3 transition-all ${
|
|
agent.isEnabled ? 'translate-x-4' : ''
|
|
}`} />
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{agent.description && (
|
|
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
|
{agent.description}
|
|
</p>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between text-[10px] text-muted-foreground font-medium">
|
|
<div className="flex items-center gap-4">
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-2.5 h-2.5" />
|
|
{t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')}
|
|
</span>
|
|
<span>{t('agents.metadata.executions', { count: agent._count.actions })}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between text-[10px] text-muted-foreground font-medium">
|
|
<div className="flex items-center gap-2">
|
|
<span className="uppercase tracking-tight">{t('agents.status.nextRun')}</span>
|
|
<span className="text-foreground">{nextRunLabel}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="uppercase tracking-tight">{t('agents.status.lastStatus')}</span>
|
|
{lastAction ? (
|
|
<span className={`flex items-center gap-1 ${
|
|
lastAction.status === 'success' ? 'text-brand-accent'
|
|
: lastAction.status === 'failure' ? 'text-destructive'
|
|
: lastAction.status === 'running' ? 'text-brand-accent'
|
|
: 'text-muted-foreground'
|
|
}`}>
|
|
{lastAction.status === 'success' && <Activity className="w-2 h-2" />}
|
|
{lastAction.status === 'failure' && <XCircle className="w-2 h-2" />}
|
|
{lastAction.status === 'running' && <Loader2 className="w-2 h-2 animate-spin" />}
|
|
{statusLabel}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-2 border-t border-border pt-4" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={() => onEdit(agent.id)}
|
|
className="py-2 border border-border rounded-lg hover:bg-muted flex items-center justify-center transition-colors text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
<span className="ml-2 text-[10px] font-bold uppercase">{t('agents.actions.edit')}</span>
|
|
</button>
|
|
<button
|
|
onClick={handleRun}
|
|
disabled={isRunning || !agent.isEnabled}
|
|
className="py-2 border border-border rounded-lg hover:bg-muted flex items-center justify-center transition-colors text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{isRunning ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5 fill-current" />}
|
|
</button>
|
|
<button
|
|
onClick={handleDelete}
|
|
disabled={isDeleting}
|
|
className="py-2 border border-border rounded-lg hover:bg-destructive/10 hover:text-destructive hover:border-destructive/20 flex items-center justify-center transition-colors text-muted-foreground disabled:opacity-40"
|
|
>
|
|
{isDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|