Une conversation, plusieurs surfaces — miroir vocal et conversationId
Le chat écrit, le vocal et le déclenchement proactif partagent désormais une seule conversation, portée par VisitAppContext.assistant. Les trois AssistantService séparés ont disparu : poser une question aux lunettes puis ouvrir le chat donnait un guide qui ne savait rien de ce qu'on venait de demander, et produisait deux lignes VisitorQuestion sans lien pour un visiteur qui avait simplement changé de surface. Le maxHistory du vocal (6) s'aligne sur 10 : deux surfaces qui partagent une conversation ne peuvent pas la tronquer différemment selon le point d'entrée. La concision vocale vient d'isVoice, qui change le prompt côté serveur. Le vrai obstacle n'était pas l'affichage mais la forme du chat : AssistantChatSheet gardait ses messages en List<Widget>, des bulles déjà construites. On ne rejoue pas une conversation à partir de widgets, et un tour vocal survenu pendant que la feuille était fermée n'aurait jamais pu y entrer. Le service porte maintenant les tours en données et notifie ses écouteurs. Deux listes, délibérément : celle envoyée au modèle et celle affichée ne coïncident pas. Un message d'erreur se montre sans repartir au guide, et le prompt d'un déclenchement proactif part au guide sans jamais s'afficher — seule sa réponse apparaît, marquée « À voix haute ». Sans ce marquage, le visiteur trouverait dans son chat des messages qu'il n'a jamais tapés. conversationId est enfin envoyé, et renouvelé quand la conversation est vidée : sinon la visite entière d'un visiteur n'en formerait qu'une. flutter analyze lib sans erreur, flutter build apk --debug --flavor dev vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6e6b40e185
commit
591775cd30
@ -57,7 +57,6 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
|
||||
late AssistantService _assistantService;
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<Widget> _bubbles = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
final SpeechToText _speech = SpeechToText();
|
||||
@ -67,10 +66,20 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
|
||||
@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<void> _initSpeech() async {
|
||||
final available = await _speech.initialize();
|
||||
if (mounted) setState(() => _speechAvailable = available);
|
||||
@ -98,6 +107,9 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
|
||||
|
||||
@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<AssistantChatSheet> {
|
||||
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<AssistantChatSheet> {
|
||||
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<AssistantChatSheet> {
|
||||
}
|
||||
} 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<AssistantChatSheet> {
|
||||
),
|
||||
),
|
||||
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<AssistantChatSheet> {
|
||||
: 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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<AiChatMessage> _history = [];
|
||||
final List<AssistantTurn> _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<AssistantTurn> 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<AssistantResponse> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user