diff --git a/lib/Components/AssistantChatSheet.dart b/lib/Components/AssistantChatSheet.dart index 7bcc2c1..634f999 100644 --- a/lib/Components/AssistantChatSheet.dart +++ b/lib/Components/AssistantChatSheet.dart @@ -57,7 +57,6 @@ class _AssistantChatSheetState extends State { late AssistantService _assistantService; final TextEditingController _controller = TextEditingController(); final ScrollController _scrollController = ScrollController(); - final List _bubbles = []; bool _isLoading = false; final SpeechToText _speech = SpeechToText(); @@ -67,10 +66,20 @@ class _AssistantChatSheetState extends State { @override void initState() { super.initState(); - _assistantService = AssistantService(visitAppContext: widget.visitAppContext); + // Conversation partagée avec le vocal et le proactif : à l'ouverture, la feuille + // affiche déjà ce qui s'est dit aux lunettes, et se met à jour en direct si le + // guide parle pendant qu'elle est ouverte. + _assistantService = widget.visitAppContext.assistant; + _assistantService.addListener(_onConversationChanged); _initSpeech(); } + void _onConversationChanged() { + if (!mounted) return; + setState(() {}); + _scrollToBottom(); + } + Future _initSpeech() async { final available = await _speech.initialize(); if (mounted) setState(() => _speechAvailable = available); @@ -98,6 +107,9 @@ class _AssistantChatSheetState extends State { @override void dispose() { + // Le service survit à la feuille — il porte la conversation, pas l'écran. On se + // désabonne, on ne le dispose pas. + _assistantService.removeListener(_onConversationChanged); _speech.cancel(); super.dispose(); } @@ -107,10 +119,9 @@ class _AssistantChatSheetState extends State { if (text.isEmpty || _isLoading) return; _controller.clear(); - setState(() { - _bubbles.add(_ChatBubble(text: text, isUser: true)); - _isLoading = true; - }); + // Le tour du visiteur est ajouté par le service, qui prévient ses écouteurs : + // rien à empiler ici, sinon il s'afficherait deux fois. + setState(() => _isLoading = true); _scrollToBottom(); try { @@ -118,12 +129,6 @@ class _AssistantChatSheetState extends State { message: text, configurationId: widget.configurationId, ); - setState(() { - _bubbles.add(_AssistantMessage( - response: response, - onNavigate: widget.onNavigateToSection, - )); - }); // Pipe TTS vers les lunettes si connectées if (MetaGlassesService.instance.isConnected && response.reply.isNotEmpty) { @@ -135,17 +140,11 @@ class _AssistantChatSheetState extends State { } } on AssistantUnavailableException { // Quota épuisé : surtout ne pas inviter à réessayer, le visiteur boucterait. - setState(() { - _bubbles.add(_ChatBubble( - text: "Le guide se repose pour aujourd'hui. Revenez demain.", - isUser: false, - )); - }); + _assistantService.addServiceMessage( + "Le guide se repose pour aujourd'hui. Revenez demain."); } catch (e) { debugPrint('AssistantChatSheet error: $e'); - setState(() { - _bubbles.add(_ChatBubble(text: "Une erreur est survenue, réessayez.", isUser: false)); - }); + _assistantService.addServiceMessage("Une erreur est survenue, réessayez."); } finally { setState(() => _isLoading = false); _scrollToBottom(); @@ -274,9 +273,10 @@ class _AssistantChatSheetState extends State { ), ), const Divider(height: 1), - // Messages + // Messages — lus depuis la conversation partagée, donc ce qui a été dit + // aux lunettes est déjà là à l'ouverture. Expanded( - child: _bubbles.isEmpty + child: _assistantService.turns.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(24), @@ -318,8 +318,22 @@ class _AssistantChatSheetState extends State { : ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - itemCount: _bubbles.length, - itemBuilder: (_, i) => _bubbles[i], + itemCount: _assistantService.turns.length, + itemBuilder: (_, i) { + final turn = _assistantService.turns[i]; + if (turn.response != null) { + return _AssistantMessage( + response: turn.response!, + isVoice: turn.isVoice, + onNavigate: widget.onNavigateToSection, + ); + } + return _ChatBubble( + text: turn.text, + isUser: turn.isUser, + isVoice: turn.isVoice, + ); + }, ), ), // Le guide rédige — trois points plutôt qu'un spinner : la réponse @@ -540,13 +554,41 @@ class _ChatBubble extends StatelessWidget { final String text; final bool isUser; - const _ChatBubble({required this.text, required this.isUser}); + /// Le tour est passé par la voix. Une petite icône le signale — sans elle, le + /// visiteur qui ouvre le chat ne comprend pas d'où sortent des messages qu'il + /// n'a jamais tapés. + final bool isVoice; + + const _ChatBubble({required this.text, required this.isUser, this.isVoice = false}); @override Widget build(BuildContext context) { return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, - child: Container( + child: Column( + crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if (isVoice) + Padding( + padding: const EdgeInsets.only(top: 4, left: 6, right: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, size: 11, color: Colors.grey[500]), + const SizedBox(width: 3), + Text('À voix haute', + style: TextStyle(fontSize: 10.5, color: Colors.grey[500])), + ], + ), + ), + _bubble(context), + ], + ), + ); + } + + Widget _bubble(BuildContext context) { + return Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.78), @@ -568,16 +610,20 @@ class _ChatBubble extends StatelessWidget { text, textStyle: TextStyle(color: kSecondGrey, fontSize: 14), ), - ), ); } } class _AssistantMessage extends StatelessWidget { final AssistantResponse response; + final bool isVoice; final void Function(String sectionId, String sectionTitle)? onNavigate; - const _AssistantMessage({required this.response, this.onNavigate}); + const _AssistantMessage({ + required this.response, + this.isVoice = false, + this.onNavigate, + }); @override Widget build(BuildContext context) { @@ -587,7 +633,7 @@ class _AssistantMessage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (response.reply.isNotEmpty) - _ChatBubble(text: response.reply, isUser: false), + _ChatBubble(text: response.reply, isUser: false, isVoice: isVoice), if (response.cards != null && response.cards!.isNotEmpty) Padding( diff --git a/lib/Models/visitContext.dart b/lib/Models/visitContext.dart index 3a8a745..a4fd2b5 100644 --- a/lib/Models/visitContext.dart +++ b/lib/Models/visitContext.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Models/articleRead.dart'; +import 'package:mymuseum_visitapp/Services/assistantService.dart'; import 'package:mymuseum_visitapp/Services/statisticsService.dart'; import 'package:mymuseum_visitapp/Models/beaconSection.dart'; import 'package:mymuseum_visitapp/Models/resourceModel.dart'; @@ -33,6 +34,17 @@ class VisitAppContext with ChangeNotifier { ApplicationInstanceDTO? applicationInstanceDTO; // null = assistant non activé StatisticsService? statisticsService; + AssistantService? _assistant; + + /// **La** conversation du visiteur avec le guide, partagée par le chat écrit, le + /// vocal et le déclenchement proactif. Créée à la première utilisation. + /// + /// ⚠️ Chaque surface construisait auparavant la sienne : changer de surface donnait + /// un guide amnésique et coupait la conversation en deux côté serveur. Ne pas + /// réintroduire de `AssistantService(...)` ailleurs — c'est ce getter, ou rien. + AssistantService get assistant => + _assistant ??= AssistantService(visitAppContext: this); + /// Retourne l'AppConfigurationLink de l'instance mobile pour la configuration courante. /// Contient roundedValue, isSectionImageBackground, etc. AppConfigurationLink? get currentAppConfigurationLink { diff --git a/lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart b/lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart index a55bf39..cb51a70 100644 --- a/lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart +++ b/lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart @@ -12,15 +12,14 @@ import 'package:mymuseum_visitapp/Services/Glasses/engines/llm_client.dart'; /// - Prompt audio-friendly : réponses courtes, sans markdown, max 30s à l'oral class MyInfoMateLlmClient implements LlmClient { final VisitAppContext visitAppContext; - late final AssistantService _service; - MyInfoMateLlmClient({required this.visitAppContext}) { - _service = AssistantService( - visitAppContext: visitAppContext, - maxHistory: 6, - inactivityTimeout: const Duration(minutes: 5), - ); - } + /// ⚠️ La conversation est **partagée** avec le chat écrit et le mode proactif — + /// une conversation, plusieurs surfaces. Ce client construisait la sienne + /// (`maxHistory: 6`), si bien que poser une question aux lunettes puis ouvrir le + /// chat donnait un guide qui ne savait rien de ce qui venait d'être demandé. + AssistantService get _service => visitAppContext.assistant; + + MyInfoMateLlmClient({required this.visitAppContext}); @override Future<({String reply, bool expectsReply})> chat( diff --git a/lib/Services/assistantService.dart b/lib/Services/assistantService.dart index ef2c952..6350c91 100644 --- a/lib/Services/assistantService.dart +++ b/lib/Services/assistantService.dart @@ -13,10 +13,48 @@ class AssistantUnavailableException implements Exception { const AssistantUnavailableException(); } -class AssistantService { +/// Un tour de conversation **tel qu'il s'affiche**, par opposition à l'historique +/// envoyé au modèle. Les deux ne coïncident pas : un message d'erreur se montre au +/// visiteur sans être renvoyé au guide, et le prompt d'un déclenchement proactif +/// part au guide sans jamais s'afficher. +class AssistantTurn { + final String text; + final bool isUser; + + /// Le tour est passé par la voix — lunettes, mode vocal ou déclenchement + /// proactif. C'est ce qui permet au chat de montrer d'où vient chaque message. + final bool isVoice; + + /// Réponse complète (cartes, navigation). Null pour un tour du visiteur ou un + /// message de service. + final AssistantResponse? response; + + const AssistantTurn({ + required this.text, + required this.isUser, + this.isVoice = false, + this.response, + }); +} + +/// Conversation du visiteur avec le guide, **une seule par session de visite**. +/// +/// ⚠️ Il y avait auparavant une instance par surface — le chat écrit, le vocal et le +/// déclenchement proactif en construisaient chacun une. Poser une question aux +/// lunettes puis ouvrir le chat du téléphone donnait donc un interlocuteur qui ne +/// savait rien de ce qui venait d'être demandé, et produisait **deux lignes +/// `VisitorQuestion` sans lien** pour un même visiteur qui avait simplement changé +/// de surface. Une conversation, deux surfaces : l'instance est portée par +/// [VisitAppContext.assistant] et partagée. +class AssistantService extends ChangeNotifier { final VisitAppContext visitAppContext; /// Nombre maximum de messages conservés dans l'historique envoyé au backend. + /// + /// ⚠️ Le vocal utilisait 6 et le chat 10. Les deux surfaces partageant désormais + /// une conversation, tronquer différemment selon le point d'entrée n'a plus de + /// sens — c'est 10 pour tout le monde. La concision des réponses vocales est + /// obtenue par `isVoice`, qui change le prompt côté serveur, pas par l'historique. final int maxHistory; /// Durée d'inactivité après laquelle l'historique est automatiquement vidé. @@ -24,14 +62,42 @@ class AssistantService { final Duration? inactivityTimeout; final List _history = []; + final List _turns = []; Timer? _inactivityTimer; + /// GUID de session, seul lien entre deux tours côté serveur. + /// + /// ⚠️ Personne ne l'envoyait : `AiController` retombait alors sur un + /// `Guid.NewGuid()` par appel, donc **chaque question était une conversation + /// isolée en base**, y compris deux questions d'affilée dans le même chat. Les + /// agrégats du Guide IA ne reliaient rien. + /// + /// Il change avec l'historique : quand la conversation est vidée, la suivante en + /// est une autre. Sinon la visite entière d'un même visiteur n'en formerait qu'une. + String _conversationId = _newConversationId(); + + String get conversationId => _conversationId; + + /// Les tours à afficher, du plus ancien au plus récent. + List get turns => List.unmodifiable(_turns); + AssistantService({ required this.visitAppContext, this.maxHistory = 10, this.inactivityTimeout = const Duration(minutes: 5), }); + static String _newConversationId() => + '${DateTime.now().microsecondsSinceEpoch}-${Object().hashCode}'; + + /// Message affiché au visiteur sans être renvoyé au guide : erreur, quota épuisé. + /// Il n'entre pas dans [_history] — le modèle n'a pas à s'excuser d'une panne au + /// tour suivant. + void addServiceMessage(String text) { + _turns.add(AssistantTurn(text: text, isUser: false)); + notifyListeners(); + } + Future chat({ required String message, String? configurationId, @@ -55,12 +121,21 @@ class AssistantService { }) async { _resetInactivityTimer(); + // Le prompt d'un déclenchement proactif est une consigne machine : il ne + // s'affiche pas. La réponse, elle, s'affichera — c'est ce que le visiteur a + // entendu, et c'est tout l'objet du miroir. + if (!isAutoTriggered) { + _turns.add(AssistantTurn(text: message, isUser: true, isVoice: isVoice)); + notifyListeners(); + } + final request = AiChatRequest( message: message, instanceId: visitAppContext.instanceId, appType: appType, configurationId: configurationId, language: visitAppContext.language?.toUpperCase() ?? 'FR', + conversationId: _conversationId, history: List.from(_history), isVoice: isVoice, isAutoTriggered: isAutoTriggered, @@ -100,7 +175,13 @@ class AssistantService { expectsReply: response.expectsReply ?? true, ); - _history.add(AiChatMessage(role: 'user', content: message)); + // ⚠️ Le prompt d'un déclenchement proactif n'entre pas dans l'historique : le + // modèle n'a pas à relire « Tu es un guide audio de musée… » au tour suivant. + // Sa réponse, si — c'est ce que le visiteur a entendu, et ça rend « tu peux + // répéter ? » compréhensible depuis le chat comme depuis la voix. + if (!isAutoTriggered) { + _history.add(AiChatMessage(role: 'user', content: message)); + } _history.add(AiChatMessage(role: 'assistant', content: result.reply)); // Cap local — inutile de garder plus que maxHistory côté client @@ -108,6 +189,14 @@ class AssistantService { _history.removeRange(0, _history.length - maxHistory); } + _turns.add(AssistantTurn( + text: result.reply, + isUser: false, + isVoice: isVoice, + response: result, + )); + notifyListeners(); + return result; } @@ -120,12 +209,19 @@ class AssistantService { }); } + /// Vide la conversation. Le `conversationId` est renouvelé : ce qui suit est une + /// autre conversation, et le serveur doit pouvoir les distinguer. void clearHistory() { _history.clear(); + _turns.clear(); + _conversationId = _newConversationId(); _inactivityTimer?.cancel(); + notifyListeners(); } + @override void dispose() { _inactivityTimer?.cancel(); + super.dispose(); } } diff --git a/lib/Services/geo_beacon_trigger_service.dart b/lib/Services/geo_beacon_trigger_service.dart index 6edb2d2..a27a287 100644 --- a/lib/Services/geo_beacon_trigger_service.dart +++ b/lib/Services/geo_beacon_trigger_service.dart @@ -63,7 +63,9 @@ class GeoBeaconTriggerService { if (!Platform.isAndroid && !Platform.isIOS) return; _visitAppContext = visitAppContext; - _assistantService = AssistantService(visitAppContext: visitAppContext); + // Conversation partagée : ce que le guide dit à l'approche d'une œuvre apparaît + // dans le chat, et la question suivante du visiteur en tient compte. + _assistantService = visitAppContext.assistant; _geoPoints ..clear() ..addAll(geoPoints);