All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 42s
The getAgents() function was recalculating nextRun to future dates when it found past values. This prevented the cron scheduler from ever finding due agents (nextRun <= now was always false since getAgents had already pushed it to the future). Also fix toast polling: use null sentinel for agents without initial actions so first execution is still detected. Limit cron to 3 agents per cycle and add logging. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Agents Page Client
|
|
* Main client component for the agents page.
|
|
*/
|
|
|
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
|
import { Plus, Bot, LifeBuoy, Search } 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 { AgentHelp } from '@/components/agents/agent-help'
|
|
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
|
|
createdAt: string | Date
|
|
updatedAt: string | Date
|
|
tools?: string | null
|
|
maxSteps?: number
|
|
notifyEmail?: boolean
|
|
includeImages?: boolean
|
|
_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
|
|
|
|
// --- 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 [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
|
|
}
|
|
}, [])
|
|
|
|
// Track latest action per agent to detect new executions
|
|
// null = agent tracked with no initial actions, undefined = not tracked yet
|
|
const prevActionsRef = useRef<Record<string, string | null>>({})
|
|
|
|
// Poll every 30s to detect agent executions and show toast notifications
|
|
useEffect(() => {
|
|
// Initialize tracking from initial data — use null for agents without actions
|
|
// so we can still detect their FIRST execution
|
|
for (const agent of initialAgents) {
|
|
prevActionsRef.current[agent.id] = agent.actions[0]?.id ?? null
|
|
}
|
|
|
|
const interval = setInterval(async () => {
|
|
const updated = await refreshAgents()
|
|
if (!updated) return
|
|
|
|
for (const agent of updated) {
|
|
const lastAction = agent.actions[0]
|
|
if (!lastAction) continue
|
|
|
|
const prevId = prevActionsRef.current[agent.id]
|
|
// undefined = agent not in initial list (created by someone else, skip)
|
|
if (prevId === undefined) continue
|
|
|
|
if (prevId !== lastAction.id) {
|
|
// New execution detected (first action ever, or new run)
|
|
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
|
|
}
|
|
}, 30000)
|
|
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
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,
|
|
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,
|
|
}
|
|
|
|
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)
|
|
await refreshAgents()
|
|
}, [editingAgent, refreshAgents, t])
|
|
|
|
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])
|
|
|
|
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>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<button
|
|
onClick={() => setShowHelp(true)}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 border border-primary/20 rounded-lg hover:bg-primary/15 transition-colors"
|
|
>
|
|
<LifeBuoy className="w-4 h-4" />
|
|
{t('agents.help.btnLabel')}
|
|
</button>
|
|
<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-muted-foreground uppercase tracking-wider mb-3">
|
|
{t('agents.myAgents')}
|
|
</h3>
|
|
|
|
{/* Search and filter */}
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 mb-4">
|
|
<div className="relative flex-1 w-full sm:max-w-xs">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
placeholder={t('agents.searchPlaceholder')}
|
|
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-lg outline-none focus:border-primary/40 focus:ring-2 focus:ring-primary/10 transition-all"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
{typeFilterOptions.map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setTypeFilter(opt.value)}
|
|
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-colors ${
|
|
typeFilter === opt.value
|
|
? 'bg-primary text-white'
|
|
: 'bg-muted text-muted-foreground hover:bg-accent'
|
|
}`}
|
|
>
|
|
{t(opt.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{filteredAgents.length > 0 ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{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/30 mb-3" />
|
|
<p className="text-sm text-muted-foreground">{t('agents.noResults')}</p>
|
|
</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-muted-foreground/30 mb-4" />
|
|
<h3 className="text-lg font-medium text-muted-foreground mb-2">{t('agents.noAgents')}</h3>
|
|
<p className="text-sm text-muted-foreground max-w-sm">
|
|
{t('agents.noAgentsDescription')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Templates */}
|
|
<AgentTemplates onInstalled={refreshAgents} existingAgentNames={existingAgentNames} />
|
|
|
|
{/* 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)}
|
|
/>
|
|
)}
|
|
|
|
{/* Help modal */}
|
|
{showHelp && (
|
|
<AgentHelp onClose={() => setShowHelp(false)} />
|
|
)}
|
|
</>
|
|
)
|
|
}
|