fix(agents): empêcher le déplacement des cartes lors du toggle
Le tri par `updatedAt` provoquait un saut de position quand on toggait un agent car Prisma mettait à jour `updatedAt` automatiquement. - Tri stable par `createdAt` au lieu de `updatedAt` - Mise à jour optimiste locale via `onToggle` au lieu d'un re-fetch complet - Rollback automatique en cas d'erreur serveur - Désactivation du bouton toggle pendant l'opération (anti double-clic) - Suppression du `revalidatePath` superflu dans `toggleAgent` Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
197
keep-notes/app/(main)/agents/agents-page-client.tsx
Normal file
197
keep-notes/app/(main)/agents/agents-page-client.tsx
Normal file
@@ -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<AgentItem | null>(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 */}
|
||||
<div className="flex items-center justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-primary/10 rounded-2xl shadow-sm border border-primary/20">
|
||||
<Bot className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('agents.title')}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t('agents.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create button */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Agents grid */}
|
||||
{agents.length > 0 && (
|
||||
<div className="mb-10">
|
||||
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||
{t('agents.myAgents')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{agents.map(agent => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onEdit={handleEdit}
|
||||
onRefresh={refreshAgents}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{agents.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center mb-10">
|
||||
<Bot className="w-16 h-16 text-slate-300 mb-4" />
|
||||
<h3 className="text-lg font-medium text-slate-500 mb-2">{t('agents.noAgents')}</h3>
|
||||
<p className="text-sm text-slate-400 max-w-sm">
|
||||
{t('agents.noAgentsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates */}
|
||||
<AgentTemplates onInstalled={refreshAgents} />
|
||||
|
||||
{/* Form modal */}
|
||||
{showForm && (
|
||||
<AgentForm
|
||||
agent={editingAgent}
|
||||
notebooks={notebooks}
|
||||
onSave={handleSave}
|
||||
onCancel={() => { setShowForm(false); setEditingAgent(null) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Run log modal */}
|
||||
{logAgent && (
|
||||
<AgentRunLog
|
||||
agentId={logAgent.id}
|
||||
agentName={logAgent.name}
|
||||
onClose={() => setLogAgent(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
209
keep-notes/app/actions/agent-actions.ts
Normal file
209
keep-notes/app/actions/agent-actions.ts
Normal file
@@ -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<string, unknown> = {}
|
||||
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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user