feat: redesign agents page (architectural-grid style), add image description, fix AI limits, remove dead code
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 53s
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)
This commit is contained in:
@@ -1,17 +1,13 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agents Page Client
|
||||
* Main client component for the agents page.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
||||
import { Plus, Bot, LayoutTemplate, Search, HelpCircle } from 'lucide-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 { AgentForm } from '@/components/agents/agent-form'
|
||||
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'
|
||||
@@ -21,8 +17,6 @@ import {
|
||||
getAgents,
|
||||
} from '@/app/actions/agent-actions'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface Notebook {
|
||||
id: string
|
||||
name: string
|
||||
@@ -37,16 +31,23 @@ interface AgentItem {
|
||||
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
|
||||
@@ -65,21 +66,18 @@ const typeFilterOptions = [
|
||||
{ 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 [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 [activeTab, setActiveTab] = useState<'dashboard' | 'templates'>('dashboard')
|
||||
|
||||
const refreshAgents = useCallback(async () => {
|
||||
try {
|
||||
@@ -139,15 +137,15 @@ export function AgentsPageClient({
|
||||
}, [])
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
setEditingAgent(null)
|
||||
setShowForm(true)
|
||||
setIsNewAgent(true)
|
||||
setSelectedAgent(null)
|
||||
}, [])
|
||||
|
||||
const handleEdit = useCallback((id: string) => {
|
||||
const agent = agents.find(a => a.id === id)
|
||||
if (agent) {
|
||||
setEditingAgent(agent)
|
||||
setShowForm(true)
|
||||
setIsNewAgent(false)
|
||||
setSelectedAgent(agent)
|
||||
}
|
||||
}, [agents])
|
||||
|
||||
@@ -172,17 +170,22 @@ export function AgentsPageClient({
|
||||
slideTheme: (formData.get('slideTheme') as string) || undefined,
|
||||
slideStyle: (formData.get('slideStyle') as string) || undefined,
|
||||
}
|
||||
if (editingAgent) {
|
||||
await updateAgent(editingAgent.id, data)
|
||||
if (selectedAgent && !isNewAgent) {
|
||||
await updateAgent(selectedAgent.id, data)
|
||||
toast.success(t('agents.toasts.updated'))
|
||||
} else {
|
||||
await createAgent(data)
|
||||
toast.success(t('agents.toasts.created'))
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingAgent(null)
|
||||
setSelectedAgent(null)
|
||||
setIsNewAgent(false)
|
||||
await refreshAgents()
|
||||
}, [editingAgent, refreshAgents, t])
|
||||
}, [selectedAgent, isNewAgent, refreshAgents, t])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setSelectedAgent(null)
|
||||
setIsNewAgent(false)
|
||||
}, [])
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
return agents.filter(agent => {
|
||||
@@ -198,110 +201,126 @@ export function AgentsPageClient({
|
||||
|
||||
const existingAgentNames = useMemo(() => agents.map(a => a.name), [agents])
|
||||
|
||||
const showDetail = selectedAgent !== null || isNewAgent
|
||||
|
||||
return (
|
||||
/* Full-bleed layout */
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
|
||||
{/* ── Top header bar — architectural grid style ── */}
|
||||
<header className="flex items-center justify-between px-12 py-10 border-b border-border/40 flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight">
|
||||
{t('agents.myAgents')}
|
||||
</h1>
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">
|
||||
{t('agents.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 px-6 py-3 text-[13px] font-medium uppercase tracking-[0.12em] border border-foreground text-foreground hover:bg-foreground hover:text-background transition-all"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* ── Scrollable content area ── */}
|
||||
<main className="flex-1 overflow-y-auto px-12 py-10">
|
||||
|
||||
{/* Dashboard tab - agents + templates */}
|
||||
{activeTab === 'dashboard' && (
|
||||
<>
|
||||
{agents.length > 0 && (
|
||||
<>
|
||||
{/* Filter pills + search */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-1 bg-muted/40 p-1 rounded-lg border border-border/40">
|
||||
{typeFilterOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setTypeFilter(opt.value)}
|
||||
className={`px-3 py-1.5 text-[12px] font-semibold rounded-md transition-all ${
|
||||
typeFilter === opt.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<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-primary/50 focus:ring-2 focus:ring-primary/10 transition-all placeholder:text-muted-foreground/40 w-56"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAgents.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3 mb-10">
|
||||
{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 mb-10">
|
||||
<Search className="w-10 h-10 text-muted-foreground/20 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{t('agents.noResults')}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{agents.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center bg-card rounded-xl border border-border/40 shadow-sm mb-10">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Templates always visible on dashboard */}
|
||||
<AgentTemplates onInstalled={refreshAgents} existingAgentNames={existingAgentNames} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Sliding panels */}
|
||||
{showForm && (
|
||||
<AgentForm
|
||||
agent={editingAgent}
|
||||
<>
|
||||
{showDetail ? (
|
||||
<AgentDetailView
|
||||
agent={isNewAgent ? null : selectedAgent}
|
||||
notebooks={notebooks}
|
||||
onSave={handleSave}
|
||||
onCancel={() => { setShowForm(false); setEditingAgent(null) }}
|
||||
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}
|
||||
@@ -312,6 +331,6 @@ export function AgentsPageClient({
|
||||
{showHelp && (
|
||||
<AgentHelp onClose={() => setShowHelp(false)} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,29 +5,23 @@ import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { updateUserSettings } from '@/app/actions/user-settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import { Palette, Type, LayoutGrid, Maximize2 } from 'lucide-react'
|
||||
import { Palette, Type } from 'lucide-react'
|
||||
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
||||
|
||||
interface AppearanceSettingsClientProps {
|
||||
initialFontSize: string
|
||||
initialTheme: string
|
||||
initialNotesViewMode: 'masonry' | 'tabs' | 'list'
|
||||
initialCardSizeMode?: 'variable' | 'uniform'
|
||||
initialFontFamily?: string
|
||||
}
|
||||
|
||||
export function AppearanceSettingsClient({
|
||||
initialFontSize,
|
||||
initialTheme,
|
||||
initialNotesViewMode,
|
||||
initialCardSizeMode = 'variable',
|
||||
initialFontFamily = 'inter',
|
||||
}: AppearanceSettingsClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [theme, setTheme] = useState<ThemeId>(normalizeThemeId(initialTheme || 'light'))
|
||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
||||
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs' | 'list'>(initialNotesViewMode)
|
||||
const [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode)
|
||||
const [fontFamily, setFontFamily] = useState(initialFontFamily)
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
@@ -47,21 +41,6 @@ export function AppearanceSettingsClient({
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleNotesViewChange = async (value: string) => {
|
||||
const mode = value === 'tabs' ? 'tabs' : value === 'list' ? 'list' : 'masonry'
|
||||
setNotesViewMode(mode)
|
||||
await updateAISettings({ notesViewMode: mode })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleCardSizeModeChange = async (value: string) => {
|
||||
const mode = value === 'uniform' ? 'uniform' : 'variable'
|
||||
setCardSizeMode(mode)
|
||||
localStorage.setItem('card-size-mode', mode)
|
||||
await updateUserSettings({ cardSizeMode: mode })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleFontFamilyChange = async (value: string) => {
|
||||
const font = value === 'system' ? 'system'
|
||||
: value === 'playfair' ? 'playfair'
|
||||
@@ -206,30 +185,7 @@ export function AppearanceSettingsClient({
|
||||
onChange={handleFontFamilyChange}
|
||||
/>
|
||||
|
||||
<SelectCard
|
||||
icon={LayoutGrid}
|
||||
title={t('appearance.notesViewLabel')}
|
||||
description={t('appearance.notesViewDescription')}
|
||||
value={notesViewMode}
|
||||
options={[
|
||||
{ value: 'masonry', label: t('appearance.notesViewMasonry') },
|
||||
{ value: 'list', label: t('appearance.notesViewList') },
|
||||
{ value: 'tabs', label: t('appearance.notesViewTabs') },
|
||||
]}
|
||||
onChange={handleNotesViewChange}
|
||||
/>
|
||||
|
||||
<SelectCard
|
||||
icon={Maximize2}
|
||||
title={t('settings.cardSizeMode')}
|
||||
description={t('settings.cardSizeModeDescription')}
|
||||
value={cardSizeMode}
|
||||
options={[
|
||||
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
||||
{ value: 'uniform', label: t('settings.cardSizeUniform') },
|
||||
]}
|
||||
onChange={handleCardSizeModeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -19,14 +19,6 @@ export default async function AppearanceSettingsPage() {
|
||||
<AppearanceSettingsClient
|
||||
initialFontSize={aiSettings.fontSize}
|
||||
initialTheme={userSettings.theme}
|
||||
initialNotesViewMode={
|
||||
aiSettings.notesViewMode === 'masonry'
|
||||
? 'masonry'
|
||||
: aiSettings.notesViewMode === 'list'
|
||||
? 'list'
|
||||
: 'tabs'
|
||||
}
|
||||
initialCardSizeMode={userSettings.cardSizeMode}
|
||||
initialFontFamily={aiSettings.fontFamily || 'inter'}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -287,6 +287,31 @@ export async function getAgentActions(agentId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAgentHistory(agentId: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Non autorise')
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await prisma.agent.findFirst({
|
||||
where: { id: agentId, userId: session.user.id },
|
||||
select: { id: true }
|
||||
})
|
||||
if (!agent) throw new Error('Agent non trouve')
|
||||
|
||||
await prisma.agentAction.deleteMany({
|
||||
where: { agentId }
|
||||
})
|
||||
|
||||
revalidatePath('/agents')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error deleting agent history:', error)
|
||||
throw new Error('Impossible de supprimer l\'historique')
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAgent(id: string, isEnabled: boolean) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
|
||||
@@ -27,9 +27,9 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
if (wordCount > 2000) {
|
||||
if (wordCount > 5000) {
|
||||
return NextResponse.json(
|
||||
{ errorKey: 'ai.wordCountMax', params: { max: 2000, current: wordCount } },
|
||||
{ errorKey: 'ai.wordCountMax', params: { max: 5000, current: wordCount } },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agent Card Component
|
||||
* Compact card matching the reference design — with a "Next Run / Status" footer.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
@@ -21,14 +16,13 @@ import {
|
||||
XCircle,
|
||||
Clock,
|
||||
Pencil,
|
||||
Activity,
|
||||
Presentation,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: {
|
||||
id: string
|
||||
@@ -50,19 +44,13 @@ interface AgentCardProps {
|
||||
onToggle: (id: string, isEnabled: boolean) => void
|
||||
}
|
||||
|
||||
// --- Config ---
|
||||
|
||||
/** Icône par type — tons neutres alignés sur le thème (encre / papier). */
|
||||
const ICON_BOX = 'bg-primary/10 dark:bg-primary/15'
|
||||
const ICON_MARK = 'text-primary'
|
||||
|
||||
const typeConfig: Record<string, { icon: typeof Globe; color: string; bgColor: string }> = {
|
||||
scraper: { icon: Globe, color: ICON_MARK, bgColor: ICON_BOX },
|
||||
researcher: { icon: Search, color: ICON_MARK, bgColor: ICON_BOX },
|
||||
monitor: { icon: Eye, color: ICON_MARK, bgColor: ICON_BOX },
|
||||
custom: { icon: Settings, color: ICON_MARK, bgColor: ICON_BOX },
|
||||
'slide-generator': { icon: Presentation, color: ICON_MARK, bgColor: ICON_BOX },
|
||||
'excalidraw-generator': { icon: Pencil, color: ICON_MARK, bgColor: ICON_BOX },
|
||||
const typeConfig: Record<string, { icon: typeof Globe }> = {
|
||||
scraper: { icon: Globe },
|
||||
researcher: { icon: Search },
|
||||
monitor: { icon: Eye },
|
||||
custom: { icon: Settings },
|
||||
'slide-generator': { icon: Presentation },
|
||||
'excalidraw-generator': { icon: Pencil },
|
||||
}
|
||||
|
||||
const frequencyKeys: Record<string, string> = {
|
||||
@@ -73,8 +61,6 @@ const frequencyKeys: Record<string, string> = {
|
||||
monthly: 'agents.frequencies.monthly',
|
||||
}
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
@@ -88,11 +74,8 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
const Icon = config.icon
|
||||
const lastAction = agent.actions[0]
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
const isNew = Date.now() - new Date(agent.createdAt).getTime() < 5 * 60 * 1000
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Cleanup polling on unmount
|
||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||||
|
||||
const handleRun = async () => {
|
||||
@@ -109,8 +92,6 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
setIsRunning(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Poll status every 3 s until terminal state
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
@@ -129,9 +110,8 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
toast.error(t('agents.toasts.runError', { error: data.error || t('agents.toasts.runFailed') }), { id: toastId })
|
||||
onRefresh()
|
||||
}
|
||||
} catch { /* network error — keep polling */ }
|
||||
} catch { /* keep polling */ }
|
||||
}, 3000)
|
||||
|
||||
} catch {
|
||||
toast.error(t('agents.toasts.runGenericError'), { id: toastId })
|
||||
setIsRunning(false)
|
||||
@@ -169,7 +149,6 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
}
|
||||
}
|
||||
|
||||
// Derive "Next Run" label
|
||||
const nextRunLabel = (() => {
|
||||
if (!agent.isEnabled) return '—'
|
||||
if (agent.frequency === 'manual') return t('agents.frequencies.manual')
|
||||
@@ -180,137 +159,116 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
return t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')
|
||||
})()
|
||||
|
||||
const statusLabel = lastAction
|
||||
? lastAction.status === 'success' ? t('agents.status.success')
|
||||
: lastAction.status === 'failure' ? t('agents.status.failure')
|
||||
: lastAction.status === 'running' ? t('agents.status.running')
|
||||
: t('agents.status.pending')
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
font-display group flex flex-col bg-card rounded-lg border transition-all duration-200
|
||||
${agent.isEnabled
|
||||
? 'border-border/40 hover:border-primary/25 hover:shadow-[0_2px_12px_color-mix(in_oklab,var(--foreground)_7%,transparent)]'
|
||||
: 'border-border/30 opacity-60'
|
||||
}
|
||||
`}>
|
||||
{/* Card body */}
|
||||
<div className="p-4 flex flex-col gap-3 flex-1">
|
||||
|
||||
{/* Header row: icon + name/type + toggle */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${config.bgColor}`}>
|
||||
<Icon className={`w-5 h-5 ${config.color}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="font-semibold text-sm text-card-foreground truncate leading-tight">{agent.name}</h3>
|
||||
{mounted && isNew && (
|
||||
<span className="flex-shrink-0 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider bg-muted text-muted-foreground rounded border border-border/60">
|
||||
{t('agents.newBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t(`agents.types.${agent.type || 'custom'}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
bg-card border border-border rounded-2xl p-6 space-y-6
|
||||
hover:border-foreground/20 transition-all group cursor-pointer
|
||||
shadow-sm relative overflow-hidden
|
||||
${!agent.isEnabled ? 'opacity-50' : ''}
|
||||
`}
|
||||
onClick={() => onEdit(agent.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-muted rounded-xl group-hover:bg-foreground group-hover:text-background transition-all">
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-foreground">{agent.name}</h4>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground opacity-60">
|
||||
{t(`agents.types.${agent.type || 'custom'}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={isToggling}
|
||||
className="flex-shrink-0 disabled:opacity-50"
|
||||
className="disabled:opacity-50"
|
||||
title={agent.isEnabled ? t('agents.actions.toggleOff') : t('agents.actions.toggleOn')}
|
||||
>
|
||||
<div className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
agent.isEnabled ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||
agent.isEnabled ? 'translate-x-4.5' : 'translate-x-0.5'
|
||||
}`} />
|
||||
<div className="relative inline-flex items-center cursor-pointer">
|
||||
<div className={`w-8 h-4 rounded-full transition-colors ${
|
||||
agent.isEnabled ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}>
|
||||
<span className={`absolute top-0.5 left-[2px] bg-background border border-muted-foreground/30 rounded-full h-3 w-3 transition-all ${
|
||||
agent.isEnabled ? 'translate-x-4' : ''
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{agent.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{agent.description}</p>
|
||||
)}
|
||||
|
||||
{/* Meta: frequency + executions */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{t('agents.metadata.executions', { count: agent._count.actions })}</span>
|
||||
{agent.notebook && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{(() => {
|
||||
const NbIcon = getNotebookIcon(agent.notebook!.icon)
|
||||
return <NbIcon className="w-3 h-3" />
|
||||
})()}
|
||||
{agent.notebook.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: Next Run + Last Status */}
|
||||
<div className="border-t border-border/30 grid grid-cols-2 divide-x divide-border/30">
|
||||
<div className="px-4 py-2.5">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-0.5">{t('agents.status.nextRun')}</p>
|
||||
<p className="text-xs font-semibold text-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-muted-foreground/60" />
|
||||
{nextRunLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-2.5">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-0.5">{t('agents.status.lastStatus')}</p>
|
||||
{lastAction ? (
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
|
||||
lastAction.status === 'success' ? 'text-primary' :
|
||||
lastAction.status === 'failure' ? 'text-destructive' :
|
||||
lastAction.status === 'running' ? 'text-primary' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
{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" />}
|
||||
{lastAction.status === 'success' && t('agents.status.success')}
|
||||
{lastAction.status === 'failure' && t('agents.status.failure')}
|
||||
{lastAction.status === 'running' && t('agents.status.running')}
|
||||
{lastAction.status === 'pending' && t('agents.status.pending')}
|
||||
{agent.description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{agent.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground font-medium">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
<span>{t('agents.metadata.executions', { count: agent._count.actions })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="uppercase tracking-tight">{t('agents.status.nextRun')}</span>
|
||||
<span className="text-foreground">{nextRunLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="uppercase tracking-tight">{t('agents.status.lastStatus')}</span>
|
||||
{lastAction ? (
|
||||
<span className={`flex items-center gap-1 ${
|
||||
lastAction.status === 'success' ? 'text-primary'
|
||||
: lastAction.status === 'failure' ? 'text-destructive'
|
||||
: lastAction.status === 'running' ? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
}`}>
|
||||
{lastAction.status === 'success' && <Activity className="w-2 h-2" />}
|
||||
{lastAction.status === 'failure' && <XCircle className="w-2 h-2" />}
|
||||
{lastAction.status === 'running' && <Loader2 className="w-2 h-2 animate-spin" />}
|
||||
{statusLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions row */}
|
||||
<div className="border-t border-border/30 flex items-center px-4 py-2 gap-2">
|
||||
<div className="grid grid-cols-3 gap-2 border-t border-border pt-4" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => onEdit(agent.id)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted-foreground bg-muted/40 hover:bg-muted/80 rounded-md transition-colors"
|
||||
className="py-2 border border-border rounded-lg hover:bg-muted flex items-center justify-center transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
{t('agents.actions.edit')}
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
<span className="ml-2 text-[10px] font-bold uppercase">{t('agents.actions.edit')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || !agent.isEnabled}
|
||||
className="p-1.5 text-primary bg-primary/10 rounded-md hover:bg-primary/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={t('agents.actions.run')}
|
||||
className="py-2 border border-border rounded-lg hover:bg-muted flex items-center justify-center transition-colors text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isRunning ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
|
||||
{isRunning ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5 fill-current" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="p-1.5 text-destructive bg-destructive/10 rounded-md hover:bg-destructive/20 transition-colors disabled:opacity-40"
|
||||
title={t('agents.actions.delete')}
|
||||
className="py-2 border border-border rounded-lg hover:bg-destructive/10 hover:text-destructive hover:border-destructive/20 flex items-center justify-center transition-colors text-muted-foreground disabled:opacity-40"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
900
memento-note/components/agents/agent-detail-view.tsx
Normal file
900
memento-note/components/agents/agent-detail-view.tsx
Normal file
@@ -0,0 +1,900 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||
import { motion } from 'motion/react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Trash2,
|
||||
Globe,
|
||||
FileSearch,
|
||||
FilePlus,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
Brain,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
HelpCircle,
|
||||
Mail,
|
||||
ImageIcon,
|
||||
Presentation,
|
||||
Pencil,
|
||||
Check,
|
||||
Eye,
|
||||
Search,
|
||||
Settings,
|
||||
Clock,
|
||||
Activity,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
BookOpen,
|
||||
LifeBuoy,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
|
||||
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, typeof Globe> = {
|
||||
scraper: Globe,
|
||||
researcher: Search,
|
||||
monitor: Eye,
|
||||
custom: Settings,
|
||||
'slide-generator': Presentation,
|
||||
'excalidraw-generator': Pencil,
|
||||
}
|
||||
|
||||
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'],
|
||||
'slide-generator': ['generate_pptx'],
|
||||
'excalidraw-generator': ['generate_excalidraw'],
|
||||
}
|
||||
|
||||
interface AgentDetailViewProps {
|
||||
agent?: {
|
||||
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
|
||||
tools?: string | null
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
scheduledTime?: string | null
|
||||
scheduledDay?: number | null
|
||||
timezone?: string | null
|
||||
slideTheme?: string | null
|
||||
slideStyle?: string | null
|
||||
lastRun: string | Date | null
|
||||
nextRun?: string | Date | null
|
||||
createdAt: string | Date
|
||||
_count: { actions: number }
|
||||
actions: { id: string; status: string; createdAt: string | Date }[]
|
||||
} | null
|
||||
notebooks: { id: string; name: string; icon?: string | null }[]
|
||||
onSave: (data: FormData) => Promise<void>
|
||||
onBack: () => void
|
||||
onOpenLogs: (agentId: string, agentName: string) => void
|
||||
onOpenHelp: () => void
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
export function AgentDetailView({
|
||||
agent,
|
||||
notebooks,
|
||||
onSave,
|
||||
onBack,
|
||||
onOpenLogs,
|
||||
onOpenHelp,
|
||||
isNew,
|
||||
}: AgentDetailViewProps) {
|
||||
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 [sourceNoteIds, setSourceNoteIds] = useState<string[]>(() => {
|
||||
if (agent?.sourceNoteIds) {
|
||||
try { return JSON.parse(agent.sourceNoteIds) } catch { return [] }
|
||||
}
|
||||
return []
|
||||
})
|
||||
const [noteOptions, setNoteOptions] = useState<{ id: string; title: string }[]>([])
|
||||
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 */ }
|
||||
}
|
||||
return TOOL_PRESETS[(agent?.type as AgentType) || 'scraper'] || []
|
||||
})
|
||||
const [maxSteps, setMaxSteps] = useState(agent?.maxSteps || 10)
|
||||
const [notifyEmail, setNotifyEmail] = useState(agent?.notifyEmail || false)
|
||||
const [includeImages, setIncludeImages] = useState(agent?.includeImages || false)
|
||||
const [slideTheme, setSlideTheme] = useState(agent?.slideTheme || '')
|
||||
const [slideStyle, setSlideStyle] = useState<'soft' | 'sharp' | 'rounded' | 'pill'>(
|
||||
(agent?.slideStyle as 'soft' | 'sharp' | 'rounded' | 'pill') || 'soft'
|
||||
)
|
||||
const [excalidrawStyle, setExcalidrawStyle] = useState<'default' | 'austere' | 'sketch-plus'>(() => {
|
||||
if (agent?.slideStyle === 'austere') return 'austere'
|
||||
if (agent?.slideStyle === 'sketch-plus') return 'sketch-plus'
|
||||
return 'default'
|
||||
})
|
||||
const [excalidrawType, setExcalidrawType] = useState<'auto' | 'architecture-cloud' | 'flowchart' | 'mindmap' | 'org-chart' | 'timeline' | 'process-map'>(() => {
|
||||
const value = (agent?.slideTheme || '').trim()
|
||||
if (value === 'auto' || value === 'architecture-cloud' || value === 'mindmap' || value === 'org-chart' || value === 'timeline' || value === 'process-map') return value
|
||||
return 'flowchart'
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [showAdvanced, setShowAdvanced] = useState(() => {
|
||||
if (agent?.tools) {
|
||||
try { const tools = JSON.parse(agent.tools); if (tools.length > 0) return true } catch { /* */ }
|
||||
}
|
||||
if (agent?.role && agent.role.trim().length > 0) return true
|
||||
return false
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceNotebookId || (type !== 'slide-generator' && type !== 'excalidraw-generator' && type !== 'monitor')) {
|
||||
setNoteOptions([])
|
||||
return
|
||||
}
|
||||
fetch(`/api/notes?notebookId=${sourceNotebookId}&limit=50`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const notes = Array.isArray(data.data) ? data.data : Array.isArray(data) ? data : []
|
||||
setNoteOptions(notes.map((n: any) => ({ id: n.id, title: n.title || 'Sans titre' })))
|
||||
})
|
||||
.catch(() => setNoteOptions([]))
|
||||
}, [sourceNotebookId, type])
|
||||
|
||||
const availableTools = useMemo(() => [
|
||||
{ id: 'web_search', icon: Globe, labelKey: 'agents.tools.webSearch' },
|
||||
{ id: 'web_scrape', icon: ExternalLink, labelKey: 'agents.tools.webScrape' },
|
||||
{ id: 'note_search', icon: FileSearch, labelKey: 'agents.tools.noteSearch' },
|
||||
{ id: 'note_read', icon: FileText, labelKey: 'agents.tools.noteRead' },
|
||||
{ id: 'note_create', icon: FilePlus, labelKey: 'agents.tools.noteCreate' },
|
||||
{ id: 'url_fetch', icon: ExternalLink, labelKey: 'agents.tools.urlFetch' },
|
||||
{ id: 'memory_search', icon: Brain, labelKey: 'agents.tools.memorySearch' },
|
||||
{ id: 'generate_pptx', icon: Presentation, labelKey: 'agents.tools.generatePptx' },
|
||||
{ id: 'generate_slides', icon: Presentation, labelKey: 'agents.tools.generateSlides' },
|
||||
{ id: 'generate_excalidraw', icon: Pencil, labelKey: 'agents.tools.generateExcalidraw' },
|
||||
], [])
|
||||
|
||||
const prevTypeRef = useRef(type)
|
||||
if (prevTypeRef.current !== type) {
|
||||
prevTypeRef.current = type
|
||||
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' || type === 'slide-generator' || type === 'excalidraw-generator') {
|
||||
formData.set('sourceNotebookId', sourceNotebookId)
|
||||
}
|
||||
if (sourceNoteIds.length > 0) {
|
||||
formData.set('sourceNoteIds', JSON.stringify(sourceNoteIds))
|
||||
}
|
||||
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)
|
||||
if (type === 'slide-generator') {
|
||||
if (slideTheme) formData.set('slideTheme', slideTheme)
|
||||
formData.set('slideStyle', slideStyle)
|
||||
}
|
||||
if (type === 'excalidraw-generator') {
|
||||
formData.set('slideTheme', excalidrawType)
|
||||
formData.set('slideStyle', excalidrawStyle)
|
||||
}
|
||||
await onSave(formData)
|
||||
} catch {
|
||||
toast.error(t('agents.toasts.saveError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showSourceNotebook = type === 'monitor' || type === 'slide-generator' || type === 'excalidraw-generator'
|
||||
const Icon = typeIcons[type] || Settings
|
||||
|
||||
const frequencyLabel = t(`agents.frequencies.${frequency}`) || frequency
|
||||
const successRate = agent?.actions?.length
|
||||
? Math.round((agent.actions.filter(a => a.status === 'success').length / agent.actions.length) * 100 * 10) / 10
|
||||
: null
|
||||
|
||||
const sectionTitleCls = 'text-xs font-bold uppercase tracking-[0.3em] text-muted-foreground'
|
||||
const cardCls = 'bg-card border border-border rounded-3xl overflow-hidden shadow-sm'
|
||||
const labelCls = 'block text-[11px] uppercase tracking-widest font-bold text-muted-foreground'
|
||||
const inputCls = 'w-full bg-muted/50 border border-border rounded-xl px-4 py-3 text-sm outline-none focus:ring-1 focus:ring-foreground/10 focus:border-foreground/20 transition-all text-foreground'
|
||||
const selectCls = 'w-full bg-muted/50 border border-border rounded-xl px-4 py-3 text-sm outline-none focus:ring-1 focus:ring-foreground/10 focus:border-foreground/20 transition-all cursor-pointer font-medium text-foreground appearance-none'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="min-h-full flex flex-col"
|
||||
>
|
||||
<header className="px-12 py-10 border-b border-border bg-background/80 backdrop-blur-md sticky top-0 z-30">
|
||||
<div className="flex items-center justify-between max-w-5xl mx-auto">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-3 text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('agents.form.back')}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onOpenHelp}
|
||||
className="p-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('agents.help.title')}
|
||||
>
|
||||
<LifeBuoy className="w-4 h-4" />
|
||||
</button>
|
||||
{!isNew && agent && (
|
||||
<button
|
||||
onClick={() => onOpenLogs(agent.id, agent.name)}
|
||||
className="px-5 py-2 text-xs font-bold uppercase tracking-widest border border-border rounded-xl hover:bg-muted transition-all"
|
||||
>
|
||||
{t('agents.runLog.title')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
form="agent-config-form"
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-6 py-2 bg-foreground text-background text-xs font-bold uppercase tracking-widest rounded-xl hover:opacity-90 transition-all shadow-lg shadow-foreground/10 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSaving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{isNew ? t('agents.form.create') : t('agents.form.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 px-12 py-16 max-w-5xl mx-auto w-full space-y-16">
|
||||
<section className="space-y-8">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-4 bg-foreground text-background rounded-2xl shadow-xl shadow-foreground/10">
|
||||
<Icon className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="text-3xl font-memento-serif font-medium text-foreground bg-transparent border-none outline-none placeholder:text-muted-foreground/40 w-full"
|
||||
placeholder={t('agents.form.namePlaceholder')}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-1 bg-muted text-muted-foreground rounded-md">
|
||||
{isNew ? t('agents.newBadge') : `ID: ${agent?.id?.slice(0, 8)}`}
|
||||
</span>
|
||||
{!isNew && agent?.isEnabled && (
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-1 bg-primary/10 text-primary rounded-md">
|
||||
{t('agents.actions.toggleOn')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isNew && agent && (
|
||||
<div className="flex items-center gap-8 text-[12px] uppercase tracking-[0.2em] font-bold text-muted-foreground">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="opacity-40">{t('agents.status.nextRun')}</span>
|
||||
<span className="text-foreground">{t('agents.metadata.executions', { count: agent._count.actions })}</span>
|
||||
</div>
|
||||
{successRate !== null && (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="opacity-40">Succès</span>
|
||||
<span className="text-primary">{successRate}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form id="agent-config-form" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
<div className="lg:col-span-2 space-y-12">
|
||||
|
||||
<section className="space-y-6">
|
||||
<h3 className={sectionTitleCls}>{t('agents.form.agentType')}<FieldHelp tooltip={t('agents.help.tooltips.agentType')} /></h3>
|
||||
<div className={cardCls}>
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ value: 'researcher' as AgentType, labelKey: 'agents.types.researcher', descKey: 'agents.typeDescriptions.researcher', icon: Search },
|
||||
{ value: 'scraper' as AgentType, labelKey: 'agents.types.scraper', descKey: 'agents.typeDescriptions.scraper', icon: Globe },
|
||||
{ value: 'monitor' as AgentType, labelKey: 'agents.types.monitor', descKey: 'agents.typeDescriptions.monitor', icon: Eye },
|
||||
{ value: 'custom' as AgentType, labelKey: 'agents.types.custom', descKey: 'agents.typeDescriptions.custom', icon: Settings },
|
||||
{ value: 'slide-generator' as AgentType, labelKey: 'agents.types.slideGenerator', descKey: 'agents.typeDescriptions.slideGenerator', icon: Presentation },
|
||||
{ value: 'excalidraw-generator' as AgentType, labelKey: 'agents.types.excalidrawGenerator', descKey: 'agents.typeDescriptions.excalidrawGenerator', icon: Pencil },
|
||||
].map(at => {
|
||||
const TypeIcon = at.icon
|
||||
return (
|
||||
<button
|
||||
key={at.value}
|
||||
type="button"
|
||||
onClick={() => setType(at.value)}
|
||||
className={`p-4 rounded-2xl flex items-center justify-between transition-all cursor-pointer ${
|
||||
type === at.value
|
||||
? 'border-2 border-foreground bg-muted'
|
||||
: 'border border-border hover:border-foreground/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<TypeIcon className={`w-4 h-4 ${type === at.value ? 'text-foreground' : 'text-muted-foreground'}`} />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-medium text-foreground">{t(at.labelKey)}</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">{t(at.descKey)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${
|
||||
type === at.value
|
||||
? 'border-4 border-foreground'
|
||||
: 'border border-border'
|
||||
}`}>
|
||||
{type === at.value && <div className="w-2 h-2 bg-foreground rounded-full" />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<h3 className={sectionTitleCls}>{t('agents.form.configuration')}<FieldHelp tooltip={t('agents.help.tooltips.instructions')} /></h3>
|
||||
<div className={cardCls}>
|
||||
<div className="p-8 space-y-8">
|
||||
|
||||
{type === 'researcher' ? (
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{(type === 'scraper' || type === 'custom') && (
|
||||
<div className="space-y-4">
|
||||
<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-destructive/80 hover:text-destructive hover:bg-destructive/10 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-foreground hover:opacity-60 font-medium"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('agents.form.addUrl')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSourceNotebook && (
|
||||
<div className="space-y-4">
|
||||
<label className={labelCls}>{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
|
||||
<select
|
||||
value={sourceNotebookId}
|
||||
onChange={e => { setSourceNotebookId(e.target.value); setSourceNoteIds([]) }}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="">{t('agents.form.selectNotebook')}</option>
|
||||
{notebooks.map(nb => (
|
||||
<option key={nb.id} value={nb.id}>{nb.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(type === 'slide-generator' || type === 'excalidraw-generator') && sourceNotebookId && noteOptions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<label className={labelCls}>{t('agents.form.selectNotes')}<FieldHelp tooltip={t('agents.help.tooltips.selectNotes')} /></label>
|
||||
<div className="border border-border rounded-xl max-h-48 overflow-y-auto bg-muted/30">
|
||||
{noteOptions.map(note => {
|
||||
const isSelected = sourceNoteIds.includes(note.id)
|
||||
return (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSourceNoteIds(prev =>
|
||||
isSelected ? prev.filter(id => id !== note.id) : [...prev, note.id]
|
||||
)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-4 py-2.5 text-sm text-left hover:bg-muted/50 transition-colors ${isSelected ? 'bg-foreground/5' : ''}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 ${isSelected ? 'border-foreground bg-foreground' : 'border-border'}`}>
|
||||
{isSelected && <Check className="w-3 h-3 text-background" />}
|
||||
</div>
|
||||
<span className={isSelected ? 'text-foreground font-medium' : 'text-foreground'}>{note.title}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{sourceNoteIds.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t('agents.form.notesSelected', { count: sourceNoteIds.length })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'slide-generator' && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<label className={labelCls}>{t('agents.form.slideTheme')}<FieldHelp tooltip={t('agents.help.tooltips.slideTheme')} /></label>
|
||||
<select value={slideTheme} onChange={e => setSlideTheme(e.target.value)} className={selectCls}>
|
||||
<option value="">{t('agents.form.slideThemeDefault')}</option>
|
||||
<option value="modern_wellness">Modern & Wellness</option>
|
||||
<option value="business_authority">Business & Authority</option>
|
||||
<option value="nature_outdoors">Nature & Outdoors</option>
|
||||
<option value="vintage_academic">Vintage & Academic</option>
|
||||
<option value="soft_creative">Soft & Creative</option>
|
||||
<option value="bohemian">Bohemian</option>
|
||||
<option value="vibrant_tech">Vibrant & Tech</option>
|
||||
<option value="craft_artisan">Craft & Artisan</option>
|
||||
<option value="tech_night">Tech & Night (dark)</option>
|
||||
<option value="education_charts">Education & Charts</option>
|
||||
<option value="forest_eco">Forest & Eco</option>
|
||||
<option value="elegant_fashion">Elegant & Fashion</option>
|
||||
<option value="art_food">Art & Food</option>
|
||||
<option value="luxury_mystery">Luxury & Mystery</option>
|
||||
<option value="pure_tech_blue">Pure Tech Blue</option>
|
||||
<option value="coastal_coral">Coastal Coral</option>
|
||||
<option value="vibrant_orange_mint">Vibrant Orange Mint</option>
|
||||
<option value="platinum_white_gold">Platinum White Gold</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className={labelCls}>{t('agents.form.slideStyle')}<FieldHelp tooltip={t('agents.help.tooltips.slideStyle')} /></label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{(['soft', 'sharp', 'rounded', 'pill'] as const).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSlideStyle(s)}
|
||||
className={`p-4 rounded-2xl flex items-center justify-between transition-all cursor-pointer ${
|
||||
slideStyle === s
|
||||
? 'border-2 border-foreground bg-muted'
|
||||
: 'border border-border hover:border-foreground/20'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm font-medium ${slideStyle === s ? 'text-foreground' : 'text-muted-foreground'}`}>
|
||||
{t(`agents.form.slideStyle${s.charAt(0).toUpperCase() + s.slice(1)}` as any)}
|
||||
</span>
|
||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${slideStyle === s ? 'border-4 border-foreground' : 'border border-border'}`}>
|
||||
{slideStyle === s && <div className="w-2 h-2 bg-foreground rounded-full" />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'excalidraw-generator' && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<label className={labelCls}>{t('agents.form.excalidrawDiagramType')}</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{([
|
||||
{ id: 'auto', labelKey: 'agents.form.excalidrawDiagramTypeAuto' },
|
||||
{ id: 'flowchart', labelKey: 'agents.form.excalidrawDiagramTypeFlowchart' },
|
||||
{ id: 'mindmap', labelKey: 'agents.form.excalidrawDiagramTypeMindmap' },
|
||||
{ id: 'org-chart', labelKey: 'agents.form.excalidrawDiagramTypeOrgChart' },
|
||||
{ id: 'timeline', labelKey: 'agents.form.excalidrawDiagramTypeTimeline' },
|
||||
{ id: 'process-map', labelKey: 'agents.form.excalidrawDiagramTypeProcessMap' },
|
||||
{ id: 'architecture-cloud', labelKey: 'agents.form.excalidrawDiagramTypeArchitectureCloud' },
|
||||
] as const).map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setExcalidrawType(opt.id)}
|
||||
className={`p-3 rounded-xl text-sm text-left transition-all ${
|
||||
excalidrawType === opt.id
|
||||
? 'border-2 border-foreground bg-muted text-foreground font-medium'
|
||||
: 'border border-border text-muted-foreground hover:border-foreground/20'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className={labelCls}>{t('agents.form.excalidrawDiagramStyle')}</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{([
|
||||
{ id: 'default', labelKey: 'agents.form.excalidrawDiagramStyleDefault' },
|
||||
{ id: 'sketch-plus', labelKey: 'agents.form.excalidrawDiagramStyleSketchPlus' },
|
||||
{ id: 'austere', labelKey: 'agents.form.excalidrawDiagramStyleAustere' },
|
||||
] as const).map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setExcalidrawStyle(opt.id)}
|
||||
className={`p-3 rounded-xl text-sm text-left transition-all ${
|
||||
excalidrawStyle === opt.id
|
||||
? 'border-2 border-foreground bg-muted text-foreground font-medium'
|
||||
: 'border border-border text-muted-foreground hover:border-foreground/20'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className={labelCls}>{t('agents.form.instructions')}<FieldHelp tooltip={t('agents.help.tooltips.instructions')} /></label>
|
||||
<textarea
|
||||
value={role}
|
||||
onChange={e => setRole(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full bg-muted/50 border border-border rounded-2xl p-6 text-sm outline-none focus:ring-1 focus:ring-foreground/10 focus:border-foreground/20 transition-all font-light leading-relaxed resize-none text-foreground"
|
||||
placeholder={t('agents.form.instructionsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
|
||||
<section className="space-y-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground font-bold uppercase tracking-widest w-full pt-2"
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{t('agents.form.advancedMode')}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className={cardCls}>
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="space-y-4">
|
||||
<label className={labelCls}>{t('agents.tools.title')}<FieldHelp tooltip={t('agents.help.tooltips.tools')} /></label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{availableTools.map(at => {
|
||||
const ToolIcon = 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.5 px-4 py-3 rounded-xl text-sm transition-all text-left ${
|
||||
isSelected
|
||||
? 'border-2 border-foreground bg-muted text-foreground font-medium'
|
||||
: 'border border-border text-muted-foreground hover:border-foreground/20'
|
||||
}`}
|
||||
>
|
||||
<ToolIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t(at.labelKey)}</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>
|
||||
|
||||
{selectedTools.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<label className={labelCls}>
|
||||
{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={50}
|
||||
value={maxSteps}
|
||||
onChange={e => setMaxSteps(Number(e.target.value))}
|
||||
className="w-full accent-foreground"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>3</span>
|
||||
<span>50</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-10">
|
||||
<section className="space-y-6">
|
||||
<h3 className={sectionTitleCls}>{t('agents.form.frequency')}<FieldHelp tooltip={t('agents.help.tooltips.frequency')} /></h3>
|
||||
<div className="bg-foreground rounded-3xl p-8 space-y-8 text-background shadow-2xl shadow-foreground/20 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10">
|
||||
<Clock className="w-24 h-24" />
|
||||
</div>
|
||||
<div className="relative space-y-6">
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] uppercase tracking-widest font-bold opacity-60">{t('agents.form.frequency')}</span>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={e => setFrequency(e.target.value)}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-xl px-4 py-3 text-lg font-memento-serif italic text-background outline-none cursor-pointer appearance-none"
|
||||
>
|
||||
<option value="manual" className="text-foreground">{t('agents.frequencies.manual')}</option>
|
||||
<option value="hourly" className="text-foreground">{t('agents.frequencies.hourly')}</option>
|
||||
<option value="daily" className="text-foreground">{t('agents.frequencies.daily')}</option>
|
||||
<option value="weekly" className="text-foreground">{t('agents.frequencies.weekly')}</option>
|
||||
<option value="monthly" className="text-foreground">{t('agents.frequencies.monthly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{frequency !== 'manual' && frequency !== 'hourly' && (
|
||||
<>
|
||||
<div className="h-px bg-white/10" />
|
||||
<div className="space-y-4">
|
||||
{frequency === 'weekly' && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] uppercase tracking-widest font-bold opacity-60">{t('agents.schedule.dayOfWeek')}</span>
|
||||
<select
|
||||
value={scheduledDay}
|
||||
onChange={e => setScheduledDay(Number(e.target.value))}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-xl px-4 py-3 text-sm font-bold text-background outline-none cursor-pointer appearance-none"
|
||||
>
|
||||
{[
|
||||
{ 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} className="text-foreground">{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{frequency === 'monthly' && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] uppercase tracking-widest font-bold opacity-60">{t('agents.schedule.dayOfMonth')}</span>
|
||||
<select
|
||||
value={scheduledDay}
|
||||
onChange={e => setScheduledDay(Number(e.target.value))}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-xl px-4 py-3 text-sm font-bold text-background outline-none cursor-pointer appearance-none"
|
||||
>
|
||||
{Array.from({ length: 31 }, (_, i) => i + 1).map(d => (
|
||||
<option key={d} value={d} className="text-foreground">{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] uppercase tracking-widest font-bold opacity-60">{t('agents.schedule.time')}</span>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduledTime}
|
||||
onChange={e => setScheduledTime(e.target.value)}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-xl px-4 py-3 text-sm font-bold text-background outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<h3 className={sectionTitleCls}>{t('agents.form.options')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></h3>
|
||||
<div className="space-y-3">
|
||||
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
|
||||
<div
|
||||
onClick={() => setNotifyEmail(!notifyEmail)}
|
||||
className={`flex items-center justify-between p-4 rounded-2xl border cursor-pointer transition-all ${
|
||||
notifyEmail ? 'bg-muted border-foreground/20' : 'bg-card border-border/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className={`w-4 h-4 ${notifyEmail ? 'text-foreground' : 'text-muted-foreground'}`} />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-foreground">{t('agents.form.notifyEmail')}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t('agents.form.notifyEmailHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`relative inline-flex items-center`}>
|
||||
<div className={`w-8 h-4 rounded-full transition-colors ${notifyEmail ? 'bg-foreground' : 'bg-muted-foreground/30'}`}>
|
||||
<span className={`absolute top-0.5 left-[2px] bg-background border border-border rounded-full h-3 w-3 transition-all ${notifyEmail ? 'translate-x-4' : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type !== 'slide-generator' && type !== 'excalidraw-generator' && (
|
||||
<div
|
||||
onClick={() => setIncludeImages(!includeImages)}
|
||||
className={`flex items-center justify-between p-4 rounded-2xl border cursor-pointer transition-all ${
|
||||
includeImages ? 'bg-muted border-foreground/20' : 'bg-card border-border/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageIcon className={`w-4 h-4 ${includeImages ? 'text-foreground' : 'text-muted-foreground'}`} />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-foreground">{t('agents.form.includeImages')}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t('agents.form.includeImagesHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`relative inline-flex items-center`}>
|
||||
<div className={`w-8 h-4 rounded-full transition-colors ${includeImages ? 'bg-foreground' : 'bg-muted-foreground/30'}`}>
|
||||
<span className={`absolute top-0.5 left-[2px] bg-background border border-border rounded-full h-3 w-3 transition-all ${includeImages ? 'translate-x-4' : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!isNew && (
|
||||
<section className="pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!agent || !confirm(t('agents.actions.deleteConfirm', { name: agent.name }))) return
|
||||
try {
|
||||
const { deleteAgent } = await import('@/app/actions/agent-actions')
|
||||
await deleteAgent(agent.id)
|
||||
toast.success(t('agents.toasts.deleted', { name: agent.name }))
|
||||
onBack()
|
||||
} catch {
|
||||
toast.error(t('agents.toasts.deleteError'))
|
||||
}
|
||||
}}
|
||||
className="w-full py-4 border border-destructive/30 text-destructive bg-destructive/5 rounded-2xl text-xs font-bold uppercase tracking-widest hover:bg-destructive/10 transition-colors flex items-center justify-center gap-3"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('agents.actions.delete')}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
'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 { X, CheckCircle2, XCircle, Loader2, Clock, ChevronDown, Wrench, Trash2 } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface AgentRunLogProps {
|
||||
@@ -47,22 +43,38 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [actions, setActions] = useState<Action[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
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)
|
||||
}
|
||||
const loadActions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { getAgentActions } = await import('@/app/actions/agent-actions')
|
||||
const data = await getAgentActions(agentId)
|
||||
setActions(data)
|
||||
} catch {
|
||||
// Silent fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [agentId])
|
||||
}
|
||||
|
||||
useEffect(() => { loadActions() }, [agentId])
|
||||
|
||||
const handleClearHistory = async () => {
|
||||
if (!confirm(t('agents.runLog.clearConfirm'))) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const { deleteAgentHistory } = await import('@/app/actions/agent-actions')
|
||||
await deleteAgentHistory(agentId)
|
||||
setActions([])
|
||||
toast.success(t('agents.runLog.cleared'))
|
||||
} catch {
|
||||
toast.error(t('agents.toasts.deleteError'))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/20 flex justify-end z-50" onClick={onClose}>
|
||||
@@ -70,18 +82,28 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
||||
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 */}
|
||||
<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 className="flex items-center gap-2">
|
||||
{actions.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearHistory}
|
||||
disabled={isDeleting}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
title={t('agents.runLog.clearHistory')}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="p-1 rounded-md hover:bg-accent">
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
@@ -134,7 +156,6 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
||||
</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">
|
||||
@@ -152,7 +173,7 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
||||
<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)}
|
||||
{JSON.stringify(tc.args ?? {}).substring(0, 80)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agent Templates Gallery
|
||||
* Pre-built agent configurations that users can install in one click.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Globe,
|
||||
@@ -58,8 +53,6 @@ const typeIcons: Record<string, typeof Globe> = {
|
||||
'excalidraw-generator': Pencil,
|
||||
}
|
||||
|
||||
const templateIconBox = 'bg-primary/10 text-primary dark:bg-primary/15'
|
||||
|
||||
export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) {
|
||||
const { t } = useLanguage()
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
@@ -106,50 +99,43 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground 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`
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{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-border/70 rounded-xl p-4 hover:border-primary/35 hover:bg-primary/[0.03] transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<div className={`p-1.5 rounded-lg ${templateIconBox}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<h4 className="font-medium text-sm text-foreground">{t(nameKey)}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground 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>
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className="bg-card/40 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-card hover:border-foreground/20 transition-all"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center text-muted-foreground group-hover:bg-foreground group-hover:text-background mb-4 transition-all">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<h4 className="text-[13px] font-bold text-foreground mb-2">{t(nameKey)}</h4>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-4">{t(descKey)}</p>
|
||||
<button
|
||||
onClick={() => handleInstall(tpl)}
|
||||
disabled={isInstalling}
|
||||
className="text-[11px] font-bold uppercase tracking-widest text-foreground hover:opacity-60 transition-opacity flex items-center gap-2 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
|
||||
GitMerge, PlusCircle, Eye, Code, Languages,
|
||||
Presentation, PenTool, ExternalLink, ImagePlus,
|
||||
ChevronRight, MessageSquare, History, Scissors, Zap, Layout, ArrowRightLeft,
|
||||
ChevronRight, MessageSquare, History, Scissors, Zap, Layout, ArrowRightLeft, Copy, CheckCircle,
|
||||
} from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
|
||||
@@ -120,6 +120,40 @@ interface ContextualAIChatProps {
|
||||
onGenerateTitle?: () => void
|
||||
}
|
||||
|
||||
function CopyPreviewButton({ text }: { text: string }) {
|
||||
const { t } = useLanguage()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = () => {
|
||||
if (!text) return
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
ta.style.top = '-9999px'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.setSelectionRange(0, ta.value.length)
|
||||
let ok = false
|
||||
try { ok = document.execCommand('copy') } catch {}
|
||||
document.body.removeChild(ta)
|
||||
if (!ok) {
|
||||
try { navigator.clipboard.writeText(text) } catch {}
|
||||
}
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex-1 py-3.5 border border-border rounded-xl text-[10px] font-bold uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-muted transition-all"
|
||||
>
|
||||
{copied ? <CheckCircle size={14} className="text-primary" /> : <Copy size={14} />}
|
||||
{copied ? t('ai.copied') : t('ai.copy')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ContextualAIChat({
|
||||
@@ -561,8 +595,9 @@ export function ContextualAIChat({
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 border-t border-border flex gap-3 shrink-0">
|
||||
<button onClick={handleDiscardPreview} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-foreground/40 hover:text-foreground transition-all">ANNULER</button>
|
||||
<button onClick={handleApplyPreview} className="flex-1 py-3.5 bg-foreground text-background rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg transition-all hover:opacity-90">APPLIQUER À LA NOTE</button>
|
||||
<button onClick={handleDiscardPreview} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-foreground/40 hover:text-foreground transition-all">{t('ai.cancel')}</button>
|
||||
<CopyPreviewButton text={actionPreview.text} />
|
||||
<button onClick={handleApplyPreview} className="flex-1 py-3.5 bg-foreground text-background rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg transition-all hover:opacity-90">{t('ai.applyToNote')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -583,8 +618,9 @@ export function ContextualAIChat({
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 border-t border-border flex gap-3 shrink-0">
|
||||
<button onClick={() => setResourcePreview(null)} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-foreground/40 hover:text-foreground transition-all">ANNULER</button>
|
||||
<button onClick={handleApplyResourcePreview} className="flex-1 py-3.5 bg-memento-blue text-white rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg shadow-memento-blue/20 transition-all hover:opacity-90">APPLIQUER À LA NOTE</button>
|
||||
<button onClick={() => setResourcePreview(null)} className="flex-1 py-3.5 text-[10px] font-bold uppercase tracking-widest text-foreground/40 hover:text-foreground transition-all">{t('ai.cancel')}</button>
|
||||
<CopyPreviewButton text={resourcePreview.text} />
|
||||
<button onClick={handleApplyResourcePreview} className="flex-1 py-3.5 bg-memento-blue text-white rounded-xl text-[10px] font-bold uppercase tracking-widest shadow-lg shadow-memento-blue/20 transition-all hover:opacity-90">{t('ai.applyToNote')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -739,7 +775,7 @@ export function ContextualAIChat({
|
||||
</motion.button>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{ACTION_IDS.filter(a => a.id !== 'markdown' && a.id !== 'describe-images').map((action, i) => {
|
||||
{ACTION_IDS.filter(a => a.id !== 'markdown').map((action, i) => {
|
||||
const loading = actionLoading === action.id
|
||||
const isActive = action.id === 'translate' && showLangPicker
|
||||
const Icon = action.icon
|
||||
|
||||
@@ -65,7 +65,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
|
||||
initialNotes.filter(n => n.isPinned)
|
||||
)
|
||||
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
|
||||
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode === 'list' ? 'masonry' : initialSettings.notesViewMode)
|
||||
const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode)
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
@@ -6,8 +6,7 @@ import { getNoteFeedImage, getNotePlainExcerpt, getNoteDisplayTitle } from '@/li
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen, StickyNote } from 'lucide-react'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen } from 'lucide-react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
|
||||
@@ -147,18 +146,15 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-52">
|
||||
<DropdownMenuItem onClick={e => { e.stopPropagation(); handleMoveToNotebook(null) }}>
|
||||
<StickyNote className="h-4 w-4 mr-2 text-foreground/50" />
|
||||
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold mr-2 shrink-0">N</span>
|
||||
{t('notebookSuggestion.generalNotes') || 'Notes générales'}
|
||||
</DropdownMenuItem>
|
||||
{notebooks.map((nb: any) => {
|
||||
const NotebookIcon = getNotebookIcon(nb.icon || 'folder')
|
||||
return (
|
||||
<DropdownMenuItem key={nb.id} onClick={e => { e.stopPropagation(); handleMoveToNotebook(nb.id) }}>
|
||||
<NotebookIcon className="h-4 w-4 mr-2 text-foreground/50" />
|
||||
{nb.name}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
{notebooks.map((nb: any) => (
|
||||
<DropdownMenuItem key={nb.id} onClick={e => { e.stopPropagation(); handleMoveToNotebook(nb.id) }}>
|
||||
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold mr-2 shrink-0">{nb.name.charAt(0).toUpperCase()}</span>
|
||||
{nb.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Note } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow, Locale } from 'date-fns'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Users, FileText, ImageIcon } from 'lucide-react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
const localeMap: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
fr,
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function firstImageFromContent(html: string): string | null {
|
||||
const m = html.match(/<img[^>]+src=["']([^"']+)["']/i)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
function previewText(note: Note): string {
|
||||
if (note.type === 'richtext') {
|
||||
return stripHtml(note.content || '').slice(0, 280)
|
||||
}
|
||||
if (note.type === 'markdown') {
|
||||
return (note.content || '')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/[*`_~]/g, '')
|
||||
.replace(/\[(.*?)\]\([^)]*\)/g, '$1')
|
||||
.slice(0, 280)
|
||||
}
|
||||
if (note.type === 'checklist') {
|
||||
const items = (note.checkItems || []).map((i) => i.text).join(' · ')
|
||||
return items.slice(0, 280)
|
||||
}
|
||||
return (note.content || '').slice(0, 280)
|
||||
}
|
||||
|
||||
function thumbUrl(note: Note): string | null {
|
||||
if (note.images?.length) return note.images[0]!
|
||||
if (note.type === 'richtext' && note.content) {
|
||||
return firstImageFromContent(note.content)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface NotesListViewProps {
|
||||
notes: Note[]
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
}
|
||||
|
||||
export function NotesListView({
|
||||
notes,
|
||||
onEdit,
|
||||
}: NotesListViewProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
const currentUserId = session?.user?.id
|
||||
const locale = localeMap[language] || enUS
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...notes].sort(
|
||||
(a, b) =>
|
||||
new Date(b.contentUpdatedAt || b.updatedAt).getTime() -
|
||||
new Date(a.contentUpdatedAt || a.updatedAt).getTime()
|
||||
),
|
||||
[notes]
|
||||
)
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground" data-testid="notes-list-empty">
|
||||
{t('notes.emptyState')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3" data-testid="notes-list-view">
|
||||
{sorted.map((note) => {
|
||||
const thumb = thumbUrl(note)
|
||||
const preview = previewText(note)
|
||||
const title = note.title?.trim() || t('notes.untitled')
|
||||
const edited = new Date(note.contentUpdatedAt || note.updatedAt)
|
||||
const sharedCount = note.sharedWith?.length ?? 0
|
||||
const isShared = sharedCount > 0 || (note as any)._isShared
|
||||
|
||||
const isSharedNote = !!(currentUserId && note.userId && currentUserId !== note.userId)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onEdit?.(note, !!isSharedNote)}
|
||||
className={cn(
|
||||
'group flex w-full gap-5 rounded-2xl border border-border/50 bg-card/90 p-4 text-start shadow-sm transition-all duration-200',
|
||||
'hover:border-primary/25 hover:shadow-[0_8px_30px_-12px_color-mix(in_oklab, var(--foreground) 14%, transparent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
|
||||
'md:gap-6 md:p-5'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative size-[4.5rem] shrink-0 overflow-hidden rounded-xl border border-border/40 bg-muted/40 md:size-24"
|
||||
aria-hidden
|
||||
>
|
||||
{thumb ? (
|
||||
<img src={thumb} alt="" className="h-full w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground/50">
|
||||
<FileText className="size-8 stroke-[1.25]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 gap-y-1">
|
||||
<h3 className="font-memento-serif text-lg font-normal leading-snug tracking-tight text-foreground md:text-xl">
|
||||
{title}
|
||||
</h3>
|
||||
<time
|
||||
dateTime={edited.toISOString()}
|
||||
className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-muted-foreground tabular-nums"
|
||||
>
|
||||
{formatDistanceToNow(edited, { addSuffix: true, locale })}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<p className="line-clamp-2 text-sm leading-relaxed text-muted-foreground md:line-clamp-3">{preview}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
{(note.labels?.length ?? 0) > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{note.labels!.slice(0, 6).map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="rounded-md border border-border/60 bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-foreground/80"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
{note.labels!.length > 6 && (
|
||||
<span className="text-[11px] text-muted-foreground">+{note.labels!.length - 6}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isShared && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-primary">
|
||||
<Users className="size-3.5 opacity-80" />
|
||||
{sharedCount > 0 ? `${sharedCount}` : t('notes.sharedShort') || 'Partagé'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Note } from '@/lib/types'
|
||||
import { NotesTabsView } from '@/components/notes-tabs-view'
|
||||
import { NotesListView } from '@/components/notes-list-view'
|
||||
|
||||
const MasonryGridLazy = dynamic(
|
||||
() => import('@/components/masonry-grid').then((m) => m.MasonryGrid),
|
||||
@@ -18,7 +17,7 @@ const MasonryGridLazy = dynamic(
|
||||
}
|
||||
)
|
||||
|
||||
export type NotesViewMode = 'masonry' | 'tabs' | 'list'
|
||||
export type NotesViewMode = 'masonry' | 'tabs'
|
||||
|
||||
interface NotesMainSectionProps {
|
||||
notes: Note[]
|
||||
@@ -43,14 +42,6 @@ export function NotesMainSection({
|
||||
onEnableHistory,
|
||||
onNoteCreated,
|
||||
}: NotesMainSectionProps) {
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<div data-testid="notes-list">
|
||||
<NotesListView notes={notes} onEdit={onEdit} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (viewMode === 'tabs') {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTransition } from 'react'
|
||||
import { LayoutGrid, PanelsTopLeft, List } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { NotesViewMode } from '@/components/notes-main-section'
|
||||
|
||||
interface NotesViewToggleProps {
|
||||
mode: NotesViewMode
|
||||
onModeChange: (mode: NotesViewMode) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NotesViewToggle({ mode, onModeChange, className }: NotesViewToggleProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
const setMode = (next: NotesViewMode) => {
|
||||
if (next === mode) return
|
||||
const previous = mode
|
||||
onModeChange(next)
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateAISettings({ notesViewMode: next })
|
||||
} catch {
|
||||
onModeChange(previous)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div
|
||||
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
|
||||
className={cn(
|
||||
'inline-flex rounded-full border border-border bg-muted/40 p-0.5 shadow-sm',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
aria-label={t('notes.viewModeGroup')}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
className={cn(
|
||||
'h-9 rounded-full px-3 gap-1.5',
|
||||
mode === 'masonry' && 'bg-background shadow-sm text-foreground'
|
||||
)}
|
||||
onClick={() => setMode('masonry')}
|
||||
aria-pressed={mode === 'masonry'}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" aria-hidden />
|
||||
<span className="hidden sm:inline text-xs font-medium">{t('notes.viewCards')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('notes.viewCardsTooltip')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
className={cn(
|
||||
'h-9 rounded-full px-3 gap-1.5',
|
||||
mode === 'list' && 'bg-background shadow-sm text-foreground'
|
||||
)}
|
||||
onClick={() => setMode('list')}
|
||||
aria-pressed={mode === 'list'}
|
||||
>
|
||||
<List className="h-4 w-4" aria-hidden />
|
||||
<span className="hidden sm:inline text-xs font-medium">{t('notes.viewList')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('notes.viewListTooltip')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
className={cn(
|
||||
'h-9 rounded-full px-3 gap-1.5',
|
||||
mode === 'tabs' && 'bg-background shadow-sm text-foreground'
|
||||
)}
|
||||
onClick={() => setMode('tabs')}
|
||||
aria-pressed={mode === 'tabs'}
|
||||
>
|
||||
<PanelsTopLeft className="h-4 w-4" aria-hidden />
|
||||
<span className="hidden sm:inline text-xs font-medium">{t('notes.viewTabs')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('notes.viewTabsTooltip')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useTransition, useOptimistic } from 'react'
|
||||
import { Note } from '@/lib/types'
|
||||
import { Clock, Pin, FolderOpen, Trash2, Folder, X } from 'lucide-react'
|
||||
import { Clock, Pin, FolderOpen, Trash2, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Button } from './ui/button'
|
||||
@@ -12,7 +12,6 @@ import { useRouter } from 'next/navigation'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { toast } from 'sonner'
|
||||
import { StickyNote } from 'lucide-react'
|
||||
|
||||
interface RecentNotesSectionProps {
|
||||
recentNotes: Note[]
|
||||
@@ -196,7 +195,7 @@ function CompactCard({
|
||||
{t('notebookSuggestion.moveToNotebook')}
|
||||
</div>
|
||||
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
|
||||
<StickyNote className="h-4 w-4 mr-2" />
|
||||
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold mr-2 shrink-0">N</span>
|
||||
{t('notebookSuggestion.generalNotes')}
|
||||
</DropdownMenuItem>
|
||||
{notebooks.map((notebook: any) => (
|
||||
@@ -204,7 +203,7 @@ function CompactCard({
|
||||
key={notebook.id}
|
||||
onClick={() => handleMoveToNotebook(notebook.id)}
|
||||
>
|
||||
<Folder className="h-4 w-4 mr-2" />
|
||||
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold mr-2 shrink-0">{notebook.name.charAt(0).toUpperCase()}</span>
|
||||
{notebook.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
@@ -75,10 +75,18 @@ export async function describeImages(
|
||||
}
|
||||
const langName = langMap[language] || 'English'
|
||||
|
||||
// Resolve all images as base64 data URLs — skip any that can't be found
|
||||
const resolved = await Promise.all(imageUrls.map(url => resolveImageAsBase64(url)))
|
||||
const imageDataUrls = resolved.filter((d): d is string => d !== null)
|
||||
|
||||
if (imageDataUrls.length === 0) {
|
||||
throw new Error('Could not load any of the provided images. Please check the image URLs.')
|
||||
}
|
||||
|
||||
const buildImageContent = (dataUrl: string) => ({
|
||||
type: 'image' as const,
|
||||
image: dataUrl,
|
||||
})
|
||||
|
||||
if (isTitleMode) {
|
||||
const prompt = imageUrls.length === 1
|
||||
? `Look carefully at this image and identify every concrete detail you can see: objects, people, animals, text, logos, colors, location/setting, actions, weather, time of day, style (photo/illustration/diagram), and any notable elements.
|
||||
@@ -100,13 +108,22 @@ Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"titl
|
||||
|
||||
const content: any[] = [{ type: 'text', text: prompt }]
|
||||
for (const dataUrl of imageDataUrls) {
|
||||
content.push({ type: 'image', image: dataUrl })
|
||||
content.push(buildImageContent(dataUrl))
|
||||
}
|
||||
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
messages: [{ role: 'user', content }],
|
||||
})
|
||||
let text: string
|
||||
try {
|
||||
const result = await generateText({
|
||||
model,
|
||||
messages: [{ role: 'user', content }],
|
||||
})
|
||||
text = result.text
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('image_url') || e.message?.includes('image') || e.message?.includes('vision') || e.message?.includes('multimodal')) {
|
||||
throw new Error('Your AI model does not support image analysis. Please switch to a vision-capable model (e.g., gpt-4o, claude-3.5-sonnet, gemini-2.0-flash).')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
const jsonMatch = text.match(/\[[\s\S]*\]/)
|
||||
@@ -128,13 +145,22 @@ Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"titl
|
||||
if (imageUrls.length === 1) {
|
||||
const content: any[] = [
|
||||
{ type: 'text', text: `Describe this image in detail in ${langName}. Be specific about what you see: objects, people, colors, setting, mood, text visible. Keep it under 100 words.` },
|
||||
{ type: 'image', image: imageDataUrls[0] },
|
||||
buildImageContent(imageDataUrls[0]),
|
||||
]
|
||||
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
messages: [{ role: 'user', content }],
|
||||
})
|
||||
let text: string
|
||||
try {
|
||||
const result = await generateText({
|
||||
model,
|
||||
messages: [{ role: 'user', content }],
|
||||
})
|
||||
text = result.text
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('image_url') || e.message?.includes('image') || e.message?.includes('vision') || e.message?.includes('multimodal')) {
|
||||
throw new Error('Your AI model does not support image analysis. Please switch to a vision-capable model (e.g., gpt-4o, claude-3.5-sonnet, gemini-2.0-flash).')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
return {
|
||||
descriptions: [{ index: 0, description: text.trim() }],
|
||||
@@ -147,13 +173,22 @@ Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"titl
|
||||
for (let i = 0; i < imageDataUrls.length; i++) {
|
||||
const content: any[] = [
|
||||
{ type: 'text', text: `Describe this image (image ${i + 1} of ${imageDataUrls.length}) in ${langName}. Be specific: objects, people, colors, setting, text visible. Under 80 words.` },
|
||||
{ type: 'image', image: imageDataUrls[i] },
|
||||
buildImageContent(imageDataUrls[i]),
|
||||
]
|
||||
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
messages: [{ role: 'user', content }],
|
||||
})
|
||||
let text: string
|
||||
try {
|
||||
const result = await generateText({
|
||||
model,
|
||||
messages: [{ role: 'user', content }],
|
||||
})
|
||||
text = result.text
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('image_url') || e.message?.includes('image') || e.message?.includes('vision') || e.message?.includes('multimodal')) {
|
||||
throw new Error('Your AI model does not support image analysis. Please switch to a vision-capable model (e.g., gpt-4o, claude-3.5-sonnet, gemini-2.0-flash).')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
descriptions.push({ index: i, description: text.trim() })
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export const REFACTOR_OPTIONS: RefactorOption[] = [
|
||||
export class ParagraphRefactorService {
|
||||
private languageDetection: LanguageDetectionService
|
||||
private readonly MIN_WORDS = 10
|
||||
private readonly MAX_WORDS = 2000
|
||||
private readonly MAX_WORDS = 5000
|
||||
|
||||
constructor() {
|
||||
this.languageDetection = new LanguageDetectionService()
|
||||
|
||||
@@ -1496,7 +1496,10 @@
|
||||
"notifyEmail": "إشعار بالبريد الإلكتروني",
|
||||
"notifyEmailHint": "استلام بريد إلكتروني بنتائج الوكيل بعد كل تشغيل",
|
||||
"includeImages": "تضمين الصور",
|
||||
"includeImagesHint": "استخراج الصور من الصفحات المجمعة وإرفاقها بالملاحظة المولدة"
|
||||
"includeImagesHint": "استخراج الصور من الصفحات المجمعة وإرفاقها بالملاحظة المولدة",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "يدوي",
|
||||
|
||||
@@ -1436,7 +1436,10 @@
|
||||
"notifyEmail": "E-Mail-Benachrichtigung",
|
||||
"notifyEmailHint": "Erhalten Sie eine E-Mail mit den Ergebnissen des Agenten nach jedem Durchlauf",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "Manuell",
|
||||
|
||||
@@ -544,7 +544,10 @@
|
||||
"preview": "Preview",
|
||||
"generatePreview": "Generate preview",
|
||||
"emptyNoteHint": "💡 The note is empty — the resource content will be integrated directly."
|
||||
}
|
||||
},
|
||||
"cancel": "Cancel",
|
||||
"copied": "Copied",
|
||||
"copy": "Copy"
|
||||
},
|
||||
"titleSuggestions": {
|
||||
"available": "Title suggestions",
|
||||
@@ -1587,7 +1590,10 @@
|
||||
"notifyEmail": "Email notification",
|
||||
"notifyEmailHint": "Receive an email with the agent's results after each run",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "Manual",
|
||||
@@ -1683,7 +1689,10 @@
|
||||
"title": "History",
|
||||
"noHistory": "No executions yet",
|
||||
"toolTrace": "{count} tool calls",
|
||||
"step": "Step {num}"
|
||||
"step": "Step {num}",
|
||||
"clearConfirm": "Are you sure you want to delete all history for this agent?",
|
||||
"cleared": "History deleted",
|
||||
"clearHistory": "Clear history"
|
||||
},
|
||||
"tools": {
|
||||
"title": "Agent Tools",
|
||||
|
||||
@@ -1436,7 +1436,10 @@
|
||||
"notifyEmail": "Notificación por correo",
|
||||
"notifyEmailHint": "Recibe un correo con los resultados del agente después de cada ejecución",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "Manual",
|
||||
|
||||
@@ -1496,7 +1496,10 @@
|
||||
"notifyEmail": "اعلان ایمیل",
|
||||
"notifyEmailHint": "پس از هر اجرا، ایمیل حاوی نتایج عامل دریافت کنید",
|
||||
"includeImages": "شامل تصاویر",
|
||||
"includeImagesHint": "استخراج تصاویر از صفحات استخراج شده و پیوست به یادداشت تولید شده"
|
||||
"includeImagesHint": "استخراج تصاویر از صفحات استخراج شده و پیوست به یادداشت تولید شده",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "دستی",
|
||||
|
||||
@@ -550,7 +550,10 @@
|
||||
"preview": "Aperçu",
|
||||
"generatePreview": "Générer l'aperçu",
|
||||
"emptyNoteHint": "💡 La note est vide — le contenu de la ressource sera intégré directement."
|
||||
}
|
||||
},
|
||||
"cancel": "Annuler",
|
||||
"copied": "Copié",
|
||||
"copy": "Copier"
|
||||
},
|
||||
"richTextEditor": {
|
||||
"bold": "Gras",
|
||||
@@ -1679,7 +1682,10 @@
|
||||
"notifyEmail": "Notification par email",
|
||||
"notifyEmailHint": "Recevez un email avec les résultats de l'agent après chaque exécution",
|
||||
"includeImages": "Inclure les images",
|
||||
"includeImagesHint": "Extraire les images des pages scrapées et les joindre à la note générée"
|
||||
"includeImagesHint": "Extraire les images des pages scrapées et les joindre à la note générée",
|
||||
"back": "Retour",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "Manuel",
|
||||
@@ -1775,7 +1781,10 @@
|
||||
"title": "Historique",
|
||||
"noHistory": "Aucune exécution pour le moment",
|
||||
"toolTrace": "{count} appels d'outils",
|
||||
"step": "Étape {num}"
|
||||
"step": "Étape {num}",
|
||||
"clearConfirm": "Voulez-vous vraiment supprimer tout l'historique de cet agent ?",
|
||||
"cleared": "Historique supprimé",
|
||||
"clearHistory": "Supprimer l'historique"
|
||||
},
|
||||
"tools": {
|
||||
"title": "Outils de l'agent",
|
||||
|
||||
@@ -1436,7 +1436,10 @@
|
||||
"notifyEmail": "ईमेल सूचना",
|
||||
"notifyEmailHint": "प्रत्येक रन के बाद एजेंट के परिणामों के साथ ईमेल प्राप्त करें",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "मैनुअल",
|
||||
|
||||
@@ -1436,7 +1436,10 @@
|
||||
"notifyEmail": "Notifica email",
|
||||
"notifyEmailHint": "Ricevi un'email con i risultati dell'agent dopo ogni esecuzione",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "Manuale",
|
||||
|
||||
@@ -1436,7 +1436,10 @@
|
||||
"notifyEmail": "メール通知",
|
||||
"notifyEmailHint": "実行後にエージェントの結果をメールで受け取る",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "手動",
|
||||
|
||||
@@ -1436,7 +1436,10 @@
|
||||
"notifyEmail": "이메일 알림",
|
||||
"notifyEmailHint": "각 실행 후 에이전트 결과가 포함된 이메일 받기",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "수동",
|
||||
|
||||
@@ -1436,7 +1436,10 @@
|
||||
"notifyEmail": "E-mailnotificatie",
|
||||
"notifyEmailHint": "Ontvang een e-mail met de resultaten van de agent na elke uitvoering",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "Handmatig",
|
||||
|
||||
@@ -1436,7 +1436,10 @@
|
||||
"notifyEmail": "Powiadomienie e-mail",
|
||||
"notifyEmailHint": "Otrzymuj e-mail z wynikami agenta po każdym uruchomieniu",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "Ręcznie",
|
||||
|
||||
@@ -1436,7 +1436,10 @@
|
||||
"notifyEmail": "Notificação por e-mail",
|
||||
"notifyEmailHint": "Receba um e-mail com os resultados do agente após cada execução",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "Manual",
|
||||
|
||||
@@ -1436,7 +1436,10 @@
|
||||
"notifyEmail": "Email-уведомление",
|
||||
"notifyEmailHint": "Получайте письмо с результатами агента после каждого запуска",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "Вручную",
|
||||
|
||||
@@ -1436,7 +1436,10 @@
|
||||
"notifyEmail": "邮件通知",
|
||||
"notifyEmailHint": "每次运行后通过邮件接收代理结果",
|
||||
"includeImages": "Include images",
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note",
|
||||
"back": "Back",
|
||||
"configuration": "Configuration",
|
||||
"options": "Options"
|
||||
},
|
||||
"frequencies": {
|
||||
"manual": "手动",
|
||||
|
||||
Reference in New Issue
Block a user