import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/AssistantResponse.dart'; /// Levée quand l'instance a épuisé son quota IA du mois (HTTP 429). /// /// Distincte d'une panne : le visiteur ne doit pas être invité à réessayer, ça /// le ferait boucler sur un mur. L'appelant affiche un message neutre, sans /// jamais mentionner le motif — c'est une affaire entre le client et nous. class AssistantUnavailableException implements Exception { const AssistantUnavailableException(); } /// 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é. /// null = pas de vidage automatique. 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, bool isVoice = false, bool isAutoTriggered = false, }) => chatWithAppType( message: message, configurationId: configurationId, isVoice: isVoice, isAutoTriggered: isAutoTriggered, ); /// [isAutoTriggered] : le tour vient du mode proactif, pas d'une question du visiteur. /// Le serveur compte les jetons mais ne le journalise pas dans `VisitorQuestion`. Future chatWithAppType({ required String message, String? configurationId, AppType appType = AppType.Mobile, bool isVoice = false, bool isAutoTriggered = false, }) 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, ); final AiChatResponse? response; try { response = await visitAppContext.clientAPI.aiApi!.aiChat(request); } on ApiException catch (e) { if (e.code == 429) throw const AssistantUnavailableException(); rethrow; } if (response == null) { throw Exception('Empty response from assistant'); } debugPrint("AI raw response: reply='${response.reply}' navigation.sectionId='${response.navigation?.sectionId}' navigation.sectionTitle='${response.navigation?.sectionTitle}' navigation.sectionType='${response.navigation?.sectionType}' cards=${response.cards?.length}"); final result = AssistantResponse( reply: response.reply ?? '', cards: response.cards ?.map((c) => AiCard( title: c.title ?? '', subtitle: c.subtitle ?? '', icon: c.icon, )) .toList(), navigation: response.navigation != null ? AssistantNavigationAction( sectionId: response.navigation!.sectionId ?? '', sectionTitle: response.navigation!.sectionTitle ?? '', sectionType: response.navigation!.sectionType ?? '', imageUrl: response.navigation!.imageUrl, ) : null, expectsReply: response.expectsReply ?? true, ); // ⚠️ 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 if (_history.length > maxHistory) { _history.removeRange(0, _history.length - maxHistory); } _turns.add(AssistantTurn( text: result.reply, isUser: false, isVoice: isVoice, response: result, )); notifyListeners(); return result; } void _resetInactivityTimer() { if (inactivityTimeout == null) return; _inactivityTimer?.cancel(); _inactivityTimer = Timer(inactivityTimeout!, () { debugPrint('[AssistantService] Inactivity timeout — clearing history'); clearHistory(); }); } /// 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(); } }