feat: Tier 1 & 2 — Daily Note, Voice, Flashcard quota, Readwise, Calendar, Agent Gallery
Tier 1: - BASIC tier: chat (10/mo) + reformulate (10/mo) désormais accessibles - Nouveaux quotas: ai_flashcard + voice_transcribe dans tous les tiers - /api/notes/daily : note du jour auto-créée (find or create) - Bouton Note du Jour dans la sidebar (CalendarDays) - Voice-to-Text dans l'éditeur (Web Speech API, bouton Mic toolbar) - Flashcard generation → quota ai_flashcard (au lieu de reformulate) Tier 2: - Intégration Readwise: GET/POST/DELETE /api/integrations/readwise - Intégration Google Calendar: OAuth flow + today's events + meeting notes - /api/integrations/calendar + /callback - Page /settings/integrations avec cards Calendar + Readwise - SettingsNav: onglet Intégrations - AgentTemplates: catégories + 4 nouveaux templates (Digest/Recap/AutoTagger/Synthesis) Schema: - UserAISettings.integrationTokens Json? (migration 20260529160000) - prisma generate + migrate deploy appliqués Fix: - SpeechRecognition types (triple-slash @types/dom-speech-recognition) - Notebook.create: suppression champ 'description' inexistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,12 @@ import {
|
||||
Presentation,
|
||||
Pencil,
|
||||
ListChecks,
|
||||
Newspaper,
|
||||
Youtube,
|
||||
BookMarked,
|
||||
FileText,
|
||||
Tag,
|
||||
Brain,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -21,7 +27,8 @@ interface AgentTemplatesProps {
|
||||
}
|
||||
|
||||
const templateConfig = [
|
||||
{ id: 'veilleAI', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
|
||||
// ── Scrapers & Veille ──────────────────────────────────────────────────
|
||||
{ id: 'veilleAI', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', category: 'veille', urls: [
|
||||
'https://www.theverge.com/rss/ai-artificial-intelligence/index.xml',
|
||||
'https://techcrunch.com/category/artificial-intelligence/feed/',
|
||||
'https://feeds.arstechnica.com/arstechnica/technology-lab',
|
||||
@@ -29,23 +36,41 @@ const templateConfig = [
|
||||
'https://www.wired.com/feed/',
|
||||
'https://korben.info/feed',
|
||||
], frequency: 'weekly' },
|
||||
{ id: 'veilleTech', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
|
||||
{ id: 'veilleTech', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', category: 'veille', urls: [
|
||||
'https://news.ycombinator.com/rss',
|
||||
'https://dev.to/feed',
|
||||
'https://www.producthunt.com/feed',
|
||||
], frequency: 'daily' },
|
||||
{ id: 'veilleDev', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
|
||||
{ id: 'veilleDev', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', category: 'veille', urls: [
|
||||
'https://dev.to/feed/tag/javascript',
|
||||
'https://dev.to/feed/tag/typescript',
|
||||
'https://dev.to/feed/tag/react',
|
||||
], frequency: 'weekly' },
|
||||
{ id: 'surveillant', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', urls: [], frequency: 'weekly' },
|
||||
{ id: 'chercheur', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', urls: [], frequency: 'manual' },
|
||||
{ id: 'slideGenerator', type: 'slide-generator', roleKey: 'agents.defaultRoles.slideGenerator', urls: [], frequency: 'manual' },
|
||||
{ id: 'excalidrawGenerator', type: 'excalidraw-generator', roleKey: 'agents.defaultRoles.excalidrawGenerator', urls: [], frequency: 'manual' },
|
||||
{ id: 'taskExtractor', type: 'task-extractor', roleKey: 'agents.defaultRoles.taskExtractor', urls: [], frequency: 'manual' },
|
||||
// ── Digest & Résumés ──────────────────────────────────────────────────
|
||||
{ id: 'dailyDigest', type: 'digest', roleKey: 'agents.defaultRoles.researcher', category: 'digest', urls: [], frequency: 'daily' },
|
||||
{ id: 'weeklyRecap', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', category: 'digest', urls: [], frequency: 'weekly' },
|
||||
// ── Outils ────────────────────────────────────────────────────────────
|
||||
{ id: 'surveillant', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', category: 'tools', urls: [], frequency: 'weekly' },
|
||||
{ id: 'chercheur', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', category: 'tools', urls: [], frequency: 'manual' },
|
||||
{ id: 'autoTagger', type: 'auto-tagger', roleKey: 'agents.defaultRoles.researcher', category: 'tools', urls: [], frequency: 'weekly' },
|
||||
// ── Génération ────────────────────────────────────────────────────────
|
||||
{ id: 'slideGenerator', type: 'slide-generator', roleKey: 'agents.defaultRoles.slideGenerator', category: 'generate', urls: [], frequency: 'manual' },
|
||||
{ id: 'excalidrawGenerator', type: 'excalidraw-generator', roleKey: 'agents.defaultRoles.excalidrawGenerator', category: 'generate', urls: [], frequency: 'manual' },
|
||||
{ id: 'taskExtractor', type: 'task-extractor', roleKey: 'agents.defaultRoles.taskExtractor', category: 'generate', urls: [], frequency: 'manual' },
|
||||
{ id: 'knowledgeSynthesis', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', category: 'generate', urls: [], frequency: 'weekly' },
|
||||
] as const
|
||||
|
||||
type TemplateId = typeof templateConfig[number]['id']
|
||||
type CategoryId = 'all' | 'veille' | 'digest' | 'tools' | 'generate'
|
||||
|
||||
const CATEGORIES: { id: CategoryId; label: string }[] = [
|
||||
{ id: 'all', label: 'Tous' },
|
||||
{ id: 'veille', label: '📡 Veille' },
|
||||
{ id: 'digest', label: '📰 Digest' },
|
||||
{ id: 'tools', label: '🔧 Outils' },
|
||||
{ id: 'generate', label: '✨ Génération' },
|
||||
]
|
||||
|
||||
const typeIcons: Record<string, typeof Globe> = {
|
||||
scraper: Globe,
|
||||
researcher: Search,
|
||||
@@ -54,11 +79,25 @@ const typeIcons: Record<string, typeof Globe> = {
|
||||
'slide-generator': Presentation,
|
||||
'excalidraw-generator': Pencil,
|
||||
'task-extractor': ListChecks,
|
||||
digest: Newspaper,
|
||||
'youtube-transcript': Youtube,
|
||||
'readwise-sync': BookMarked,
|
||||
'auto-tagger': Tag,
|
||||
'knowledge-synthesis': Brain,
|
||||
}
|
||||
|
||||
// Extra icons for specific template IDs
|
||||
const templateIcons: Partial<Record<TemplateId, typeof Globe>> = {
|
||||
dailyDigest: Newspaper,
|
||||
weeklyRecap: FileText,
|
||||
autoTagger: Tag,
|
||||
knowledgeSynthesis: Brain,
|
||||
}
|
||||
|
||||
export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) {
|
||||
const { t } = useLanguage()
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all')
|
||||
|
||||
const handleInstall = async (tpl: typeof templateConfig[number]) => {
|
||||
setInstallingId(tpl.id)
|
||||
@@ -73,6 +112,18 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
while (existingAgentNames.includes(`${baseName} ${n}`)) n++
|
||||
resolvedName = `${baseName} ${n}`
|
||||
}
|
||||
|
||||
const toolMap: Record<string, string[]> = {
|
||||
scraper: ['web_scrape', 'note_create'],
|
||||
researcher: ['web_search', 'web_scrape', 'note_search', 'note_create'],
|
||||
monitor: ['note_search', 'note_read', 'note_create'],
|
||||
'slide-generator': ['note_search', 'note_read', 'generate_pptx'],
|
||||
'excalidraw-generator': ['note_search', 'note_read', 'generate_excalidraw'],
|
||||
'task-extractor': ['note_search', 'note_read', 'task_extract', 'note_create'],
|
||||
digest: ['note_search', 'note_read', 'note_create'],
|
||||
'auto-tagger': ['note_search', 'note_read', 'note_update'],
|
||||
}
|
||||
|
||||
await createAgent({
|
||||
name: resolvedName,
|
||||
description: t(descKey),
|
||||
@@ -80,19 +131,7 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
role: t(tpl.roleKey),
|
||||
sourceUrls: tpl.urls.length > 0 ? [...tpl.urls] : undefined,
|
||||
frequency: tpl.frequency,
|
||||
tools: tpl.type === 'scraper'
|
||||
? ['web_scrape', 'note_create']
|
||||
: tpl.type === 'researcher'
|
||||
? ['web_search', 'web_scrape', 'note_search', 'note_create']
|
||||
: tpl.type === 'monitor'
|
||||
? ['note_search', 'note_read', 'note_create']
|
||||
: tpl.type === 'slide-generator'
|
||||
? ['note_search', 'note_read', 'generate_pptx']
|
||||
: tpl.type === 'excalidraw-generator'
|
||||
? ['note_search', 'note_read', 'generate_excalidraw']
|
||||
: tpl.type === 'task-extractor'
|
||||
? ['note_search', 'note_read', 'task_extract', 'note_create']
|
||||
: [],
|
||||
tools: toolMap[tpl.type] ?? [],
|
||||
})
|
||||
toast.success(t('agents.toasts.installSuccess', { name: resolvedName }))
|
||||
onInstalled()
|
||||
@@ -103,44 +142,75 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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`
|
||||
const filtered = activeCategory === 'all'
|
||||
? templateConfig
|
||||
: templateConfig.filter((tpl) => tpl.category === activeCategory)
|
||||
|
||||
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"
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Category filter */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-semibold transition-all border ${
|
||||
activeCategory === cat.id
|
||||
? 'bg-ink text-paper border-ink'
|
||||
: 'bg-paper text-muted-ink border-border/40 hover:border-ink/30'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filtered.map(tpl => {
|
||||
const Icon = templateIcons[tpl.id as TemplateId] ?? 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="bg-card/40 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-card hover:border-foreground/20 transition-all"
|
||||
>
|
||||
{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 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 className="flex items-start justify-between gap-2 mb-2">
|
||||
<h4 className="text-[13px] font-bold text-foreground">{t(nameKey)}</h4>
|
||||
{tpl.frequency !== 'manual' && (
|
||||
<span className="text-[10px] font-semibold text-concrete bg-border/20 rounded-full px-2 py-0.5 shrink-0">
|
||||
{tpl.frequency === 'daily' ? '📅 Quotidien' : '📆 Hebdo'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useNoteEditorContext } from './note-editor-context'
|
||||
import { LabelManager } from '@/components/label-manager'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
@@ -19,7 +19,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
|
||||
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff
|
||||
} from 'lucide-react'
|
||||
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
@@ -28,6 +28,7 @@ import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useVoiceTranscription } from '@/hooks/use-voice-transcription'
|
||||
import { toast } from 'sonner'
|
||||
import { format } from 'date-fns'
|
||||
import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
|
||||
@@ -50,6 +51,18 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
|
||||
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
|
||||
|
||||
// ── Voice transcription ──────────────────────────────────────────────────
|
||||
const handleTranscript = useCallback((text: string) => {
|
||||
const editor = richTextEditorRef?.current?.getEditor()
|
||||
if (editor) {
|
||||
editor.chain().focus().insertContent(' ' + text).run()
|
||||
}
|
||||
}, [richTextEditorRef])
|
||||
|
||||
const { state: voiceState, toggle: toggleVoice, isSupported: voiceSupported } = useVoiceTranscription({
|
||||
onTranscript: handleTranscript,
|
||||
})
|
||||
|
||||
// ── Markdown export ───────────────────────────────────────────────────────
|
||||
const handleExportMarkdown = () => {
|
||||
try {
|
||||
@@ -246,6 +259,24 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && voiceSupported && (
|
||||
<button
|
||||
title={voiceState === 'listening'
|
||||
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
||||
: (t('editor.voiceStart') || 'Dicter du texte')}
|
||||
aria-label={voiceState === 'listening' ? 'Stop voice' : 'Start voice'}
|
||||
onClick={toggleVoice}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all',
|
||||
voiceState === 'listening'
|
||||
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && onToggleAttachments && (
|
||||
<button
|
||||
title={t('notes.attachments') || 'Attachments'}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Settings, Sparkles, Palette, User, Database, Info, Key, CreditCard } from 'lucide-react'
|
||||
import { Settings, Sparkles, Palette, User, Database, Info, Key, CreditCard, Plug } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
@@ -21,6 +21,7 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
||||
{ id: 'appearance', label: t('appearance.title'), icon: <Palette size={14} />, href: '/settings/appearance' },
|
||||
{ id: 'profile', label: t('profile.title'), icon: <User size={14} />, href: '/settings/profile' },
|
||||
{ id: 'data', label: t('dataManagement.title'), icon: <Database size={14} />, href: '/settings/data' },
|
||||
{ id: 'integrations', label: t('integrations.title') || 'Intégrations', icon: <Plug size={14} />, href: '/settings/integrations' },
|
||||
{ id: 'mcp', label: t('mcpSettings.title'), icon: <Key size={14} />, href: '/settings/mcp' },
|
||||
{ id: 'about', label: t('about.title'), icon: <Info size={14} />, href: '/settings/about' },
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
BookMarked,
|
||||
Bot,
|
||||
Inbox,
|
||||
CalendarDays,
|
||||
FlaskConical,
|
||||
ArrowUpDown,
|
||||
Archive,
|
||||
@@ -764,6 +765,21 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
router.push('/home?forceList=1')
|
||||
}
|
||||
|
||||
const handleDailyNoteClick = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/notes/daily')
|
||||
const data = await res.json()
|
||||
if (data.success && data.note) {
|
||||
const params = new URLSearchParams()
|
||||
if (data.note.notebookId) params.set('notebook', data.note.notebookId)
|
||||
params.set('openNote', data.note.id)
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('sidebar.dailyNoteError') || 'Impossible d\'ouvrir la note du jour')
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteClick = (noteId: string, notebookId: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('notebook', notebookId)
|
||||
@@ -1379,6 +1395,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDailyNoteClick}
|
||||
className="sidebar-inbox-item"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0 bg-paper dark:bg-white/5 text-muted-ink border-border">
|
||||
<CalendarDays size={14} />
|
||||
</div>
|
||||
<span className="text-[13px] font-medium truncate text-muted-ink">
|
||||
{t('sidebar.dailyNote') || 'Note du jour'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="my-3 h-px bg-border/40" />
|
||||
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user