From 76ad3b62fda4f3a72e21c834f892497e4f4ff719 Mon Sep 17 00:00:00 2001 From: Kantin-Petit Date: Tue, 23 Jun 2026 07:22:18 +0200 Subject: [PATCH] feat: voix navigateur (STT push-to-talk + TTS) (v0.28.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook useSpeech (Web Speech API, fr-FR) : micro dicter→envoyer + lecture vocale des réponses (bascule persistée). 100% navigateur, zéro backend/GPU, dégrade si non supporté. Build OK. Palier de risque : reversible (front). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 7 +++ web/src/pages/Chat.tsx | 100 +++++++++++++++++++++++++++----------- web/src/useSpeech.ts | 106 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 27 deletions(-) create mode 100644 web/src/useSpeech.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d517e..5b3c0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.28.0] — 2026-06-23 — début Phase 6 (voix) +### Added +- `web/src/useSpeech.ts` : hook voix 100 % navigateur (Web Speech API), STT + (push-to-talk, fr-FR) + TTS, dégrade proprement si non supporté. Zéro backend/GPU. +- Chat : bouton micro (dicter → envoyer), bascule "Voix ON/OFF" (persistée) qui lit + les réponses à voix haute. Pas d'emoji comme icône (texte). Build OK. + ## [0.27.0] — 2026-06-23 — fin Phase 5 (auto-extension v1) ### Added - Outil local **`chlova.propose_asset`** (`src/autoext/tool.ts`) exposé à l'agent : diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index a9fb07d..6d2f7a9 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { useAuth } from "../auth"; import { api, ApiError } from "../api"; +import { useSpeech } from "../useSpeech"; interface Msg { role: "user" | "assistant"; @@ -9,52 +10,75 @@ interface Msg { export function Chat() { const { token, logout } = useAuth(); + const speech = useSpeech(); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [speakReplies, setSpeakReplies] = useState(() => localStorage.getItem("chlova.speak") === "1"); const bottom = useRef(null); useEffect(() => { bottom.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, busy]); - const send = async (e: FormEvent): Promise => { - e.preventDefault(); - const text = input.trim(); - if (!text || busy || !token) return; - setInput(""); - setError(null); - setMessages((m) => [...m, { role: "user", text }]); - setBusy(true); - try { - const { reply } = await api.chat(token, text); - setMessages((m) => [...m, { role: "assistant", text: reply }]); - } catch (err) { - if (err instanceof ApiError && err.status === 401) { - logout(); - return; + const toggleSpeak = (): void => { + setSpeakReplies((v) => { + const next = !v; + localStorage.setItem("chlova.speak", next ? "1" : "0"); + if (!next) speech.cancelSpeak(); + return next; + }); + }; + + const sendText = useCallback( + async (text: string): Promise => { + const t = text.trim(); + if (!t || busy || !token) return; + setInput(""); + setError(null); + setMessages((m) => [...m, { role: "user", text: t }]); + setBusy(true); + try { + const { reply } = await api.chat(token, t); + setMessages((m) => [...m, { role: "assistant", text: reply }]); + if (speakReplies) speech.speak(reply); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + logout(); + return; + } + setError(err instanceof Error ? err.message : "Erreur"); + } finally { + setBusy(false); } - setError(err instanceof Error ? err.message : "Erreur"); - } finally { - setBusy(false); + }, + [busy, token, speakReplies, speech, logout], + ); + + const submit = (e: FormEvent): void => { + e.preventDefault(); + void sendText(input); + }; + + const mic = (): void => { + if (speech.listening) { + speech.stopListening(); + return; } + speech.listen((text) => void sendText(text)); // dicter → envoyer }; return (
- {messages.length === 0 && ( -

Pose une question à CHLOVA…

- )} + {messages.length === 0 &&

Pose une question à CHLOVA…

} {messages.map((m, i) => (
{m.text} @@ -62,18 +86,40 @@ export function Chat() {
))} {busy &&

CHLOVA réfléchit…

} + {speech.speaking &&

Lecture vocale…

} {error &&

{error}

}
-
+ + {speech.ttsSupported && ( + + )} setInput(e.target.value)} disabled={busy} /> + {speech.sttSupported && ( + + )}