Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 53s
- Redesign agents page with architectural-grid (8) design system: rounded-2xl cards, serif headings, motion tabs, dashed templates section - Replace agent form popup with full-page detail view (SettingsView style) with dark planning card, section tooltips, and help button - Hide advanced mode for slide/excalidraw generators - Add 'describe images' action to contextual AI assistant - Add copy button to action/resource preview with HTTP fallback - Add delete history button to agent run log panel - Increase AI word limit from 2000 to 5000 (reformulate + transform-markdown) - Increase max steps slider from 25 to 50 - Fix image description error with clear model compatibility message - Fix doubled execution count display in agent detail view - Remove dead files: notes-list-view.tsx, notes-view-toggle.tsx - Remove 'list' view mode from NotesViewMode type - Add missing i18n keys (back, configuration, options, copy, cleared)
337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
|
import { motion } from 'motion/react'
|
|
import { Plus, Bot, Search, LifeBuoy } 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-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-background/80 backdrop-blur-md z-30">
|
|
<div className="flex justify-between items-end">
|
|
<div className="space-y-1">
|
|
<h1 className="font-memento-serif 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-foreground text-background text-sm font-medium rounded-xl hover:opacity-90 transition-all flex items-center gap-3 shadow-lg shadow-foreground/10"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
{t('agents.newAgent')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-8 border-b border-border pt-4">
|
|
<div className="flex items-center gap-8">
|
|
{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 ${
|
|
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-foreground"
|
|
/>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="relative pb-4">
|
|
<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-12 flex-1 pb-20 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)} />
|
|
)}
|
|
</>
|
|
)
|
|
}
|