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>
88 lines
2.5 KiB
TypeScript
88 lines
2.5 KiB
TypeScript
/// <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 }
|
|
}
|