feat(agents): add search/filter, "New" badge, and duplicate name resolution

- Add search bar with real-time filtering on agent name and description
- Add type filter chips (All, Veilleur, Chercheur, Surveillant, Personnalisé)
- Add "New" badge on agents created within the last 24h (hydration-safe)
- Auto-increment template names on duplicate install (e.g. "Veille Tech 2")
- Add i18n keys for new UI elements in both fr and en locales

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-19 15:11:32 +02:00
parent 08ab0d1a1e
commit 5c63dfdd0c
5 changed files with 841 additions and 77 deletions

View File

@@ -5,7 +5,7 @@
* Displays a single agent with status, actions, and metadata.
*/
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
@@ -25,6 +25,7 @@ import {
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { getNotebookIcon } from '@/lib/notebook-icon'
// --- Types ---
@@ -37,6 +38,7 @@ interface AgentCardProps {
isEnabled: boolean
frequency: string
lastRun: string | Date | null
createdAt: string | Date
updatedAt: string | Date
_count: { actions: number }
actions: { id: string; status: string; createdAt: string | Date }[]
@@ -78,11 +80,16 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
const [isRunning, setIsRunning] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isToggling, setIsToggling] = useState(false)
const [mounted, setMounted] = useState(false)
// Prevent hydration mismatch for date formatting
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 isNew = Date.now() - new Date(agent.createdAt).getTime() < 24 * 60 * 60 * 1000
const handleRun = async () => {
setIsRunning(true)
@@ -147,7 +154,14 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<Icon className={`w-4 h-4 ${config.color}`} />
</div>
<div className="min-w-0">
<h3 className="font-semibold text-slate-800 truncate">{agent.name}</h3>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-slate-800 truncate">{agent.name}</h3>
{mounted && isNew && (
<span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-emerald-100 text-emerald-700 rounded">
{t('agents.newBadge')}
</span>
)}
</div>
<span className={`text-xs font-medium ${config.color}`}>
{t(`agents.types.${agent.type || 'custom'}`)}
</span>
@@ -173,7 +187,10 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
</span>
{agent.notebook && (
<span className="flex items-center gap-1">
{agent.notebook.icon || '📁'} {agent.notebook.name}
{(() => {
const Icon = getNotebookIcon(agent.notebook.icon)
return <Icon className="w-3 h-3" />
})()} {agent.notebook.name}
</span>
)}
<span>{t('agents.metadata.executions', { count: agent._count.actions })}</span>
@@ -191,7 +208,9 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
{lastAction.status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />}
{t(statusKeys[lastAction.status] || lastAction.status)}
{' - '}
{formatDistanceToNow(new Date(lastAction.createdAt), { addSuffix: true, locale: dateLocale })}
{mounted
? formatDistanceToNow(new Date(lastAction.createdAt), { addSuffix: true, locale: dateLocale })
: new Date(lastAction.createdAt).toISOString().split('T')[0]}
</div>
)}

View File

@@ -0,0 +1,150 @@
'use client'
/**
* Agent Templates Gallery
* Pre-built agent configurations that users can install in one click.
*/
import { useState } from 'react'
import {
Globe,
Search,
Eye,
Settings,
Plus,
Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface AgentTemplatesProps {
onInstalled: () => void
existingAgentNames: string[]
}
const templateConfig = [
{ id: 'veilleAI', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
'https://www.theverge.com/rss/ai-artificial-intelligence/index.xml',
'https://techcrunch.com/category/artificial-intelligence/feed/',
'https://feeds.arstechnica.com/arstechnica/technology-lab',
'https://www.technologyreview.com/feed/',
'https://www.wired.com/feed/',
'https://korben.info/feed',
], frequency: 'weekly' },
{ id: 'veilleTech', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
'https://news.ycombinator.com/rss',
'https://dev.to/feed',
'https://www.producthunt.com/feed',
], frequency: 'daily' },
{ id: 'veilleDev', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
'https://dev.to/feed/tag/javascript',
'https://dev.to/feed/tag/typescript',
'https://dev.to/feed/tag/react',
], frequency: 'weekly' },
{ id: 'surveillant', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', urls: [], frequency: 'weekly' },
{ id: 'chercheur', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', urls: [], frequency: 'manual' },
] as const
const typeIcons: Record<string, typeof Globe> = {
scraper: Globe,
researcher: Search,
monitor: Eye,
custom: Settings,
}
const typeColors: Record<string, string> = {
scraper: 'text-blue-600 bg-blue-50',
researcher: 'text-purple-600 bg-purple-50',
monitor: 'text-amber-600 bg-amber-50',
custom: 'text-green-600 bg-green-50',
}
export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) {
const { t } = useLanguage()
const [installingId, setInstallingId] = useState<string | null>(null)
const handleInstall = async (tpl: typeof templateConfig[number]) => {
setInstallingId(tpl.id)
try {
const { createAgent } = await import('@/app/actions/agent-actions')
const nameKey = `agents.templates.${tpl.id}.name` as const
const descKey = `agents.templates.${tpl.id}.description` as const
const baseName = t(nameKey)
let resolvedName = baseName
if (existingAgentNames.includes(baseName)) {
let n = 2
while (existingAgentNames.includes(`${baseName} ${n}`)) n++
resolvedName = `${baseName} ${n}`
}
await createAgent({
name: resolvedName,
description: t(descKey),
type: tpl.type,
role: t(tpl.roleKey),
sourceUrls: tpl.urls.length > 0 ? [...tpl.urls] : undefined,
frequency: tpl.frequency,
tools: tpl.type === 'scraper'
? ['web_scrape', 'note_create']
: tpl.type === 'researcher'
? ['web_search', 'web_scrape', 'note_search', 'note_create']
: tpl.type === 'monitor'
? ['note_search', 'note_read', 'note_create']
: [],
})
toast.success(t('agents.toasts.installSuccess', { name: resolvedName }))
onInstalled()
} catch {
toast.error(t('agents.toasts.installError'))
} finally {
setInstallingId(null)
}
}
return (
<div>
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
{t('agents.templates.title')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{templateConfig.map(tpl => {
const Icon = typeIcons[tpl.type] || Settings
const isInstalling = installingId === tpl.id
const nameKey = `agents.templates.${tpl.id}.name`
const descKey = `agents.templates.${tpl.id}.description`
return (
<div
key={tpl.id}
className="border-2 border-dashed border-slate-200 rounded-xl p-4 hover:border-primary/30 hover:bg-primary/[0.02] transition-all group"
>
<div className="flex items-center gap-2.5 mb-2">
<div className={`p-1.5 rounded-lg ${typeColors[tpl.type]}`}>
<Icon className="w-4 h-4" />
</div>
<h4 className="font-medium text-sm text-slate-700">{t(nameKey)}</h4>
</div>
<p className="text-xs text-slate-400 mb-3 line-clamp-2">{t(descKey)}</p>
<button
onClick={() => handleInstall(tpl)}
disabled={isInstalling}
className="flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-50"
>
{isInstalling ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t('agents.templates.installing')}
</>
) : (
<>
<Plus className="w-3.5 h-3.5" />
{t('agents.templates.install')}
</>
)}
</button>
</div>
)
})}
</div>
</div>
)
}