feat: Tier 1 & 2 — Daily Note, Voice, Flashcard quota, Readwise, Calendar, Agent Gallery
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped

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:
Antigravity
2026-05-29 15:14:01 +00:00
parent 79fd6553b7
commit c415d93945
20 changed files with 1271 additions and 63 deletions

View File

@@ -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>
)
}

View File

@@ -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'}

View File

@@ -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' },
]

View File

@@ -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