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

@@ -107,3 +107,7 @@ NEXTAUTH_URL="http://localhost:3000"
# Feature flag — set to "true" to enable billing UI (default: false)
# NEXT_PUBLIC_FEATURE_BILLING_ENABLED="false"
# Google Calendar Integration (optional — uses Google OAuth credentials above if not set separately)
# GOOGLE_CALENDAR_CLIENT_ID="....apps.googleusercontent.com"
# GOOGLE_CALENDAR_CLIENT_SECRET="GOCSPX-..."

View File

@@ -0,0 +1,289 @@
'use client'
import { useState, useEffect } from 'react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays } from 'lucide-react'
import { toast } from 'sonner'
export default function IntegrationsPage() {
// ── Readwise ───────────────────────────────────────────────────────────
const [rwToken, setRwToken] = useState('')
const [rwConnected, setRwConnected] = useState(false)
const [rwSyncing, setRwSyncing] = useState(false)
const [rwConnecting, setRwConnecting] = useState(false)
const [rwLastSync, setRwLastSync] = useState<{ created: number; updated: number } | null>(null)
// ── Google Calendar ────────────────────────────────────────────────────
const [calConnected, setCalConnected] = useState(false)
const [calLoading, setCalLoading] = useState(true)
const [calEvents, setCalEvents] = useState<any[]>([])
const [calFetching, setCalFetching] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
fetch('/api/integrations/readwise').then((r) => r.json()),
fetch('/api/integrations/calendar').then((r) => r.json()),
]).then(([rw, cal]) => {
setRwConnected(rw.connected)
setCalConnected(cal.connected)
}).finally(() => {
setLoading(false)
setCalLoading(false)
})
// Handle redirect params
const params = new URLSearchParams(window.location.search)
if (params.get('connected') === 'calendar') {
setCalConnected(true)
toast.success('Google Calendar connecté !')
window.history.replaceState({}, '', '/settings/integrations')
}
if (params.get('error')) {
toast.error(`Erreur: ${params.get('error')}`)
window.history.replaceState({}, '', '/settings/integrations')
}
}, [])
// ── Readwise handlers ──────────────────────────────────────────────────
const handleRwConnect = async () => {
if (!rwToken.trim()) return
setRwConnecting(true)
try {
const res = await fetch('/api/integrations/readwise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: rwToken.trim() }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur Readwise'); return }
setRwConnected(true)
setRwToken('')
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Readwise connecté — ${data.created} notes créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de connexion Readwise') } finally { setRwConnecting(false) }
}
const handleRwSync = async () => {
setRwSyncing(true)
try {
const res = await fetch('/api/integrations/readwise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur de sync'); return }
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Sync Readwise — ${data.created} créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de synchronisation') } finally { setRwSyncing(false) }
}
const handleRwDisconnect = async () => {
await fetch('/api/integrations/readwise', { method: 'DELETE' })
setRwConnected(false)
toast.success('Readwise déconnecté')
}
// ── Calendar handlers ──────────────────────────────────────────────────
const handleCalConnect = () => {
window.location.href = '/api/integrations/calendar?connect=1'
}
const handleCalFetchEvents = async () => {
setCalFetching(true)
try {
const res = await fetch('/api/integrations/calendar?events=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); return }
setCalEvents(data.events ?? [])
if (data.events.length === 0) toast.info('Aucun événement aujourd\'hui')
} catch { toast.error('Erreur de chargement des événements') } finally { setCalFetching(false) }
}
const handleCreateMeetingNote = async (event: any) => {
const res = await fetch('/api/integrations/calendar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ eventId: event.id, summary: event.summary, start: event.start }),
})
const data = await res.json()
if (data.success) {
toast.success(`Note de réunion créée : ${event.summary}`, {
action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
})
} else {
toast.error('Erreur lors de la création de la note')
}
}
const handleCalDisconnect = async () => {
await fetch('/api/integrations/calendar', { method: 'DELETE' })
setCalConnected(false)
setCalEvents([])
toast.success('Google Calendar déconnecté')
}
const StatusBadge = ({ connected }: { connected: boolean }) =>
connected ? (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800/40 rounded-full px-2.5 py-1">
<Check size={11} /> Connecté
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-concrete bg-border/20 border border-border/40 rounded-full px-2.5 py-1">
<X size={11} /> Non connecté
</span>
)
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">Intégrations</h2>
<p className="text-sm text-concrete mt-1">Connectez des services externes à Momento.</p>
</div>
{/* ── Google Calendar ────────────────────────────────────────────── */}
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<CalendarDays size={20} className="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Google Calendar</h3>
<p className="text-[12px] text-concrete">Accédez à vos événements et créez des notes de réunion</p>
</div>
</div>
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
</div>
{!calConnected ? (
<button
onClick={handleCalConnect}
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
>
<CalendarDays size={14} />
Connecter Google Calendar
</button>
) : (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
onClick={handleCalFetchEvents}
disabled={calFetching}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{calFetching ? <Loader2 size={14} className="animate-spin" /> : <CalendarDays size={14} />}
Événements aujourd&apos;hui
</button>
<button
onClick={handleCalDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
Déconnecter
</button>
</div>
{calEvents.length > 0 && (
<div className="space-y-2">
{calEvents.map((ev) => (
<div key={ev.id} className="flex items-center justify-between gap-3 px-4 py-3 bg-border/10 rounded-xl border border-border/20">
<div className="min-w-0">
<p className="text-sm font-medium text-ink truncate">{ev.summary}</p>
<p className="text-[11px] text-concrete">
{ev.start ? new Date(ev.start).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : ''}
</p>
</div>
<button
onClick={() => handleCreateMeetingNote(ev)}
className="shrink-0 text-[11px] font-semibold px-3 py-1.5 border border-border/40 rounded-full hover:bg-ink/5 transition-all"
>
+ Note
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* ── Readwise ──────────────────────────────────────────────────── */}
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<BookOpen size={20} className="text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Readwise</h3>
<p className="text-[12px] text-concrete">Importez vos surlignages de livres, articles et Kindle</p>
</div>
</div>
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
</div>
{!rwConnected && (
<div className="space-y-3">
<p className="text-[12px] text-concrete">
Trouvez votre token sur{' '}
<a href="https://readwise.io/access_token" target="_blank" rel="noopener noreferrer" className="text-brand-accent underline">
readwise.io/access_token
</a>
</p>
<div className="flex gap-2">
<input
type="password"
value={rwToken}
onChange={(e) => setRwToken(e.target.value)}
placeholder="Token Readwise…"
className="flex-1 text-sm border border-border/40 rounded-xl px-3 py-2 bg-paper text-ink placeholder:text-concrete/50 focus:outline-none focus:ring-1 focus:ring-brand-accent/40"
onKeyDown={(e) => e.key === 'Enter' && handleRwConnect()}
/>
<button
onClick={handleRwConnect}
disabled={!rwToken.trim() || rwConnecting}
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all disabled:opacity-50 flex items-center gap-2"
>
{rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null}
Connecter
</button>
</div>
</div>
)}
{rwConnected && (
<div className="flex flex-wrap gap-2">
<button
onClick={handleRwSync}
disabled={rwSyncing}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{rwSyncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
Synchroniser maintenant
</button>
<button
onClick={handleRwDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
Déconnecter
</button>
</div>
)}
{rwLastSync && (
<p className="text-[11px] text-concrete">
Dernière sync : <strong>{rwLastSync.created}</strong> notes créées, <strong>{rwLastSync.updated}</strong> mises à jour
</p>
)}
</div>
{/* Placeholder */}
<div className="border border-dashed border-border/40 rounded-2xl p-6 text-center">
<p className="text-sm text-concrete italic">D'autres intégrations arrivent bientôt Zapier, GitHub, Notion import</p>
</div>
</div>
)
}

View File

@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
}
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
await checkEntitlementOrThrow(session.user.id, 'ai_flashcard')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
language: note.language || undefined,
})
incrementUsageAsync(session.user.id, 'reformulate')
incrementUsageAsync(session.user.id, 'ai_flashcard')
return NextResponse.json({ cards, noteId: note.id, style })
} catch (error) {

View File

@@ -0,0 +1,69 @@
/**
* GET /api/integrations/calendar/callback
* Google OAuth callback — exchanges code for tokens and persists them.
*/
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
export async function GET(req: NextRequest) {
const url = new URL(req.url)
const code = url.searchParams.get('code')
const userId = url.searchParams.get('state')
const error = url.searchParams.get('error')
if (error || !code || !userId) {
return NextResponse.redirect(
`${url.origin}/settings/integrations?error=calendar_auth_failed`,
)
}
const clientId = process.env.GOOGLE_CALENDAR_CLIENT_ID || process.env.AUTH_GOOGLE_ID || ''
const clientSecret = process.env.GOOGLE_CALENDAR_CLIENT_SECRET || process.env.AUTH_GOOGLE_SECRET || ''
const redirectUri = `${url.origin}/api/integrations/calendar/callback`
const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
})
const tokenData = await tokenRes.json()
if (!tokenData.access_token) {
return NextResponse.redirect(
`${url.origin}/settings/integrations?error=calendar_token_failed`,
)
}
// Persist tokens in UserAISettings.integrationTokens
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId },
select: { integrationTokens: true },
})
const meta =
typeof aiSettings?.integrationTokens === 'string'
? JSON.parse(aiSettings.integrationTokens)
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
meta.calendarAccessToken = tokenData.access_token
if (tokenData.refresh_token) {
meta.calendarRefreshToken = tokenData.refresh_token
}
await prisma.userAISettings.upsert({
where: { userId },
update: { integrationTokens: JSON.stringify(meta) },
create: { userId, integrationTokens: JSON.stringify(meta) },
})
return NextResponse.redirect(`${url.origin}/settings/integrations?connected=calendar`)
}

View File

@@ -0,0 +1,232 @@
/**
* Google Calendar Integration
*
* OAuth flow (simplified — uses the existing Google account from NextAuth):
* 1. GET /api/integrations/calendar → redirect to Google OAuth consent (calendar scope)
* 2. GET /api/integrations/calendar/callback → exchange code, store refresh token
*
* Usage after auth:
* GET /api/integrations/calendar?events=1 → list today's events
* POST /api/integrations/calendar → create a meeting note from an event id
*/
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.readonly'
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
const GOOGLE_CALENDAR_API = 'https://www.googleapis.com/calendar/v3'
function getClientId() {
return process.env.GOOGLE_CALENDAR_CLIENT_ID || process.env.AUTH_GOOGLE_ID || ''
}
function getClientSecret() {
return process.env.GOOGLE_CALENDAR_CLIENT_SECRET || process.env.AUTH_GOOGLE_SECRET || ''
}
function getRedirectUri(baseUrl: string) {
return `${baseUrl}/api/integrations/calendar/callback`
}
async function getStoredTokens(userId: string): Promise<{ accessToken: string; refreshToken: string } | null> {
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId },
select: { integrationTokens: true },
})
try {
const meta =
typeof aiSettings?.integrationTokens === 'string'
? JSON.parse(aiSettings.integrationTokens)
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
if (meta?.calendarAccessToken && meta?.calendarRefreshToken) {
return {
accessToken: meta.calendarAccessToken as string,
refreshToken: meta.calendarRefreshToken as string,
}
}
} catch { /* no-op */ }
return null
}
async function refreshAccessToken(userId: string, refreshToken: string): Promise<string | null> {
const res = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: getClientId(),
client_secret: getClientSecret(),
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
})
const data = await res.json()
if (!data.access_token) return null
// Persist refreshed access token
const aiSettings = await prisma.userAISettings.findUnique({ where: { userId }, select: { integrationTokens: true } })
const meta = typeof aiSettings?.integrationTokens === 'string' ? JSON.parse(aiSettings.integrationTokens) : {}
meta.calendarAccessToken = data.access_token
await prisma.userAISettings.upsert({
where: { userId },
update: { integrationTokens: JSON.stringify(meta) },
create: { userId, integrationTokens: JSON.stringify(meta) },
})
return data.access_token
}
/** GET /api/integrations/calendar — status check or redirect to OAuth */
export async function GET(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const url = new URL(req.url)
const connect = url.searchParams.get('connect')
const events = url.searchParams.get('events')
const userId = session.user.id
// Check connection status
if (!connect && !events) {
const tokens = await getStoredTokens(userId)
return NextResponse.json({ connected: !!tokens })
}
// Start OAuth flow
if (connect === '1') {
const clientId = getClientId()
if (!clientId) {
return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 503 })
}
const baseUrl = url.origin
const authUrl = new URL(GOOGLE_AUTH_URL)
authUrl.searchParams.set('client_id', clientId)
authUrl.searchParams.set('redirect_uri', getRedirectUri(baseUrl))
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', CALENDAR_SCOPE)
authUrl.searchParams.set('access_type', 'offline')
authUrl.searchParams.set('prompt', 'consent')
authUrl.searchParams.set('state', userId)
return NextResponse.redirect(authUrl.toString())
}
// Fetch today's events
if (events === '1') {
let tokens = await getStoredTokens(userId)
if (!tokens) {
return NextResponse.json({ error: 'Calendar not connected' }, { status: 400 })
}
const now = new Date()
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString()
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59).toISOString()
const fetchEvents = async (accessToken: string) =>
fetch(
`${GOOGLE_CALENDAR_API}/calendars/primary/events?` +
new URLSearchParams({
timeMin: startOfDay,
timeMax: endOfDay,
singleEvents: 'true',
orderBy: 'startTime',
}),
{ headers: { Authorization: `Bearer ${accessToken}` } },
)
let res = await fetchEvents(tokens.accessToken)
// Token might be expired — refresh and retry
if (res.status === 401 && tokens.refreshToken) {
const newToken = await refreshAccessToken(userId, tokens.refreshToken)
if (!newToken) {
return NextResponse.json({ error: 'Token refresh failed — please reconnect Calendar' }, { status: 401 })
}
res = await fetchEvents(newToken)
}
const data = await res.json()
const items = (data.items ?? []).map((e: any) => ({
id: e.id,
summary: e.summary || '(Sans titre)',
start: e.start?.dateTime || e.start?.date,
end: e.end?.dateTime || e.end?.date,
location: e.location,
description: e.description,
htmlLink: e.htmlLink,
}))
return NextResponse.json({ events: items })
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
}
/** POST /api/integrations/calendar — create a meeting note from a calendar event */
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const body = await req.json().catch(() => ({}))
const eventId = body.eventId as string | undefined
const summary = body.summary as string | undefined
const start = body.start as string | undefined
if (!summary) {
return NextResponse.json({ error: 'summary required' }, { status: 400 })
}
const dateStr = start ? new Date(start).toLocaleDateString('fr-FR') : new Date().toLocaleDateString('fr-FR')
const timeStr = start ? new Date(start).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : ''
const content = JSON.stringify({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: `🗓 ${summary}` }] },
{ type: 'paragraph', content: [{ type: 'text', text: `📅 ${dateStr}${timeStr ? ` à ${timeStr}` : ''}` }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '📋 Ordre du jour' }] },
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '📝 Notes' }] },
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '✅ Actions à faire' }] },
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
],
})
const note = await prisma.note.create({
data: {
userId,
title: `${summary}${dateStr}`,
content,
type: 'meeting',
labels: JSON.stringify(['réunion', 'calendar']),
},
})
return NextResponse.json({ success: true, note })
}
/** DELETE /api/integrations/calendar — disconnect Calendar */
export async function DELETE() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const aiSettings = await prisma.userAISettings.findUnique({ where: { userId }, select: { integrationTokens: true } })
if (aiSettings) {
try {
const meta = ((aiSettings.integrationTokens as Record<string, unknown>) ?? {})
delete meta.calendarAccessToken
delete meta.calendarRefreshToken
await prisma.userAISettings.update({ where: { userId }, data: { integrationTokens: JSON.stringify(meta) } })
} catch { /* no-op */ }
}
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,258 @@
/**
* POST /api/integrations/readwise/sync
* Syncs Readwise highlights into Momento notes.
* Each book/article becomes a note with all its highlights listed.
*
* Query params:
* ?token=xxx (Readwise API token) — used for initial test; stored in UserAISettings.integrationTokens
*/
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
const READWISE_API = 'https://readwise.io/api/v2'
interface ReadwiseHighlight {
id: number
text: string
note: string
location: number
book_id: number
highlighted_at: string | null
url: string | null
color: string
}
interface ReadwiseBook {
id: number
title: string
author: string | null
category: string
source: string
num_highlights: number
last_highlight_at: string | null
cover_image_url: string | null
source_url: string | null
}
interface ReadwisePaginatedResponse<T> {
count: number
next: string | null
results: T[]
}
async function fetchAllPages<T>(url: string, token: string): Promise<T[]> {
const results: T[] = []
let nextUrl: string | null = url
while (nextUrl) {
const res = await fetch(nextUrl, {
headers: { Authorization: `Token ${token}` },
})
if (!res.ok) throw new Error(`Readwise API error: ${res.status}`)
const data: ReadwisePaginatedResponse<T> = await res.json()
results.push(...data.results)
nextUrl = data.next
}
return results
}
function buildNoteContent(book: ReadwiseBook, highlights: ReadwiseHighlight[]): string {
const sourceLink = book.source_url
? `<p><a href="${book.source_url}">${book.source_url}</a></p>`
: ''
const highlightLines = highlights
.sort((a, b) => a.location - b.location)
.map((h) => {
const note = h.note ? `<blockquote>${h.note}</blockquote>` : ''
return `<li>${h.text}${note}</li>`
})
.join('\n')
return `<h1>📚 ${book.title}</h1>
${book.author ? `<p><em>par ${book.author}</em></p>` : ''}
${sourceLink}
<p><strong>${highlights.length} surlignage${highlights.length > 1 ? 's' : ''}</strong></p>
<ul>
${highlightLines}
</ul>`
}
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// Retrieve stored Readwise token from UserAISettings metadata
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId },
select: { integrationTokens: true },
})
let token: string | undefined
try {
const meta = typeof aiSettings?.integrationTokens === 'string'
? JSON.parse(aiSettings.integrationTokens)
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
token = meta?.readwiseToken as string | undefined
} catch {
token = undefined
}
// Allow passing token directly (for test/setup)
const body = await req.json().catch(() => ({}))
if (body.token && typeof body.token === 'string') {
token = body.token
// Persist token
await prisma.userAISettings.upsert({
where: { userId },
update: {
integrationTokens: JSON.stringify({
...(typeof aiSettings?.integrationTokens === 'string' ? JSON.parse(aiSettings.integrationTokens) : {}),
readwiseToken: body.token,
}),
},
create: {
userId,
integrationTokens: { readwiseToken: body.token },
},
})
}
if (!token) {
return NextResponse.json({ error: 'Readwise token not configured' }, { status: 400 })
}
try {
// Fetch books and highlights
const [books, highlights] = await Promise.all([
fetchAllPages<ReadwiseBook>(`${READWISE_API}/books/?page_size=100`, token),
fetchAllPages<ReadwiseHighlight>(`${READWISE_API}/highlights/?page_size=500`, token),
])
// Group highlights by book
const byBook = new Map<number, ReadwiseHighlight[]>()
for (const h of highlights) {
const arr = byBook.get(h.book_id) ?? []
arr.push(h)
byBook.set(h.book_id, arr)
}
// Find or create a "Readwise" notebook
let notebook = await prisma.notebook.findFirst({
where: { userId, name: 'Readwise' },
})
if (!notebook) {
notebook = await prisma.notebook.create({
data: { userId, name: 'Readwise', icon: '📚', order: 999 },
})
}
let created = 0
let updated = 0
for (const book of books) {
const bookHighlights = byBook.get(book.id) ?? []
if (bookHighlights.length === 0) continue
const content = buildNoteContent(book, bookHighlights)
const existing = await prisma.note.findFirst({
where: { userId, notebookId: notebook.id, title: book.title, trashedAt: null },
})
if (existing) {
await prisma.note.update({
where: { id: existing.id },
data: { content, updatedAt: new Date() },
})
updated++
} else {
await prisma.note.create({
data: {
userId,
notebookId: notebook.id,
title: book.title,
content,
labels: JSON.stringify(['readwise', book.category]),
},
})
created++
}
}
return NextResponse.json({
success: true,
created,
updated,
notebookId: notebook.id,
books: books.length,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
console.error('[readwise/sync]', message)
return NextResponse.json({ error: message }, { status: 500 })
}
}
/**
* GET /api/integrations/readwise/sync
* Returns Readwise connection status (token configured or not).
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id },
select: { integrationTokens: true },
})
let connected = false
try {
const meta = typeof aiSettings?.integrationTokens === 'string'
? JSON.parse(aiSettings.integrationTokens)
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
connected = !!(meta?.readwiseToken)
} catch {
connected = false
}
return NextResponse.json({ connected })
}
/**
* DELETE /api/integrations/readwise/sync
* Removes the stored Readwise token.
*/
export async function DELETE() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId },
select: { integrationTokens: true },
})
if (aiSettings) {
try {
const meta = ((aiSettings.integrationTokens as Record<string, unknown> | null) ?? {})
delete meta.readwiseToken
await prisma.userAISettings.update({
where: { userId },
data: { integrationTokens: JSON.stringify(meta) },
})
} catch { /* no-op */ }
}
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
function getTodayTitle(): string {
return new Date().toISOString().slice(0, 10) // YYYY-MM-DD
}
function getTodayContent(dateStr: string): string {
const d = new Date(dateStr)
const options: Intl.DateTimeFormatOptions = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}
const formatted = d.toLocaleDateString('fr-FR', options)
return JSON.stringify({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: `📅 ${formatted}` }] },
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
],
})
}
/**
* GET /api/notes/daily
* Returns (or creates) today's daily note for the authenticated user.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const today = getTodayTitle()
const userId = session.user.id
// Try to find existing daily note for today
let note = await prisma.note.findFirst({
where: {
userId,
title: today,
type: 'daily',
trashedAt: null,
},
})
if (!note) {
note = await prisma.note.create({
data: {
userId,
title: today,
content: getTodayContent(today),
type: 'daily',
color: '#FEF9C3', // yellow-100 — distinguishes daily notes visually
labels: JSON.stringify(['daily']),
},
})
}
return NextResponse.json({ success: true, note })
}

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

View File

@@ -0,0 +1,87 @@
/// <reference types="@types/dom-speech-recognition" />
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
type VoiceState = 'idle' | 'listening' | 'processing' | 'error'
interface UseVoiceTranscriptionOptions {
onTranscript: (text: string) => void
onError?: (message: string) => void
lang?: string
}
export function useVoiceTranscription({ onTranscript, onError, lang = 'fr-FR' }: UseVoiceTranscriptionOptions) {
const [state, setState] = useState<VoiceState>('idle')
const recognitionRef = useRef<SpeechRecognition | null>(null)
const accumulatedRef = useRef<string>('')
const isSupported =
typeof window !== 'undefined' &&
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
const start = useCallback(() => {
if (!isSupported) {
onError?.('La reconnaissance vocale n\'est pas disponible sur ce navigateur.')
setState('error')
return
}
const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
const rec: SpeechRecognition = new SR()
rec.lang = lang
rec.continuous = true
rec.interimResults = false
accumulatedRef.current = ''
rec.onstart = () => setState('listening')
rec.onresult = (event: SpeechRecognitionEvent) => {
let transcript = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
if (event.results[i].isFinal) {
transcript += event.results[i][0].transcript
}
}
if (transcript) accumulatedRef.current += (accumulatedRef.current ? ' ' : '') + transcript
}
rec.onerror = (event: SpeechRecognitionErrorEvent) => {
console.error('[voice] SpeechRecognition error:', event.error)
onError?.(event.error === 'not-allowed' ? 'Microphone non autorisé.' : `Erreur: ${event.error}`)
setState('error')
}
rec.onend = () => {
setState('idle')
if (accumulatedRef.current.trim()) {
onTranscript(accumulatedRef.current.trim())
accumulatedRef.current = ''
}
}
recognitionRef.current = rec
rec.start()
}, [isSupported, lang, onTranscript, onError])
const stop = useCallback(() => {
recognitionRef.current?.stop()
recognitionRef.current = null
}, [])
const toggle = useCallback(() => {
if (state === 'listening') {
stop()
} else {
start()
}
}, [state, start, stop])
useEffect(() => {
return () => {
recognitionRef.current?.stop()
}
}, [])
return { state, toggle, start, stop, isSupported }
}

View File

@@ -75,12 +75,16 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
semantic_search: 30,
auto_tag: 15,
auto_title: 5,
chat: 10,
reformulate: 10,
brainstorm_create: 1,
brainstorm_expand: 10,
brainstorm_enrich: 20,
suggest_charts: 5,
slide_generate: 3,
excalidraw_generate: 3,
ai_flashcard: 5,
voice_transcribe: 20,
},
PRO: {
semantic_search: 100,
@@ -94,6 +98,8 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
suggest_charts: 50,
slide_generate: 20,
excalidraw_generate: 20,
ai_flashcard: 100,
voice_transcribe: 500,
},
BUSINESS: {
semantic_search: 1000,
@@ -107,6 +113,8 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
suggest_charts: 200,
slide_generate: 100,
excalidraw_generate: 100,
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
},
ENTERPRISE: {
semantic_search: 'unlimited',
@@ -120,6 +128,8 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
suggest_charts: 'unlimited',
slide_generate: 'unlimited',
excalidraw_generate: 'unlimited',
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
},
};

View File

@@ -10,6 +10,8 @@ export const VALID_FEATURES = [
'suggest_charts',
'slide_generate',
'excalidraw_generate',
'ai_flashcard',
'voice_transcribe',
] as const;
export type FeatureName = (typeof VALID_FEATURES)[number];

View File

@@ -77,7 +77,9 @@
"clearSearch": "Clear search",
"insightsPanelBody": "Semantic map of your notes: thematic clusters, bridge notes, and connection suggestions.",
"revisionPanelBody": "Review flashcards with the SM-2 algorithm. Decks are generated from your notes.",
"backToNotebooks": "Back to notebooks"
"backToNotebooks": "Back to notebooks",
"dailyNote": "Daily Note",
"dailyNoteError": "Could not open today's note"
},
"notes": {
"title": "Notes",
@@ -2188,6 +2190,22 @@
"excalidrawGenerator": {
"name": "Diagram Generator",
"description": "Reads a note and generates a visual diagram in the Excalidraw Lab."
},
"dailyDigest": {
"name": "Daily Digest",
"description": "Summarizes your notes from the day and creates a daily recap in your main notebook."
},
"weeklyRecap": {
"name": "Weekly Recap",
"description": "Analyzes your weekly notes and produces a summary of key themes, decisions and tasks."
},
"autoTagger": {
"name": "Auto-Tagger",
"description": "Scans your notes without labels and automatically suggests relevant tags based on content."
},
"knowledgeSynthesis": {
"name": "Knowledge Synthesis",
"description": "Groups related notes by theme and creates a synthesis note with identified connections."
}
},
"runLog": {
@@ -3566,5 +3584,12 @@
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
"hint_insights_refresh_title": "Refresh clusters",
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
},
"integrations": {
"title": "Integrations"
},
"editor": {
"voiceStart": "Dictate text (microphone)",
"voiceStop": "Stop dictation"
}
}

View File

@@ -77,7 +77,9 @@
"clearSearch": "Effacer la recherche",
"insightsPanelBody": "Cartographie sémantique de vos notes : clusters thématiques, notes-ponts et suggestions de connexion.",
"revisionPanelBody": "Révisez vos flashcards avec l'algorithme SM-2. Les decks sont générés depuis vos notes.",
"backToNotebooks": "Retour aux carnets"
"backToNotebooks": "Retour aux carnets",
"dailyNote": "Note du jour",
"dailyNoteError": "Impossible d'ouvrir la note du jour"
},
"notes": {
"title": "Notes",
@@ -2192,6 +2194,22 @@
"excalidrawGenerator": {
"name": "Générateur de Diagrammes",
"description": "Lit une note et génère un diagramme visuel dans le Lab Excalidraw."
},
"dailyDigest": {
"name": "Digest Quotidien",
"description": "Résume vos notes de la journée et crée un récapitulatif quotidien dans votre carnet principal."
},
"weeklyRecap": {
"name": "Récap Hebdomadaire",
"description": "Analyse vos notes de la semaine et produit un résumé des thèmes clés, décisions et tâches."
},
"autoTagger": {
"name": "Auto-Tagueur",
"description": "Parcourt vos notes sans labels et suggère automatiquement des tags pertinents basés sur le contenu."
},
"knowledgeSynthesis": {
"name": "Synthèse de Connaissances",
"description": "Regroupe les notes liées par thème et crée une note de synthèse avec les connexions identifiées."
}
},
"runLog": {
@@ -3570,5 +3588,12 @@
"hint_insights_bridge_desc": "Les notes ponts relient plusieurs clusters. Elles sont mises en avant car elles constituent les connexions clés de votre graphe de connaissances.",
"hint_insights_refresh_title": "Rafraîchir les clusters",
"hint_insights_refresh_desc": "Si vous avez ajouté de nouvelles notes, cliquez sur le bouton de rafraîchissement pour recalculer les clusters avec le contenu le plus récent."
},
"integrations": {
"title": "Intégrations"
},
"editor": {
"voiceStart": "Dicter du texte (microphone)",
"voiceStop": "Arrêter la dictée"
}
}

View File

@@ -135,6 +135,7 @@
"@types/canvas-confetti": "^1.9.0",
"@types/dagre": "^0.7.54",
"@types/diff": "^7.0.2",
"@types/dom-speech-recognition": "^0.0.11",
"@types/ioredis": "^4.28.10",
"@types/jszip": "^3.4.0",
"@types/node": "^20",
@@ -8591,6 +8592,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dom-speech-recognition": {
"version": "0.0.11",
"resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.11.tgz",
"integrity": "sha512-PyLFPLM9F5D+qEmkNLX/ZC3uiEV/2B/UhZA9uhWkFVOxUyDVj+UBKI2pF1dnhKhliOiIoR1d/QsOZQfOtQPE3A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",

View File

@@ -156,6 +156,7 @@
"@types/canvas-confetti": "^1.9.0",
"@types/dagre": "^0.7.54",
"@types/diff": "^7.0.2",
"@types/dom-speech-recognition": "^0.0.11",
"@types/ioredis": "^4.28.10",
"@types/jszip": "^3.4.0",
"@types/node": "^20",

View File

@@ -0,0 +1,2 @@
-- AlterTable: add integrationTokens column to UserAISettings
ALTER TABLE "UserAISettings" ADD COLUMN IF NOT EXISTS "integrationTokens" JSONB;

View File

@@ -342,6 +342,7 @@ model UserAISettings {
noteHistoryMode String @default("manual")
autoSave Boolean @default(true)
aiProcessingConsent Boolean @default(false)
integrationTokens Json? // Stores third-party integration tokens (Readwise, Calendar, etc.)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([memoryEcho])