diff --git a/keep-notes/app/(main)/agents/agents-page-client.tsx b/keep-notes/app/(main)/agents/agents-page-client.tsx new file mode 100644 index 0000000..ef086ac --- /dev/null +++ b/keep-notes/app/(main)/agents/agents-page-client.tsx @@ -0,0 +1,197 @@ +'use client' + +/** + * Agents Page Client + * Main client component for the agents page. + */ + +import { useState, useCallback } from 'react' +import { Plus, Bot } from 'lucide-react' +import { toast } from 'sonner' +import { useLanguage } from '@/lib/i18n' + +import { AgentCard } from '@/components/agents/agent-card' +import { AgentForm } from '@/components/agents/agent-form' +import { AgentTemplates } from '@/components/agents/agent-templates' +import { AgentRunLog } from '@/components/agents/agent-run-log' +import { + createAgent, + updateAgent, + getAgents, +} from '@/app/actions/agent-actions' + +// --- Types --- + +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 + targetNotebookId?: string | null + frequency: string + isEnabled: boolean + lastRun: string | Date | null + updatedAt: string | Date + _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[] +} + +// --- Component --- + +export function AgentsPageClient({ + agents: initialAgents, + notebooks, +}: AgentsPageClientProps) { + const { t } = useLanguage() + const [agents, setAgents] = useState(initialAgents) + const [showForm, setShowForm] = useState(false) + const [editingAgent, setEditingAgent] = useState(null) + const [logAgent, setLogAgent] = useState<{ id: string; name: string } | null>(null) + + const refreshAgents = useCallback(async () => { + try { + const updated = await getAgents() + setAgents(updated) + } catch { + // Silent + } + }, []) + + const handleToggle = useCallback((id: string, isEnabled: boolean) => { + setAgents(prev => prev.map(a => a.id === id ? { ...a, isEnabled } : a)) + }, []) + + const handleCreate = useCallback(() => { + setEditingAgent(null) + setShowForm(true) + }, []) + + const handleEdit = useCallback((id: string) => { + const agent = agents.find(a => a.id === id) + if (agent) { + setEditingAgent(agent) + setShowForm(true) + } + }, [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, + targetNotebookId: (formData.get('targetNotebookId') as string) || undefined, + frequency: formData.get('frequency') as string, + } + + if (editingAgent) { + await updateAgent(editingAgent.id, data) + toast.success(t('agents.toasts.updated')) + } else { + await createAgent(data) + toast.success(t('agents.toasts.created')) + } + + setShowForm(false) + setEditingAgent(null) + refreshAgents() + }, [editingAgent, refreshAgents, t]) + + return ( + <> + {/* Header */} +
+
+
+ +
+
+

{t('agents.title')}

+

{t('agents.subtitle')}

+
+
+
+ + {/* Create button */} +
+ +
+ + {/* Agents grid */} + {agents.length > 0 && ( +
+

+ {t('agents.myAgents')} +

+
+ {agents.map(agent => ( + + ))} +
+
+ )} + + {/* Empty state */} + {agents.length === 0 && ( +
+ +

{t('agents.noAgents')}

+

+ {t('agents.noAgentsDescription')} +

+
+ )} + + {/* Templates */} + + + {/* Form modal */} + {showForm && ( + { setShowForm(false); setEditingAgent(null) }} + /> + )} + + {/* Run log modal */} + {logAgent && ( + setLogAgent(null)} + /> + )} + + ) +} diff --git a/keep-notes/app/actions/agent-actions.ts b/keep-notes/app/actions/agent-actions.ts new file mode 100644 index 0000000..15f41bb --- /dev/null +++ b/keep-notes/app/actions/agent-actions.ts @@ -0,0 +1,209 @@ +'use server' + +/** + * Agent Server Actions + * CRUD operations for agents and execution triggers. + */ + +import { auth } from '@/auth' +import { prisma } from '@/lib/prisma' +import { revalidatePath } from 'next/cache' +import { executeAgent } from '@/lib/ai/services/agent-executor.service' + +// --- CRUD --- + +export async function createAgent(data: { + name: string + description?: string + type: string + role: string + sourceUrls?: string[] + sourceNotebookId?: string + targetNotebookId?: string + frequency?: string +}) { + const session = await auth() + if (!session?.user?.id) { + throw new Error('Non autorise') + } + + try { + const agent = await prisma.agent.create({ + data: { + name: data.name, + description: data.description, + type: data.type, + role: data.role, + sourceUrls: data.sourceUrls ? JSON.stringify(data.sourceUrls) : null, + sourceNotebookId: data.sourceNotebookId || null, + targetNotebookId: data.targetNotebookId || null, + frequency: data.frequency || 'manual', + userId: session.user.id, + } + }) + + revalidatePath('/agents') + return { success: true, agent } + } catch (error) { + console.error('Error creating agent:', error) + throw new Error('Impossible de creer l\'agent') + } +} + +export async function updateAgent(id: string, data: { + name?: string + description?: string + type?: string + role?: string + sourceUrls?: string[] + sourceNotebookId?: string | null + targetNotebookId?: string | null + frequency?: string + isEnabled?: boolean +}) { + const session = await auth() + if (!session?.user?.id) { + throw new Error('Non autorise') + } + + try { + const existing = await prisma.agent.findUnique({ where: { id } }) + if (!existing || existing.userId !== session.user.id) { + throw new Error('Agent non trouve') + } + + const updateData: Record = {} + if (data.name !== undefined) updateData.name = data.name + if (data.description !== undefined) updateData.description = data.description + if (data.type !== undefined) updateData.type = data.type + if (data.role !== undefined) updateData.role = data.role + if (data.sourceUrls !== undefined) updateData.sourceUrls = JSON.stringify(data.sourceUrls) + if (data.sourceNotebookId !== undefined) updateData.sourceNotebookId = data.sourceNotebookId + if (data.targetNotebookId !== undefined) updateData.targetNotebookId = data.targetNotebookId + if (data.frequency !== undefined) updateData.frequency = data.frequency + if (data.isEnabled !== undefined) updateData.isEnabled = data.isEnabled + + const agent = await prisma.agent.update({ + where: { id }, + data: updateData + }) + + revalidatePath('/agents') + return { success: true, agent } + } catch (error) { + console.error('Error updating agent:', error) + throw new Error('Impossible de mettre a jour l\'agent') + } +} + +export async function deleteAgent(id: string) { + const session = await auth() + if (!session?.user?.id) { + throw new Error('Non autorise') + } + + try { + const existing = await prisma.agent.findUnique({ where: { id } }) + if (!existing || existing.userId !== session.user.id) { + throw new Error('Agent non trouve') + } + + await prisma.agent.delete({ where: { id } }) + revalidatePath('/agents') + return { success: true } + } catch (error) { + console.error('Error deleting agent:', error) + throw new Error('Impossible de supprimer l\'agent') + } +} + +export async function getAgents() { + const session = await auth() + if (!session?.user?.id) { + throw new Error('Non autorise') + } + + try { + const agents = await prisma.agent.findMany({ + where: { userId: session.user.id }, + include: { + _count: { select: { actions: true } }, + actions: { + orderBy: { createdAt: 'desc' }, + take: 1, + }, + notebook: { + select: { id: true, name: true, icon: true } + } + }, + orderBy: { createdAt: 'desc' } + }) + + return agents + } catch (error) { + console.error('Error fetching agents:', error) + throw new Error('Impossible de charger les agents') + } +} + +// --- Execution --- + +export async function runAgent(id: string) { + const session = await auth() + if (!session?.user?.id) { + throw new Error('Non autorise') + } + + try { + const result = await executeAgent(id, session.user.id) + revalidatePath('/agents') + revalidatePath('/') + return result + } catch (error) { + console.error('Error running agent:', error) + return { + success: false, + actionId: '', + error: error instanceof Error ? error.message : 'Erreur inconnue' + } + } +} + +// --- History --- + +export async function getAgentActions(agentId: string) { + const session = await auth() + if (!session?.user?.id) { + throw new Error('Non autorise') + } + + try { + const actions = await prisma.agentAction.findMany({ + where: { agentId }, + orderBy: { createdAt: 'desc' }, + take: 20, + }) + return actions + } catch (error) { + console.error('Error fetching agent actions:', error) + throw new Error('Impossible de charger l\'historique') + } +} + +export async function toggleAgent(id: string, isEnabled: boolean) { + const session = await auth() + if (!session?.user?.id) { + throw new Error('Non autorise') + } + + try { + const agent = await prisma.agent.update({ + where: { id }, + data: { isEnabled } + }) + return { success: true, agent } + } catch (error) { + console.error('Error toggling agent:', error) + throw new Error('Impossible de modifier l\'agent') + } +} diff --git a/keep-notes/components/agents/agent-card.tsx b/keep-notes/components/agents/agent-card.tsx new file mode 100644 index 0000000..8880323 --- /dev/null +++ b/keep-notes/components/agents/agent-card.tsx @@ -0,0 +1,225 @@ +'use client' + +/** + * Agent Card Component + * Displays a single agent with status, actions, and metadata. + */ + +import { useState } 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, + ToggleLeft, + ToggleRight, + Globe, + Search, + Eye, + Settings, + CheckCircle2, + XCircle, + Clock, +} from 'lucide-react' +import { toast } from 'sonner' +import { useLanguage } from '@/lib/i18n' + +// --- Types --- + +interface AgentCardProps { + agent: { + id: string + name: string + description?: string | null + type?: string | null + isEnabled: boolean + frequency: string + lastRun: string | Date | null + 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 +} + +// --- Config --- + +const typeConfig: Record = { + scraper: { icon: Globe, color: 'text-blue-600', bgColor: 'bg-blue-50 border-blue-200' }, + researcher: { icon: Search, color: 'text-purple-600', bgColor: 'bg-purple-50 border-purple-200' }, + monitor: { icon: Eye, color: 'text-amber-600', bgColor: 'bg-amber-50 border-amber-200' }, + custom: { icon: Settings, color: 'text-green-600', bgColor: 'bg-green-50 border-green-200' }, +} + +const frequencyKeys: Record = { + manual: 'agents.frequencies.manual', + hourly: 'agents.frequencies.hourly', + daily: 'agents.frequencies.daily', + weekly: 'agents.frequencies.weekly', + monthly: 'agents.frequencies.monthly', +} + +const statusKeys: Record = { + success: 'agents.status.success', + failure: 'agents.status.failure', + running: 'agents.status.running', + pending: 'agents.status.pending', +} + +// --- Component --- + +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 config = typeConfig[agent.type || 'scraper'] || typeConfig.custom + const Icon = config.icon + const lastAction = agent.actions[0] + const dateLocale = language === 'fr' ? fr : enUS + + const handleRun = async () => { + setIsRunning(true) + try { + const { runAgent } = await import('@/app/actions/agent-actions') + const result = await runAgent(agent.id) + if (result.success) { + toast.success(t('agents.toasts.runSuccess', { name: agent.name })) + } else { + toast.error(t('agents.toasts.runError', { error: result.error || t('agents.toasts.runFailed') })) + } + } catch { + toast.error(t('agents.toasts.runGenericError')) + } finally { + setIsRunning(false) + onRefresh() + } + } + + 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) + } + } + + return ( +
+
+ +
+
+
+
+ +
+
+

{agent.name}

+ + {t(`agents.types.${agent.type || 'custom'}`)} + +
+
+ +
+ + {agent.description && ( +

{agent.description}

+ )} + +
+ + + {t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')} + + {agent.notebook && ( + + {agent.notebook.icon || '📁'} {agent.notebook.name} + + )} + {t('agents.metadata.executions', { count: agent._count.actions })} +
+ + {lastAction && ( +
+ {lastAction.status === 'success' && } + {lastAction.status === 'failure' && } + {lastAction.status === 'running' && } + {t(statusKeys[lastAction.status] || lastAction.status)} + {' - '} + {formatDistanceToNow(new Date(lastAction.createdAt), { addSuffix: true, locale: dateLocale })} +
+ )} + +
+ + + +
+
+
+ ) +}