Les boutons des pages Agents, MCP, Données, Intégrations et Généraux suivent la couleur choisie. Les libellés encore techniques (recherche par le sens, notes proches, connexion unique) sont en langage courant. Co-authored-by: Cursor <cursoragent@cursor.com>
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
|
import { motion } from 'motion/react'
|
|
|
|
import { Plus, Bot, Search, LifeBuoy, Menu } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
|
|
import { AgentCard } from '@/components/agents/agent-card'
|
|
import { AgentDetailView } from '@/components/agents/agent-detail-view'
|
|
import { AgentTemplates } from '@/components/agents/agent-templates'
|
|
import { AgentRunLog } from '@/components/agents/agent-run-log'
|
|
import { AgentHelp } from '@/components/agents/agent-help'
|
|
import {
|
|
createAgent,
|
|
updateAgent,
|
|
getAgents,
|
|
} from '@/app/actions/agent-actions'
|
|
|
|
interface Notebook {
|
|
id: string
|
|
name: string
|
|
icon?: string | null
|
|
}
|
|
|
|
interface AgentItem {
|
|
id: string
|
|
name: string
|
|
description?: string | null
|
|
type?: string | null
|
|
role: string
|
|
sourceUrls?: string | null
|
|
sourceNotebookId?: string | null
|
|
sourceNoteIds?: string | null
|
|
targetNotebookId?: string | null
|
|
frequency: string
|
|
isEnabled: boolean
|
|
lastRun: string | Date | null
|
|
nextRun?: string | Date | null
|
|
createdAt: string | Date
|
|
updatedAt: string | Date
|
|
tools?: string | null
|
|
maxSteps?: number
|
|
notifyEmail?: boolean
|
|
includeImages?: boolean
|
|
scheduledTime?: string | null
|
|
scheduledDay?: number | null
|
|
timezone?: string | null
|
|
slideTheme?: string | null
|
|
slideStyle?: string | null
|
|
_count: { actions: number }
|
|
actions: { id: string; status: string; createdAt: string | Date }[]
|
|
notebook?: { id: string; name: string; icon?: string | null } | null
|
|
}
|
|
|
|
interface AgentsPageClientProps {
|
|
agents: AgentItem[]
|
|
notebooks: Notebook[]
|
|
}
|
|
|
|
const typeFilterOptions = [
|
|
{ value: '', labelKey: 'agents.filterAll' },
|
|
{ value: 'scraper', labelKey: 'agents.types.scraper' },
|
|
{ value: 'researcher', labelKey: 'agents.types.researcher' },
|
|
{ value: 'monitor', labelKey: 'agents.types.monitor' },
|
|
{ value: 'custom', labelKey: 'agents.types.custom' },
|
|
] as const
|
|
|
|
export function AgentsPageClient({
|
|
agents: initialAgents,
|
|
notebooks,
|
|
}: AgentsPageClientProps) {
|
|
const { t } = useLanguage()
|
|
const [agents, setAgents] = useState(initialAgents)
|
|
const [selectedAgent, setSelectedAgent] = useState<AgentItem | null>(null)
|
|
const [isNewAgent, setIsNewAgent] = useState(false)
|
|
const [logAgent, setLogAgent] = useState<{ id: string; name: string } | null>(null)
|
|
const [showHelp, setShowHelp] = useState(false)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [typeFilter, setTypeFilter] = useState('')
|
|
|
|
const refreshAgents = useCallback(async () => {
|
|
try {
|
|
const updated = await getAgents()
|
|
setAgents(updated)
|
|
return updated
|
|
} catch {
|
|
return null
|
|
}
|
|
}, [])
|
|
|
|
const prevActionsRef = useRef<Record<string, string | null>>({})
|
|
|
|
const checkForNewActions = useCallback((updated: AgentItem[]) => {
|
|
for (const agent of updated) {
|
|
const lastAction = agent.actions[0]
|
|
if (!lastAction) continue
|
|
const prevId = prevActionsRef.current[agent.id]
|
|
if (prevId === undefined) continue
|
|
if (prevId !== lastAction.id) {
|
|
const age = Date.now() - new Date(lastAction.createdAt).getTime()
|
|
if (age < 5 * 60 * 1000) {
|
|
if (lastAction.status === 'success') {
|
|
toast.success(t('agents.toasts.autoRunSuccess', { name: agent.name }))
|
|
} else if (lastAction.status === 'failure') {
|
|
toast.error(t('agents.toasts.autoRunError', { name: agent.name }))
|
|
}
|
|
}
|
|
}
|
|
prevActionsRef.current[agent.id] = lastAction.id
|
|
}
|
|
}, [t])
|
|
|
|
useEffect(() => {
|
|
for (const agent of initialAgents) {
|
|
prevActionsRef.current[agent.id] = agent.actions[0]?.id ?? null
|
|
}
|
|
const interval = setInterval(async () => {
|
|
const updated = await refreshAgents()
|
|
if (updated) checkForNewActions(updated)
|
|
}, 30000)
|
|
const onVisible = async () => {
|
|
if (document.visibilityState === 'visible') {
|
|
const updated = await refreshAgents()
|
|
if (updated) checkForNewActions(updated)
|
|
}
|
|
}
|
|
document.addEventListener('visibilitychange', onVisible)
|
|
return () => {
|
|
clearInterval(interval)
|
|
document.removeEventListener('visibilitychange', onVisible)
|
|
}
|
|
}, [refreshAgents, checkForNewActions])
|
|
|
|
const handleToggle = useCallback((id: string, isEnabled: boolean) => {
|
|
setAgents(prev => prev.map(a => a.id === id ? { ...a, isEnabled } : a))
|
|
}, [])
|
|
|
|
const handleCreate = useCallback(() => {
|
|
setIsNewAgent(true)
|
|
setSelectedAgent(null)
|
|
}, [])
|
|
|
|
const handleEdit = useCallback((id: string) => {
|
|
const agent = agents.find(a => a.id === id)
|
|
if (agent) {
|
|
setIsNewAgent(false)
|
|
setSelectedAgent(agent)
|
|
}
|
|
}, [agents])
|
|
|
|
const handleSave = useCallback(async (formData: FormData) => {
|
|
const data = {
|
|
name: formData.get('name') as string,
|
|
description: (formData.get('description') as string) || undefined,
|
|
type: formData.get('type') as string,
|
|
role: formData.get('role') as string,
|
|
sourceUrls: formData.get('sourceUrls') ? JSON.parse(formData.get('sourceUrls') as string) : undefined,
|
|
sourceNotebookId: (formData.get('sourceNotebookId') as string) || undefined,
|
|
sourceNoteIds: formData.get('sourceNoteIds') ? JSON.parse(formData.get('sourceNoteIds') as string) : undefined,
|
|
targetNotebookId: (formData.get('targetNotebookId') as string) || undefined,
|
|
frequency: formData.get('frequency') as string,
|
|
tools: formData.get('tools') ? JSON.parse(formData.get('tools') as string) : undefined,
|
|
maxSteps: formData.get('maxSteps') ? Number(formData.get('maxSteps')) : undefined,
|
|
notifyEmail: formData.get('notifyEmail') === 'true',
|
|
includeImages: formData.get('includeImages') === 'true',
|
|
scheduledTime: (formData.get('scheduledTime') as string) || undefined,
|
|
scheduledDay: formData.get('scheduledDay') ? Number(formData.get('scheduledDay')) : undefined,
|
|
timezone: (formData.get('timezone') as string) || undefined,
|
|
slideTheme: (formData.get('slideTheme') as string) || undefined,
|
|
slideStyle: (formData.get('slideStyle') as string) || undefined,
|
|
}
|
|
if (selectedAgent && !isNewAgent) {
|
|
await updateAgent(selectedAgent.id, data)
|
|
toast.success(t('agents.toasts.updated'))
|
|
} else {
|
|
await createAgent(data)
|
|
toast.success(t('agents.toasts.created'))
|
|
}
|
|
setSelectedAgent(null)
|
|
setIsNewAgent(false)
|
|
await refreshAgents()
|
|
}, [selectedAgent, isNewAgent, refreshAgents, t])
|
|
|
|
const handleBack = useCallback(() => {
|
|
setSelectedAgent(null)
|
|
setIsNewAgent(false)
|
|
}, [])
|
|
|
|
const filteredAgents = useMemo(() => {
|
|
return agents.filter(agent => {
|
|
const matchesType = !typeFilter || (agent.type || 'scraper') === typeFilter
|
|
if (!searchQuery.trim()) return matchesType
|
|
const q = searchQuery.toLowerCase()
|
|
const matchesSearch =
|
|
(agent.name || '').toLowerCase().includes(q) ||
|
|
(agent.description && agent.description.toLowerCase().includes(q))
|
|
return matchesType && matchesSearch
|
|
})
|
|
}, [agents, searchQuery, typeFilter])
|
|
|
|
const existingAgentNames = useMemo(() => agents.map(a => a.name), [agents])
|
|
|
|
const showDetail = selectedAgent !== null || isNewAgent
|
|
|
|
return (
|
|
<>
|
|
{showDetail ? (
|
|
<AgentDetailView
|
|
agent={isNewAgent ? null : selectedAgent}
|
|
notebooks={notebooks}
|
|
onSave={handleSave}
|
|
onBack={handleBack}
|
|
onOpenLogs={(id, name) => setLogAgent({ id, name })}
|
|
onOpenHelp={() => setShowHelp(true)}
|
|
isNew={isNewAgent}
|
|
/>
|
|
) : (
|
|
<>
|
|
<header className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-10 md:pt-12 pb-5 sm:pb-6 md:pb-8 flex flex-col gap-4 sm:gap-6 sticky top-0 bg-background/80 backdrop-blur-md z-30">
|
|
<div className="flex items-start gap-3 sm:flex-row sm:justify-between sm:items-end">
|
|
{/* Hamburger mobile */}
|
|
<button
|
|
className="md:hidden p-2 -ms-1 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 mt-0.5"
|
|
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
|
aria-label="Ouvrir la navigation"
|
|
>
|
|
<Menu size={22} />
|
|
</button>
|
|
<div className="space-y-1 flex-1 min-w-0">
|
|
<h1 className="font-memento-serif text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight text-foreground">
|
|
{t('agents.myAgents')}
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground font-light">
|
|
{t('agents.subtitle')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => setShowHelp(true)}
|
|
className="p-2.5 text-muted-foreground hover:text-foreground transition-colors rounded-lg hover:bg-muted"
|
|
title={t('agents.help.title')}
|
|
>
|
|
<LifeBuoy className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={handleCreate}
|
|
className="px-6 py-2.5 bg-brand-accent text-white text-sm font-medium rounded-xl hover:opacity-90 transition-all flex items-center gap-3 shadow-lg shadow-brand-accent/10"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
{t('agents.newAgent')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between sm:gap-8 border-b border-border pt-2 sm:pt-4">
|
|
<div className="flex items-center gap-4 sm:gap-8 overflow-x-auto">
|
|
{typeFilterOptions.map((opt, i) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setTypeFilter(opt.value)}
|
|
className={`pb-4 text-xs font-bold uppercase tracking-widest transition-all relative shrink-0 ${
|
|
typeFilter === opt.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/60'
|
|
}`}
|
|
>
|
|
{t(opt.labelKey)}
|
|
{typeFilter === opt.value && (
|
|
<motion.div
|
|
layoutId="activeAgentTag"
|
|
className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand-accent"
|
|
/>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="relative pb-4 hidden sm:block">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/60" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
placeholder={t('agents.searchPlaceholder')}
|
|
className="pl-9 pr-4 py-2 text-[13px] bg-card border border-border/50 rounded-lg outline-none focus:border-foreground/20 transition-all placeholder:text-muted-foreground/40 w-48"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="px-4 sm:px-8 md:px-12 flex-1 pb-10 sm:pb-16 md:pb-20 space-y-8 sm:space-y-12">
|
|
{agents.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center bg-card rounded-2xl border border-border/40 shadow-sm">
|
|
<Bot className="w-12 h-12 text-muted-foreground/20 mb-4" />
|
|
<h3 className="text-base font-semibold text-foreground mb-2">{t('agents.noAgents')}</h3>
|
|
<p className="text-sm text-muted-foreground max-w-sm mb-2">{t('agents.noAgentsDescription')}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{filteredAgents.length > 0 ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{filteredAgents.map(agent => (
|
|
<AgentCard
|
|
key={agent.id}
|
|
agent={agent}
|
|
onEdit={handleEdit}
|
|
onRefresh={refreshAgents}
|
|
onToggle={handleToggle}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<Search className="w-10 h-10 text-muted-foreground/20 mb-3" />
|
|
<p className="text-sm text-muted-foreground">{t('agents.noResults')}</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="space-y-8">
|
|
<div className="flex items-center gap-4">
|
|
<h5 className="text-[10px] font-bold uppercase tracking-[0.3em] text-muted-foreground whitespace-nowrap">
|
|
{t('agents.templates.title')}
|
|
</h5>
|
|
<div className="h-px w-full bg-border/40" />
|
|
</div>
|
|
<AgentTemplates onInstalled={refreshAgents} existingAgentNames={existingAgentNames} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{logAgent && (
|
|
<AgentRunLog
|
|
agentId={logAgent.id}
|
|
agentName={logAgent.name}
|
|
onClose={() => setLogAgent(null)}
|
|
/>
|
|
)}
|
|
{showHelp && (
|
|
<AgentHelp onClose={() => setShowHelp(false)} />
|
|
)}
|
|
</>
|
|
)
|
|
}
|