Files
Momento/memento-note/app/(main)/agents/agents-page-client.tsx
Antigravity 5f9b8a9a09
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m7s
CI / Deploy production (on server) (push) Successful in 24s
fix(ui): thème des réglages et derniers textes de la revue
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>
2026-08-30 21:42:28 +00:00

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)} />
)}
</>
)
}