diff --git a/lib/Screens/GuideIa/guide_ia_screen.dart b/lib/Screens/GuideIa/guide_ia_screen.dart index 4603bcf..84cbcae 100644 --- a/lib/Screens/GuideIa/guide_ia_screen.dart +++ b/lib/Screens/GuideIa/guide_ia_screen.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:manager_api_new/api.dart'; @@ -9,6 +10,7 @@ import 'package:manager_app/constants.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Screens/Applications/app_configuration_link_screen.dart'; +import 'package:manager_app/Services/ai_translate_service.dart'; import 'package:manager_app/Screens/GuideIa/visitor_questions_tab.dart'; import 'package:provider/provider.dart'; @@ -38,6 +40,10 @@ class _GuideIaScreenState extends State { /// Ids des canaux dont l'activation est en cours — chaque ligne bascule indépendamment. final Set _togglingChannels = {}; + /// Formulations de repli telles que chargées, pour repérer une modification : + /// une traduction ne vaut plus rien dès que sa phrase source change. + List _loadedFallbacks = []; + int _tab = 0; final _previewController = TextEditingController(); @@ -76,9 +82,18 @@ class _GuideIaScreenState extends State { ManagerAppContext get _managerContext => _appContext.getContext() as ManagerAppContext; /// Langue dans laquelle le gestionnaire saisit ses messages de repli. - /// Les autres langues sont produites par la traduction automatique, comme le reste du contenu. + /// Les autres langues sont produites par le bouton de traduction, comme le reste du contenu. String get _editingLanguage => 'FR'; + /// Langues réellement servies aux visiteurs : l'union de celles déclarées par les canaux. + /// Traduire vers les dix langues supportées gaspillerait le quota IA du client. + List get _visitorLanguages => (_instance?.applicationInstanceDTOs ?? []) + .expand((app) => app.languages ?? const []) + .map((lang) => lang.toUpperCase()) + .where((lang) => lang != _editingLanguage) + .toSet() + .toList(); + Future _load() async { final ctx = _managerContext; final instanceId = ctx.instanceId; @@ -104,6 +119,7 @@ class _GuideIaScreenState extends State { if (_fallbackControllers.isEmpty) { _fallbackControllers.add(TextEditingController()); } + _loadedFallbacks = _fallbackControllers.map((c) => c.text.trim()).where((t) => t.isNotEmpty).toList(); setState(() { _instance = instance; @@ -176,6 +192,44 @@ class _GuideIaScreenState extends State { } } + /// Traduit les formulations de repli vers les langues des visiteurs. Sans elles, + /// `BuildFallbackInstruction` côté serveur ne trouve rien dans la langue du visiteur + /// et laisse le modèle improviser son refus — les phrases du client sont perdues. + /// Traduit les formulations de repli vers les langues des visiteurs, en ne rappelant + /// l'IA que pour les langues qui en ont besoin : celles qui n'ont encore rien, et + /// toutes les autres si une phrase source a changé depuis le chargement. + /// Renvoie null si l'appel a échoué, pour que l'enregistrement le signale. + Future?> _translateFallbacks(List sources, List existing) async { + final ctx = _managerContext; + final sourcesChanged = !listEquals(sources, _loadedFallbacks); + + final targets = _visitorLanguages + .where((lang) => sourcesChanged || !existing.any((m) => m.language == lang)) + .toList(); + + if (sources.isEmpty || targets.isEmpty) return const []; + + // Une requête par formulation : l'endpoint traduit un texte vers N langues. + final translated = []; + for (final source in sources) { + final result = await AiTranslateService.translate( + host: ctx.host!, + accessToken: ctx.accessToken!, + instanceId: ctx.instanceId!, + text: source, + sourceLang: _editingLanguage, + targetLangs: targets, + ); + for (final lang in targets) { + final value = result[lang]?.trim(); + if (value != null && value.isNotEmpty) { + translated.add(TranslationDTO(language: lang, value: value)); + } + } + } + return translated; + } + Future _save() async { final instance = _instance; if (instance == null) return; @@ -183,26 +237,46 @@ class _GuideIaScreenState extends State { setState(() => _saving = true); - // Les messages des autres langues sont préservés tels quels : on ne réécrit que la langue d'édition. - final otherLanguages = (instance.guideFallbackMessages) - .where((m) => m.language != _editingLanguage) - .toList(); - final edited = _fallbackControllers - .map((c) => c.text.trim()) - .where((t) => t.isNotEmpty) - .map((t) => TranslationDTO(language: _editingLanguage, value: t)) - .toList(); + final sources = _fallbackControllers.map((c) => c.text.trim()).where((t) => t.isNotEmpty).toList(); + final existing = instance.guideFallbackMessages.where((m) => m.language != _editingLanguage).toList(); + + // Sans traduction dans la langue du visiteur, le serveur laisse le modèle improviser + // son refus : les formulations du client seraient perdues hors du français. + List? translated; + String? translationError; + try { + translated = await _translateFallbacks(sources, existing); + } on AiTranslateException catch (e) { + translationError = e.message; + } catch (_) { + translationError = l.guideIaFallbackTranslateError; + } + if (!mounted) return; + + // Une traduction produite remplace celle de sa langue ; les autres restent en place. + // Plus aucune formulation source : les traductions n'ont plus d'objet et disparaissent. + final refreshedLanguages = (translated ?? []).map((m) => m.language).toSet(); + final preserved = sources.isEmpty + ? [] + : existing.where((m) => !refreshedLanguages.contains(m.language)).toList(); + final edited = sources.map((t) => TranslationDTO(language: _editingLanguage, value: t)).toList(); instance.guideName = _nameController.text.trim(); instance.guidePersonaPrompt = _personaController.text.trim(); instance.guideVoiceId = _voiceId; instance.isVisitorQuestionCollectionEnabled = _collectionEnabled; - instance.guideFallbackMessages = [...otherLanguages, ...edited]; + instance.guideFallbackMessages = [...preserved, ...edited, ...(translated ?? [])]; try { await _managerContext.clientAPI!.instanceApi!.instanceUpdateinstance(instance); if (!mounted) return; - showNotification(kSuccess, kWhite, l.guideIaSaved, context, null); + _loadedFallbacks = List.of(sources); + // Le reste est bien enregistré : l'échec de traduction se signale sans masquer ça. + if (translationError != null) { + showNotification(Colors.orange, kWhite, l.guideIaSavedTranslationFailed(translationError), context, null); + } else { + showNotification(kSuccess, kWhite, l.guideIaSaved, context, null); + } } catch (_) { if (!mounted) return; showNotification(kError, kWhite, l.guideIaSaveError, context, null); @@ -900,7 +974,7 @@ class _GuideIaScreenState extends State { } Widget _voiceCard(AppLocalizations l) { - final wakeword = _voiceId == kGuideVoiceMarco ? 'Marco' : 'Viva'; + final wakeword = _voiceId == kGuideVoiceMarco ? 'Hey Marco' : 'Hey Viva'; return _card( title: l.guideIaVoiceTitle, @@ -944,7 +1018,7 @@ class _GuideIaScreenState extends State { const SizedBox(height: 2), Text(description, style: TextStyle(fontSize: 12.5, color: kBodyTextColor)), const SizedBox(height: 8), - Text(l.guideIaVoiceWakeword(name), + Text(l.guideIaVoiceWakeword('Hey $name'), style: TextStyle(fontSize: 11.5, color: kBodyTextColor.withValues(alpha: 0.7))), ], ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5e75602..aab0418 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -80,7 +80,16 @@ "guideIaFallbackLabel": "When the guide doesn't know", "guideIaFallbackHint": "The guide picks one at random. With a single sentence it repeats it word for word, and that shows immediately.", "guideIaFallbackAdd": "Add a wording", - "guideIaFallbackTranslated": "Visitors see these sentences: they will be translated into their language, like the rest of your content.", + "guideIaFallbackTranslated": "Visitors see these sentences: on save, they are translated into your channels' languages.", + "guideIaFallbackTranslateError": "AI translation failed", + "guideIaSavedTranslationFailed": "Saved, but translating the wordings failed: {reason}", + "@guideIaSavedTranslationFailed": { + "placeholders": { + "reason": { + "type": "String" + } + } + }, "guideIaVoiceTitle": "Your guide out loud", "guideIaVoiceSub": "Visitors ask out loud and hear the answer — in the app, or in their connected glasses.", "guideIaVoiceFemale": "Female voice, warm", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index af22d7f..8956bb3 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -80,7 +80,16 @@ "guideIaFallbackLabel": "Quand le guide ne sait pas répondre", "guideIaFallbackHint": "Le guide en choisit une au hasard. Avec une seule phrase, il la répète à l'identique et ça se remarque tout de suite.", "guideIaFallbackAdd": "Ajouter une formulation", - "guideIaFallbackTranslated": "Ces phrases sont vues par vos visiteurs : elles seront traduites dans leur langue, comme le reste de vos contenus.", + "guideIaFallbackTranslated": "Ces phrases sont vues par vos visiteurs : à l'enregistrement, elles sont traduites dans les langues de vos canaux.", + "guideIaFallbackTranslateError": "Erreur lors de la traduction IA", + "guideIaSavedTranslationFailed": "Enregistré, mais la traduction des formulations a échoué : {reason}", + "@guideIaSavedTranslationFailed": { + "placeholders": { + "reason": { + "type": "String" + } + } + }, "guideIaVoiceTitle": "Votre guide à voix haute", "guideIaVoiceSub": "Le visiteur pose sa question à voix haute et entend la réponse — dans l'application, ou dans ses lunettes connectées.", "guideIaVoiceFemale": "Voix féminine, chaleureuse", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6e6cc9c..a74a25b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -553,9 +553,21 @@ abstract class AppLocalizations { /// No description provided for @guideIaFallbackTranslated. /// /// In fr, this message translates to: - /// **'Ces phrases sont vues par vos visiteurs : elles seront traduites dans leur langue, comme le reste de vos contenus.'** + /// **'Ces phrases sont vues par vos visiteurs : à l\'enregistrement, elles sont traduites dans les langues de vos canaux.'** String get guideIaFallbackTranslated; + /// No description provided for @guideIaFallbackTranslateError. + /// + /// In fr, this message translates to: + /// **'Erreur lors de la traduction IA'** + String get guideIaFallbackTranslateError; + + /// No description provided for @guideIaSavedTranslationFailed. + /// + /// In fr, this message translates to: + /// **'Enregistré, mais la traduction des formulations a échoué : {reason}'** + String guideIaSavedTranslationFailed(String reason); + /// No description provided for @guideIaVoiceTitle. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 99ded49..33c0b71 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -255,7 +255,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get guideIaFallbackTranslated => - 'Visitors see these sentences: they will be translated into their language, like the rest of your content.'; + 'Visitors see these sentences: on save, they are translated into your channels\' languages.'; + + @override + String get guideIaFallbackTranslateError => 'AI translation failed'; + + @override + String guideIaSavedTranslationFailed(String reason) { + return 'Saved, but translating the wordings failed: $reason'; + } @override String get guideIaVoiceTitle => 'Your guide out loud'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 25fdb37..e626d84 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -259,7 +259,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get guideIaFallbackTranslated => - 'Ces phrases sont vues par vos visiteurs : elles seront traduites dans leur langue, comme le reste de vos contenus.'; + 'Ces phrases sont vues par vos visiteurs : à l\'enregistrement, elles sont traduites dans les langues de vos canaux.'; + + @override + String get guideIaFallbackTranslateError => 'Erreur lors de la traduction IA'; + + @override + String guideIaSavedTranslationFailed(String reason) { + return 'Enregistré, mais la traduction des formulations a échoué : $reason'; + } @override String get guideIaVoiceTitle => 'Votre guide à voix haute'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index fc81918..59785f3 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -259,7 +259,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get guideIaFallbackTranslated => - 'Bezoekers zien deze zinnen: ze worden vertaald naar hun taal, net als de rest van uw inhoud.'; + 'Bezoekers zien deze zinnen: bij het opslaan worden ze vertaald naar de talen van uw kanalen.'; + + @override + String get guideIaFallbackTranslateError => 'Fout bij de AI-vertaling'; + + @override + String guideIaSavedTranslationFailed(String reason) { + return 'Opgeslagen, maar het vertalen van de formuleringen is mislukt: $reason'; + } @override String get guideIaVoiceTitle => 'Uw gids hardop'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 0d456b0..3fbb1f1 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -80,7 +80,16 @@ "guideIaFallbackLabel": "Wanneer de gids het niet weet", "guideIaFallbackHint": "De gids kiest er willekeurig een. Met één zin herhaalt hij die letterlijk, en dat valt meteen op.", "guideIaFallbackAdd": "Formulering toevoegen", - "guideIaFallbackTranslated": "Bezoekers zien deze zinnen: ze worden vertaald naar hun taal, net als de rest van uw inhoud.", + "guideIaFallbackTranslated": "Bezoekers zien deze zinnen: bij het opslaan worden ze vertaald naar de talen van uw kanalen.", + "guideIaFallbackTranslateError": "Fout bij de AI-vertaling", + "guideIaSavedTranslationFailed": "Opgeslagen, maar het vertalen van de formuleringen is mislukt: {reason}", + "@guideIaSavedTranslationFailed": { + "placeholders": { + "reason": { + "type": "String" + } + } + }, "guideIaVoiceTitle": "Uw gids hardop", "guideIaVoiceSub": "De bezoeker stelt zijn vraag hardop en hoort het antwoord — in de app of in zijn verbonden bril.", "guideIaVoiceFemale": "Vrouwelijke stem, warm",