Files
Momento/memento-note/components/agents/agent-form.tsx
sepehr 153c921960
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
fix: comprehensive i18n — replace hardcoded French/English strings with t() calls
Replaced ~100+ hardcoded French and English text strings across 30+ components
with proper i18n t() calls. Added 57 new translation keys to all 15 locale files
(ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh).

Key changes:
- contextual-ai-chat.tsx: 30 French strings → t() (actions, toasts, labels, placeholders)
- ai-chat.tsx: 15 French/English strings → t() (header, tabs, welcome, insights, history)
- note-inline-editor.tsx: 20 French fallbacks removed (toolbar, save status, checklist)
- lab-skeleton.tsx: French loading text → t()
- admin-header.tsx, header.tsx, editor-connections-section.tsx: French fallbacks removed
- New AI chat component, agent cards, sidebar, settings panel i18n cleanup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:14:45 +02:00

577 lines
25 KiB
TypeScript

'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
scheduledTime?: string | null
scheduledDay?: number | null
timezone?: string | null
} | 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 [scheduledTime, setScheduledTime] = useState(agent?.scheduledTime || '08:00')
const [scheduledDay, setScheduledDay] = useState<number>(agent?.scheduledDay ?? 1)
const [timezone] = useState(() => {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' }
})
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))
formData.set('scheduledTime', scheduledTime)
formData.set('scheduledDay', String(scheduledDay))
formData.set('timezone', timezone)
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/20 flex justify-end z-50" onClick={onCancel}>
<div
className="bg-card shadow-2xl w-full max-w-md h-full overflow-y-auto animate-in slide-in-from-right duration-300 flex flex-col border-l border-border/40"
onClick={e => e.stopPropagation()}
>
{/* 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>
{/* Schedule config: time + day pickers (hidden for manual/hourly) */}
{frequency !== 'manual' && frequency !== 'hourly' && (
<div className="flex gap-3">
{/* Day selector (weekly/monthly only) */}
{frequency === 'weekly' && (
<div className="flex-1">
<label className={labelCls}>{t('agents.schedule.dayOfWeek')}</label>
<select
value={scheduledDay}
onChange={e => setScheduledDay(Number(e.target.value))}
className={selectCls}
>
{[
{ value: 0, label: t('agents.schedule.days.mon') },
{ value: 1, label: t('agents.schedule.days.tue') },
{ value: 2, label: t('agents.schedule.days.wed') },
{ value: 3, label: t('agents.schedule.days.thu') },
{ value: 4, label: t('agents.schedule.days.fri') },
{ value: 5, label: t('agents.schedule.days.sat') },
{ value: 6, label: t('agents.schedule.days.sun') },
].map(d => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
</div>
)}
{frequency === 'monthly' && (
<div className="flex-1">
<label className={labelCls}>{t('agents.schedule.dayOfMonth')}</label>
<select
value={scheduledDay}
onChange={e => setScheduledDay(Number(e.target.value))}
className={selectCls}
>
{Array.from({ length: 31 }, (_, i) => i + 1).map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
)}
{/* Time picker */}
<div className="flex-1">
<label className={labelCls}>{t('agents.schedule.time')}</label>
<input
type="time"
value={scheduledTime}
onChange={e => setScheduledTime(e.target.value)}
className={inputCls}
/>
</div>
</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-6 pb-4 mt-auto border-t border-border/40 bg-card sticky bottom-0">
<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 shadow-sm"
>
{isSaving ? t('agents.form.saving') : agent ? t('agents.form.save') : t('agents.form.create')}
</button>
</div>
</form>
</div>
</div>
)
}