chore: clean up repo for public release
- Remove BMAD framework, IDE configs, dev screenshots, test files, internal docs, and backup files - Rename keep-notes/ to memento-note/ - Update all references from keep-notes to memento-note - Add Apache 2.0 license with Commons Clause (non-commercial restriction) - Add clean .gitignore and .env.docker.example
This commit is contained in:
244
memento-note/components/agents/agent-card.tsx
Normal file
244
memento-note/components/agents/agent-card.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agent Card Component
|
||||
* Displays a single agent with status, actions, and metadata.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } 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'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
type?: string | null
|
||||
isEnabled: boolean
|
||||
frequency: string
|
||||
lastRun: string | Date | null
|
||||
createdAt: string | Date
|
||||
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<string, { icon: typeof Globe; color: string; bgColor: string }> = {
|
||||
scraper: { icon: Globe, color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800' },
|
||||
researcher: { icon: Search, color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800' },
|
||||
monitor: { icon: Eye, color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800' },
|
||||
custom: { icon: Settings, color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800' },
|
||||
}
|
||||
|
||||
const frequencyKeys: Record<string, string> = {
|
||||
manual: 'agents.frequencies.manual',
|
||||
hourly: 'agents.frequencies.hourly',
|
||||
daily: 'agents.frequencies.daily',
|
||||
weekly: 'agents.frequencies.weekly',
|
||||
monthly: 'agents.frequencies.monthly',
|
||||
}
|
||||
|
||||
const statusKeys: Record<string, string> = {
|
||||
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 [mounted, setMounted] = useState(false)
|
||||
|
||||
// Prevent hydration mismatch for date formatting
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
const config = typeConfig[agent.type || 'scraper'] || typeConfig.custom
|
||||
const Icon = config.icon
|
||||
const lastAction = agent.actions[0]
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
const isNew = new Date(agent.createdAt).getTime() === new Date(agent.updatedAt).getTime()
|
||||
|
||||
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 (
|
||||
<div className={`
|
||||
bg-card rounded-xl border-2 transition-all overflow-hidden
|
||||
${agent.isEnabled ? 'border-border hover:border-primary/30 hover:shadow-md' : 'border-border/50 opacity-60'}
|
||||
`}>
|
||||
<div className={`h-1 ${agent.isEnabled ? 'bg-primary' : 'bg-muted'}`} />
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={`p-2 rounded-lg border ${config.bgColor}`}>
|
||||
<Icon className={`w-4 h-4 ${config.color}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-card-foreground truncate">{agent.name}</h3>
|
||||
{mounted && isNew && (
|
||||
<span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 rounded">
|
||||
{t('agents.newBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${config.color}`}>
|
||||
{t(`agents.types.${agent.type || 'custom'}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleToggle} disabled={isToggling} className="flex-shrink-0 ml-2 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{agent.isEnabled ? (
|
||||
<ToggleRight className="w-6 h-6 text-primary" />
|
||||
) : (
|
||||
<ToggleLeft className="w-6 h-6 text-muted-foreground/40" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{agent.description && (
|
||||
<p className="text-xs text-muted-foreground mb-3 line-clamp-2">{agent.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')}
|
||||
</span>
|
||||
{agent.notebook && (
|
||||
<span className="flex items-center gap-1">
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(agent.notebook.icon)
|
||||
return <Icon className="w-3 h-3" />
|
||||
})()} {agent.notebook.name}
|
||||
</span>
|
||||
)}
|
||||
<span>{t('agents.metadata.executions', { count: agent._count.actions })}</span>
|
||||
</div>
|
||||
|
||||
{lastAction && (
|
||||
<div className={`
|
||||
flex items-center gap-1.5 text-xs px-2 py-1 rounded-md mb-3
|
||||
${lastAction.status === 'success' ? 'bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400' : ''}
|
||||
${lastAction.status === 'failure' ? 'bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400' : ''}
|
||||
${lastAction.status === 'running' ? 'bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400' : ''}
|
||||
`}>
|
||||
{lastAction.status === 'success' && <CheckCircle2 className="w-3 h-3" />}
|
||||
{lastAction.status === 'failure' && <XCircle className="w-3 h-3" />}
|
||||
{lastAction.status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{t(statusKeys[lastAction.status] || lastAction.status)}
|
||||
{' - '}
|
||||
{mounted
|
||||
? formatDistanceToNow(new Date(lastAction.createdAt), { addSuffix: true, locale: dateLocale })
|
||||
: new Date(lastAction.createdAt).toISOString().split('T')[0]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onEdit(agent.id)}
|
||||
className="flex-1 px-3 py-1.5 text-xs font-medium text-primary bg-primary/5 rounded-lg hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
{t('agents.actions.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || !agent.isEnabled}
|
||||
className="p-1.5 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950 rounded-lg hover:bg-green-100 dark:hover:bg-green-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={t('agents.actions.run')}
|
||||
>
|
||||
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="p-1.5 text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950 rounded-lg hover:bg-red-100 dark:hover:bg-red-900 transition-colors disabled:opacity-50"
|
||||
title={t('agents.actions.delete')}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
509
memento-note/components/agents/agent-form.tsx
Normal file
509
memento-note/components/agents/agent-form.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agent Form Component
|
||||
* Simplified form for creating and editing agents.
|
||||
* Novice-friendly: hides system prompt and tools behind "Advanced mode".
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import { X, Plus, Trash2, Globe, FileSearch, FilePlus, FileText, ExternalLink, Brain, ChevronDown, ChevronUp, HelpCircle, Mail, ImageIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom'
|
||||
|
||||
/** Small "?" tooltip shown next to form labels */
|
||||
function FieldHelp({ tooltip }: { tooltip: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="inline-flex items-center ml-1 text-muted-foreground/40 hover:text-muted-foreground transition-colors">
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs text-balance">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
interface AgentFormProps {
|
||||
agent?: {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
type?: string | null
|
||||
role: string
|
||||
sourceUrls?: string | null
|
||||
sourceNotebookId?: string | null
|
||||
targetNotebookId?: string | null
|
||||
frequency: string
|
||||
tools?: string | null
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
} | null
|
||||
notebooks: { id: string; name: string; icon?: string | null }[]
|
||||
onSave: (data: FormData) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
// --- Tool presets per type ---
|
||||
const TOOL_PRESETS: Record<string, string[]> = {
|
||||
scraper: ['web_scrape', 'note_create', 'memory_search'],
|
||||
researcher: ['web_search', 'web_scrape', 'note_search', 'note_create', 'memory_search'],
|
||||
monitor: ['note_search', 'note_read', 'note_create', 'memory_search'],
|
||||
custom: ['memory_search'],
|
||||
}
|
||||
|
||||
// --- Shared class strings ---
|
||||
const labelCls = 'block text-sm font-medium text-foreground mb-1.5'
|
||||
const labelCls2 = 'block text-sm font-medium text-foreground mb-2'
|
||||
const inputCls = 'w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-card text-foreground'
|
||||
const selectCls = 'w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-card text-foreground'
|
||||
const toggleOffBorder = 'border-border hover:border-primary/30'
|
||||
const toggleOffIcon = 'text-muted-foreground'
|
||||
const toggleOffLabel = 'text-sm font-medium text-foreground'
|
||||
const toggleOffHint = 'text-xs text-muted-foreground'
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps) {
|
||||
const { t } = useLanguage()
|
||||
const [name, setName] = useState(agent?.name || '')
|
||||
const [description, setDescription] = useState(agent?.description || '')
|
||||
const [type, setType] = useState<AgentType>((agent?.type as AgentType) || 'scraper')
|
||||
const [role, setRole] = useState(agent?.role || '')
|
||||
const [urls, setUrls] = useState<string[]>(() => {
|
||||
if (agent?.sourceUrls) {
|
||||
try { return JSON.parse(agent.sourceUrls) } catch { return [''] }
|
||||
}
|
||||
return ['']
|
||||
})
|
||||
const [sourceNotebookId, setSourceNotebookId] = useState(agent?.sourceNotebookId || '')
|
||||
const [targetNotebookId, setTargetNotebookId] = useState(agent?.targetNotebookId || '')
|
||||
const [frequency, setFrequency] = useState(agent?.frequency || 'manual')
|
||||
const [selectedTools, setSelectedTools] = useState<string[]>(() => {
|
||||
if (agent?.tools) {
|
||||
try {
|
||||
const parsed = JSON.parse(agent.tools)
|
||||
if (parsed.length > 0) return parsed
|
||||
} catch { /* fall through to presets */ }
|
||||
}
|
||||
// New agent or old agent with empty tools: use preset defaults
|
||||
const defaultType = (agent?.type as AgentType) || 'scraper'
|
||||
return TOOL_PRESETS[defaultType] || []
|
||||
})
|
||||
const [maxSteps, setMaxSteps] = useState(agent?.maxSteps || 10)
|
||||
const [notifyEmail, setNotifyEmail] = useState(agent?.notifyEmail || false)
|
||||
const [includeImages, setIncludeImages] = useState(agent?.includeImages || false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [showAdvanced, setShowAdvanced] = useState(() => {
|
||||
// Auto-open advanced if editing an agent with custom tools or custom prompt
|
||||
if (agent?.tools) {
|
||||
try {
|
||||
const tools = JSON.parse(agent.tools)
|
||||
if (tools.length > 0) return true
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// Also open if agent has a custom role (instructions)
|
||||
if (agent?.role && agent.role.trim().length > 0) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// Tool definitions
|
||||
const availableTools = useMemo(() => [
|
||||
{ id: 'web_search', icon: Globe, labelKey: 'agents.tools.webSearch', external: true },
|
||||
{ id: 'web_scrape', icon: ExternalLink, labelKey: 'agents.tools.webScrape', external: true },
|
||||
{ id: 'note_search', icon: FileSearch, labelKey: 'agents.tools.noteSearch', external: false },
|
||||
{ id: 'note_read', icon: FileText, labelKey: 'agents.tools.noteRead', external: false },
|
||||
{ id: 'note_create', icon: FilePlus, labelKey: 'agents.tools.noteCreate', external: false },
|
||||
{ id: 'url_fetch', icon: ExternalLink, labelKey: 'agents.tools.urlFetch', external: false },
|
||||
{ id: 'memory_search', icon: Brain, labelKey: 'agents.tools.memorySearch', external: false },
|
||||
], [])
|
||||
|
||||
// Track previous type to detect user-initiated type changes
|
||||
const prevTypeRef = useRef(type)
|
||||
|
||||
// When user explicitly changes type (not on mount), reset tools to presets
|
||||
if (prevTypeRef.current !== type) {
|
||||
prevTypeRef.current = type
|
||||
// This is a user-initiated type change, not a mount
|
||||
// We queue the state update to happen after render
|
||||
setSelectedTools(TOOL_PRESETS[type] || [])
|
||||
setRole('')
|
||||
}
|
||||
|
||||
const addUrl = () => setUrls([...urls, ''])
|
||||
const removeUrl = (index: number) => setUrls(urls.filter((_, i) => i !== index))
|
||||
const updateUrl = (index: number, value: string) => {
|
||||
const newUrls = [...urls]
|
||||
newUrls[index] = value
|
||||
setUrls(newUrls)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
toast.error(t('agents.form.nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set('name', name.trim())
|
||||
formData.set('description', description.trim())
|
||||
formData.set('type', type)
|
||||
formData.set('role', role || t(`agents.defaultRoles.${type}`))
|
||||
formData.set('frequency', frequency)
|
||||
formData.set('targetNotebookId', targetNotebookId)
|
||||
|
||||
if (type === 'monitor') {
|
||||
formData.set('sourceNotebookId', sourceNotebookId)
|
||||
}
|
||||
|
||||
const validUrls = urls.filter(u => u.trim())
|
||||
if (validUrls.length > 0) {
|
||||
formData.set('sourceUrls', JSON.stringify(validUrls))
|
||||
}
|
||||
|
||||
formData.set('tools', JSON.stringify(selectedTools))
|
||||
formData.set('maxSteps', String(maxSteps))
|
||||
formData.set('notifyEmail', String(notifyEmail))
|
||||
formData.set('includeImages', String(includeImages))
|
||||
|
||||
await onSave(formData)
|
||||
} catch {
|
||||
toast.error(t('agents.toasts.saveError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showSourceNotebook = type === 'monitor'
|
||||
|
||||
const agentTypes: { value: AgentType; labelKey: string; descKey: string }[] = [
|
||||
{ value: 'researcher', labelKey: 'agents.types.researcher', descKey: 'agents.typeDescriptions.researcher' },
|
||||
{ value: 'scraper', labelKey: 'agents.types.scraper', descKey: 'agents.typeDescriptions.scraper' },
|
||||
{ value: 'monitor', labelKey: 'agents.types.monitor', descKey: 'agents.typeDescriptions.monitor' },
|
||||
{ value: 'custom', labelKey: 'agents.types.custom', descKey: 'agents.typeDescriptions.custom' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header — editable agent name */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="text-lg font-semibold text-card-foreground bg-transparent border-none outline-none focus:ring-0 p-0 flex-1 placeholder:text-muted-foreground/40"
|
||||
placeholder={t('agents.form.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
<button onClick={onCancel} className="p-1 rounded-md hover:bg-accent ml-3">
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
{/* Agent Type */}
|
||||
<div>
|
||||
<label className={labelCls2}>{t('agents.form.agentType')}<FieldHelp tooltip={t('agents.help.tooltips.agentType')} /></label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{agentTypes.map(at => (
|
||||
<button
|
||||
key={at.value}
|
||||
type="button"
|
||||
onClick={() => setType(at.value)}
|
||||
className={`
|
||||
text-left px-3 py-2.5 rounded-lg border-2 transition-all text-sm
|
||||
${type === at.value
|
||||
? 'border-primary bg-primary/5 text-primary font-medium'
|
||||
: `${toggleOffBorder} text-muted-foreground`}
|
||||
`}
|
||||
>
|
||||
<div className="font-medium">{t(at.labelKey)}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{t(at.descKey)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Research Topic (researcher only) — replaces Description for this type */}
|
||||
{type === 'researcher' && (
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.researchTopic')}<FieldHelp tooltip={t('agents.help.tooltips.researchTopic')} /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder={t('agents.form.researchTopicPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (for non-researcher types) */}
|
||||
{type !== 'researcher' && (
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.description')}<FieldHelp tooltip={t('agents.help.tooltips.description')} /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder={t('agents.form.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URLs (scraper and custom only — researcher uses search, not URLs) */}
|
||||
{(type === 'scraper' || type === 'custom') && (
|
||||
<div>
|
||||
<label className={labelCls}>
|
||||
{t('agents.form.urlsLabel')}<FieldHelp tooltip={t('agents.help.tooltips.urls')} />
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{urls.map((url, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={e => updateUrl(i, e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
{urls.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeUrl(i)}
|
||||
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addUrl}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('agents.form.addUrl')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Notebook (monitor only) */}
|
||||
{showSourceNotebook && (
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
|
||||
<select
|
||||
value={sourceNotebookId}
|
||||
onChange={e => setSourceNotebookId(e.target.value)}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="">{t('agents.form.selectNotebook')}</option>
|
||||
{notebooks.map(nb => (
|
||||
<option key={nb.id} value={nb.id}>
|
||||
{nb.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target Notebook */}
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
|
||||
<select
|
||||
value={targetNotebookId}
|
||||
onChange={e => setTargetNotebookId(e.target.value)}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="">{t('agents.form.inbox')}</option>
|
||||
{notebooks.map(nb => (
|
||||
<option key={nb.id} value={nb.id}>
|
||||
{nb.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<label className={labelCls}>{t('agents.form.frequency')}<FieldHelp tooltip={t('agents.help.tooltips.frequency')} /></label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={e => setFrequency(e.target.value)}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="manual">{t('agents.frequencies.manual')}</option>
|
||||
<option value="hourly">{t('agents.frequencies.hourly')}</option>
|
||||
<option value="daily">{t('agents.frequencies.daily')}</option>
|
||||
<option value="weekly">{t('agents.frequencies.weekly')}</option>
|
||||
<option value="monthly">{t('agents.frequencies.monthly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Email Notification */}
|
||||
<div
|
||||
onClick={() => setNotifyEmail(!notifyEmail)}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
notifyEmail
|
||||
? 'border-primary bg-primary/5'
|
||||
: toggleOffBorder
|
||||
}`}
|
||||
>
|
||||
<Mail className={`w-4 h-4 flex-shrink-0 ${notifyEmail ? 'text-primary' : toggleOffIcon}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={toggleOffLabel}>{t('agents.form.notifyEmail')}</div>
|
||||
<div className={toggleOffHint}>{t('agents.form.notifyEmailHint')}</div>
|
||||
</div>
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${notifyEmail ? 'bg-primary' : 'bg-muted'}`}>
|
||||
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${notifyEmail ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Include Images */}
|
||||
<div
|
||||
onClick={() => setIncludeImages(!includeImages)}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
includeImages
|
||||
? 'border-primary bg-primary/5'
|
||||
: toggleOffBorder
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className={`w-4 h-4 flex-shrink-0 ${includeImages ? 'text-primary' : toggleOffIcon}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={toggleOffLabel}>{t('agents.form.includeImages')}</div>
|
||||
<div className={toggleOffHint}>{t('agents.form.includeImagesHint')}</div>
|
||||
</div>
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${includeImages ? 'bg-primary' : 'bg-muted'}`}>
|
||||
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${includeImages ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced mode toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground font-medium w-full pt-2 border-t border-border"
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{t('agents.form.advancedMode')}
|
||||
</button>
|
||||
|
||||
{/* Advanced: System Prompt */}
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
{t('agents.form.instructions')}
|
||||
<FieldHelp tooltip={t('agents.help.tooltips.instructions')} />
|
||||
<span className="text-xs text-muted-foreground font-normal ml-1">({t('agents.form.instructionsHint')})</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={role}
|
||||
onChange={e => setRole(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-y min-h-[80px] bg-card text-foreground"
|
||||
placeholder={t('agents.form.instructionsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced: Tools */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">{t('agents.tools.title')}<FieldHelp tooltip={t('agents.help.tooltips.tools')} /></label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableTools.map(at => {
|
||||
const Icon = at.icon
|
||||
const isSelected = selectedTools.includes(at.id)
|
||||
return (
|
||||
<button
|
||||
key={at.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedTools(prev =>
|
||||
isSelected ? prev.filter(t => t !== at.id) : [...prev, at.id]
|
||||
)
|
||||
}}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all text-left
|
||||
${isSelected
|
||||
? 'border-primary bg-primary/5 text-primary font-medium'
|
||||
: `${toggleOffBorder} text-muted-foreground`}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t(at.labelKey)}</span>
|
||||
{at.external && !isSelected && (
|
||||
<span className="ml-auto text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950 px-1.5 py-0.5 rounded-full">{t('agents.tools.configNeeded')}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedTools.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t('agents.tools.selected', { count: selectedTools.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced: Max Steps */}
|
||||
{selectedTools.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
{t('agents.tools.maxSteps')}<FieldHelp tooltip={t('agents.help.tooltips.maxSteps')} />
|
||||
<span className="text-muted-foreground font-normal ml-1">({maxSteps})</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={3}
|
||||
max={25}
|
||||
value={maxSteps}
|
||||
onChange={e => setMaxSteps(Number(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>3</span>
|
||||
<span>25</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-muted-foreground bg-muted rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
{t('agents.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? t('agents.form.saving') : agent ? t('agents.form.save') : t('agents.form.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
memento-note/components/agents/agent-help.tsx
Normal file
96
memento-note/components/agents/agent-help.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agent Help Modal
|
||||
* Rich contextual help guide for the Agents page.
|
||||
* Collapsible sections with Markdown content inside each.
|
||||
*/
|
||||
|
||||
import { X, LifeBuoy } from 'lucide-react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface AgentHelpProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: 'whatIsAgent', defaultOpen: true },
|
||||
{ key: 'howToUse', defaultOpen: false },
|
||||
{ key: 'types', defaultOpen: false },
|
||||
{ key: 'advanced', defaultOpen: false },
|
||||
{ key: 'tools', defaultOpen: false },
|
||||
{ key: 'frequency', defaultOpen: false },
|
||||
{ key: 'targetNotebook', defaultOpen: false },
|
||||
{ key: 'templates', defaultOpen: false },
|
||||
{ key: 'tips', defaultOpen: false },
|
||||
] as const
|
||||
|
||||
export function AgentHelp({ onClose }: AgentHelpProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[85vh] flex flex-col mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LifeBuoy className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t('agents.help.title')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content — collapsible sections */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-2">
|
||||
{SECTIONS.map(section => (
|
||||
<details
|
||||
key={section.key}
|
||||
open={section.defaultOpen}
|
||||
className="group border-b border-slate-100 dark:border-slate-800 last:border-b-0"
|
||||
>
|
||||
<summary className="flex items-center gap-2 cursor-pointer py-3 font-medium text-slate-800 dark:text-slate-200 select-none hover:text-primary transition-colors text-sm">
|
||||
<span className="text-primary text-xs transition-transform group-open:rotate-90">▸</span>
|
||||
{t(`agents.help.${section.key}`)}
|
||||
</summary>
|
||||
<div className="pb-4 pl-5 prose prose-slate dark:prose-invert prose-sm max-w-none
|
||||
prose-headings:font-semibold prose-headings:text-slate-800 dark:prose-headings:text-slate-200
|
||||
prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1
|
||||
prose-p:leading-relaxed prose-p:text-slate-600 dark:prose-p:text-slate-400 prose-p:my-1.5
|
||||
prose-li:text-slate-600 dark:prose-li:text-slate-400 prose-li:my-0.5
|
||||
prose-strong:text-slate-700 dark:prose-strong:text-slate-300
|
||||
prose-code:text-primary prose-code:bg-primary/5 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
|
||||
prose-ul:my-2 prose-ol:my-2
|
||||
prose-hr:border-slate-200 dark:prose-hr:border-slate-700
|
||||
prose-table:text-xs
|
||||
prose-th:text-left prose-th:font-medium prose-th:text-slate-700 dark:prose-th:text-slate-300 prose-th:py-1 prose-th:pr-3
|
||||
prose-td:text-slate-600 dark:prose-td:text-slate-400 prose-td:py-1 prose-td:pr-3
|
||||
prose-blockquote:border-primary/30 prose-blockquote:text-slate-500 dark:prose-blockquote:text-slate-400
|
||||
">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>
|
||||
{t(`agents.help.${section.key}Content`)}
|
||||
</Markdown>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{t('agents.help.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
170
memento-note/components/agents/agent-run-log.tsx
Normal file
170
memento-note/components/agents/agent-run-log.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agent Run Log
|
||||
* Shows execution history for an agent.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, CheckCircle2, XCircle, Loader2, Clock, ChevronDown, Wrench } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface AgentRunLogProps {
|
||||
agentId: string
|
||||
agentName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface Action {
|
||||
id: string
|
||||
status: string
|
||||
result?: string | null
|
||||
log?: string | null
|
||||
input?: string | null
|
||||
toolLog?: string | null
|
||||
tokensUsed?: number | null
|
||||
createdAt: string | Date
|
||||
}
|
||||
|
||||
interface ToolLogStep {
|
||||
step: number
|
||||
text?: string
|
||||
toolCalls?: Array<{ toolName: string; args: any }>
|
||||
toolResults?: Array<{ toolName: string; preview?: string }>
|
||||
}
|
||||
|
||||
const statusKeys: Record<string, string> = {
|
||||
success: 'agents.status.success',
|
||||
failure: 'agents.status.failure',
|
||||
running: 'agents.status.running',
|
||||
pending: 'agents.status.pending',
|
||||
}
|
||||
|
||||
export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [actions, setActions] = useState<Action[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const { getAgentActions } = await import('@/app/actions/agent-actions')
|
||||
const data = await getAgentActions(agentId)
|
||||
setActions(data)
|
||||
} catch {
|
||||
// Silent fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [agentId])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-2xl shadow-xl max-w-md w-full max-h-[70vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<div>
|
||||
<h3 className="font-semibold text-card-foreground">{t('agents.runLog.title')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{agentName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-md hover:bg-accent">
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && actions.length === 0 && (
|
||||
<p className="text-center text-sm text-muted-foreground py-8">
|
||||
{t('agents.runLog.noHistory')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actions.map(action => {
|
||||
let toolSteps: ToolLogStep[] = []
|
||||
try {
|
||||
toolSteps = action.toolLog ? JSON.parse(action.toolLog) : []
|
||||
} catch {}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className={`
|
||||
p-3 rounded-lg border
|
||||
${action.status === 'success' ? 'bg-green-50/50 dark:bg-green-950/50 border-green-100 dark:border-green-900' : ''}
|
||||
${action.status === 'failure' ? 'bg-red-50/50 dark:bg-red-950/50 border-red-100 dark:border-red-900' : ''}
|
||||
${action.status === 'running' ? 'bg-blue-50/50 dark:bg-blue-950/50 border-blue-100 dark:border-blue-900' : ''}
|
||||
${action.status === 'pending' ? 'bg-muted border-border' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{action.status === 'success' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
|
||||
{action.status === 'failure' && <XCircle className="w-4 h-4 text-red-500" />}
|
||||
{action.status === 'running' && <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />}
|
||||
{action.status === 'pending' && <Clock className="w-4 h-4 text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t(statusKeys[action.status] || action.status)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(action.createdAt), { addSuffix: true, locale: dateLocale })}
|
||||
</span>
|
||||
</div>
|
||||
{action.log && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{action.log}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool trace */}
|
||||
{toolSteps.length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="flex items-center gap-1.5 text-xs text-primary cursor-pointer hover:text-primary/80 font-medium">
|
||||
<Wrench className="w-3 h-3" />
|
||||
{t('agents.runLog.toolTrace', { count: toolSteps.length })}
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 pl-2">
|
||||
{toolSteps.map((step, i) => (
|
||||
<div key={i} className="text-xs border-l-2 border-primary/30 pl-2 py-1">
|
||||
<span className="font-medium text-muted-foreground">{t('agents.runLog.step', { num: step.step })}</span>
|
||||
{step.toolCalls && step.toolCalls.length > 0 && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{step.toolCalls.map((tc, j) => (
|
||||
<div key={j} className="bg-muted rounded px-2 py-1">
|
||||
<span className="font-mono text-primary">{tc.toolName}</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{JSON.stringify(tc.args).substring(0, 80)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
150
memento-note/components/agents/agent-templates.tsx
Normal file
150
memento-note/components/agents/agent-templates.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agent Templates Gallery
|
||||
* Pre-built agent configurations that users can install in one click.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Globe,
|
||||
Search,
|
||||
Eye,
|
||||
Settings,
|
||||
Plus,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface AgentTemplatesProps {
|
||||
onInstalled: () => void
|
||||
existingAgentNames: string[]
|
||||
}
|
||||
|
||||
const templateConfig = [
|
||||
{ id: 'veilleAI', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
|
||||
'https://www.theverge.com/rss/ai-artificial-intelligence/index.xml',
|
||||
'https://techcrunch.com/category/artificial-intelligence/feed/',
|
||||
'https://feeds.arstechnica.com/arstechnica/technology-lab',
|
||||
'https://www.technologyreview.com/feed/',
|
||||
'https://www.wired.com/feed/',
|
||||
'https://korben.info/feed',
|
||||
], frequency: 'weekly' },
|
||||
{ id: 'veilleTech', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
|
||||
'https://news.ycombinator.com/rss',
|
||||
'https://dev.to/feed',
|
||||
'https://www.producthunt.com/feed',
|
||||
], frequency: 'daily' },
|
||||
{ id: 'veilleDev', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
|
||||
'https://dev.to/feed/tag/javascript',
|
||||
'https://dev.to/feed/tag/typescript',
|
||||
'https://dev.to/feed/tag/react',
|
||||
], frequency: 'weekly' },
|
||||
{ id: 'surveillant', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', urls: [], frequency: 'weekly' },
|
||||
{ id: 'chercheur', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', urls: [], frequency: 'manual' },
|
||||
] as const
|
||||
|
||||
const typeIcons: Record<string, typeof Globe> = {
|
||||
scraper: Globe,
|
||||
researcher: Search,
|
||||
monitor: Eye,
|
||||
custom: Settings,
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
scraper: 'text-blue-600 bg-blue-50',
|
||||
researcher: 'text-purple-600 bg-purple-50',
|
||||
monitor: 'text-amber-600 bg-amber-50',
|
||||
custom: 'text-green-600 bg-green-50',
|
||||
}
|
||||
|
||||
export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) {
|
||||
const { t } = useLanguage()
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
|
||||
const handleInstall = async (tpl: typeof templateConfig[number]) => {
|
||||
setInstallingId(tpl.id)
|
||||
try {
|
||||
const { createAgent } = await import('@/app/actions/agent-actions')
|
||||
const nameKey = `agents.templates.${tpl.id}.name` as const
|
||||
const descKey = `agents.templates.${tpl.id}.description` as const
|
||||
const baseName = t(nameKey)
|
||||
let resolvedName = baseName
|
||||
if (existingAgentNames.includes(baseName)) {
|
||||
let n = 2
|
||||
while (existingAgentNames.includes(`${baseName} ${n}`)) n++
|
||||
resolvedName = `${baseName} ${n}`
|
||||
}
|
||||
await createAgent({
|
||||
name: resolvedName,
|
||||
description: t(descKey),
|
||||
type: tpl.type,
|
||||
role: t(tpl.roleKey),
|
||||
sourceUrls: tpl.urls.length > 0 ? [...tpl.urls] : undefined,
|
||||
frequency: tpl.frequency,
|
||||
tools: tpl.type === 'scraper'
|
||||
? ['web_scrape', 'note_create']
|
||||
: tpl.type === 'researcher'
|
||||
? ['web_search', 'web_scrape', 'note_search', 'note_create']
|
||||
: tpl.type === 'monitor'
|
||||
? ['note_search', 'note_read', 'note_create']
|
||||
: [],
|
||||
})
|
||||
toast.success(t('agents.toasts.installSuccess', { name: resolvedName }))
|
||||
onInstalled()
|
||||
} catch {
|
||||
toast.error(t('agents.toasts.installError'))
|
||||
} finally {
|
||||
setInstallingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||
{t('agents.templates.title')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{templateConfig.map(tpl => {
|
||||
const Icon = typeIcons[tpl.type] || Settings
|
||||
const isInstalling = installingId === tpl.id
|
||||
const nameKey = `agents.templates.${tpl.id}.name`
|
||||
const descKey = `agents.templates.${tpl.id}.description`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className="border-2 border-dashed border-slate-200 rounded-xl p-4 hover:border-primary/30 hover:bg-primary/[0.02] transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<div className={`p-1.5 rounded-lg ${typeColors[tpl.type]}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<h4 className="font-medium text-sm text-slate-700">{t(nameKey)}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mb-3 line-clamp-2">{t(descKey)}</p>
|
||||
<button
|
||||
onClick={() => handleInstall(tpl)}
|
||||
disabled={isInstalling}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t('agents.templates.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('agents.templates.install')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user