manager-app/lib/Screens/GuideIa/guide_ia_screen.dart
Thomas Fransolet a6463acce6 Guide IA : libellés de canaux, activation depuis la carte, notifications topRight
La carte « Où le guide est disponible » affichait la valeur entière de l'enum
AppType (0, 1, 2…) au lieu d'un libellé. Réutilise les clés statsChannel*
déjà traduites en FR/EN/NL pour l'écran Statistiques.

L'activation de l'assistant par canal se réglait dans chaque onglet
d'application, alors que le champ vit sur ApplicationInstance et se résout
par (InstanceId, AppType). Le réglage passe dans la liste des canaux du
Guide IA, où tous les canaux sont visibles au même endroit ; le paramètre
showAssistant de AppConfigurationLinkScreen devient inutile.

Les notifications de cet écran passaient par ScaffoldMessenger (en bas),
contrairement au reste de l'application qui utilise showNotification
(toastification, topRight).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:30:33 +02:00

980 lines
35 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:manager_api_new/api.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/Components/message_notification.dart';
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/Screens/GuideIa/visitor_questions_tab.dart';
import 'package:provider/provider.dart';
/// Voix Gemini TTS retenues. Voix et mot de réveil forment un couple indissociable :
/// les mots de réveil sont des modèles OpenWakeWord pré-entraînés, pas des chaînes saisies.
const kGuideVoiceViva = 'Sulafat';
const kGuideVoiceMarco = 'Umbriel';
class GuideIaScreen extends StatefulWidget {
const GuideIaScreen({super.key});
@override
State<GuideIaScreen> createState() => _GuideIaScreenState();
}
class _GuideIaScreenState extends State<GuideIaScreen> {
final _nameController = TextEditingController();
final _personaController = TextEditingController();
final List<TextEditingController> _fallbackControllers = [];
String _voiceId = kGuideVoiceViva;
InstanceDTO? _instance;
bool _loading = true;
bool _saving = false;
bool _reindexing = false;
/// Ids des canaux dont l'activation est en cours — chaque ligne bascule indépendamment.
final Set<String?> _togglingChannels = {};
int _tab = 0;
final _previewController = TextEditingController();
final List<_PreviewTurn> _previewTurns = [];
bool _previewPending = false;
GuideIaInsights? _insights;
Map<String, dynamic>? _knowledge;
/// Nul tant que le serveur n'a pas répondu : la ligne « ~N questions » ne s'affiche pas.
int? _tokensPerQuestion;
/// Le client autorise-t-il l'enregistrement des questions de ses visiteurs ? (RGPD)
bool _collectionEnabled = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
@override
void dispose() {
_nameController.dispose();
_personaController.dispose();
_previewController.dispose();
for (final c in _fallbackControllers) {
c.dispose();
}
super.dispose();
}
AppContext get _appContext => Provider.of<AppContext>(context, listen: false);
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.
String get _editingLanguage => 'FR';
Future<void> _load() async {
final ctx = _managerContext;
final instanceId = ctx.instanceId;
if (instanceId == null || ctx.clientAPI == null) {
setState(() => _loading = false);
return;
}
try {
final instance = await ctx.clientAPI!.instanceApi!.instanceGetDetail(instanceId);
if (!mounted) return;
_nameController.text = instance?.guideName ?? '';
_personaController.text = instance?.guidePersonaPrompt ?? '';
_voiceId = instance?.guideVoiceId ?? kGuideVoiceViva;
_collectionEnabled = instance?.isVisitorQuestionCollectionEnabled ?? true;
final ownLanguage = (instance?.guideFallbackMessages ?? [])
.where((m) => m.language == _editingLanguage && (m.value ?? '').isNotEmpty);
for (final m in ownLanguage) {
_fallbackControllers.add(TextEditingController(text: m.value));
}
if (_fallbackControllers.isEmpty) {
_fallbackControllers.add(TextEditingController());
}
setState(() {
_instance = instance;
_loading = false;
});
_loadKnowledge();
_loadInsights();
_loadTokensPerQuestion();
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
/// Appel http direct, comme la relance d'indexation plus bas : un agrégat en lecture
/// seule ne justifie pas d'étendre `manager_api_new`, qui s'édite à la main.
/// Un échec est silencieux — la carte disparaît, elle n'affiche pas un chiffre faux.
Future<void> _loadKnowledge() async {
final ctx = _managerContext;
try {
final response = await http.get(
Uri.parse('${ctx.host}/api/Ai/knowledge/${ctx.instanceId}'),
headers: {'Authorization': 'Bearer ${ctx.accessToken}'},
);
if (!mounted || response.statusCode != 200) return;
setState(() => _knowledge = jsonDecode(response.body) as Map<String, dynamic>);
} catch (_) {
// La carte reste masquée.
}
}
/// Même raison que `_loadKnowledge` pour l'appel direct. Un échec laisse `_insights`
/// nul, donc l'état vide — jamais un onglet à moitié rempli qui laisserait croire
/// que les visiteurs n'ont rien demandé.
Future<void> _loadInsights() async {
final ctx = _managerContext;
try {
final response = await http.get(
Uri.parse('${ctx.host}/api/Ai/insights/${ctx.instanceId}'),
headers: {'Authorization': 'Bearer ${ctx.accessToken}'},
);
if (!mounted || response.statusCode != 200) return;
setState(() => _insights =
GuideIaInsights.fromJson(jsonDecode(response.body) as Map<String, dynamic>));
} catch (_) {
// L'onglet reste sur son état vide.
}
}
/// Même appel direct que les deux ci-dessus. On ne lit qu'une chose : combien de jetons
/// coûte une question **chez ce client**, mesuré par le serveur sur ses propres questions.
///
/// ⚠️ Un échec laisse `_tokensPerQuestion` nul et la ligne « ~N questions » disparaît.
/// C'est délibéré : le diviseur codé en dur qui vivait ici valait 1 000, alors que la grille
/// vend 20 M de jetons pour ~2 000 questions — soit 10 000. Le gestionnaire lisait un crédit
/// **dix fois trop généreux**. Pas de chiffre vaut mieux qu'un chiffre faux sur une jauge
/// qui sert à décider d'un achat.
Future<void> _loadTokensPerQuestion() async {
final ctx = _managerContext;
try {
final response = await http.get(
Uri.parse('${ctx.host}/api/Instance/${ctx.instanceId}/quota'),
headers: {'Authorization': 'Bearer ${ctx.accessToken}'},
);
if (!mounted || response.statusCode != 200) return;
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final value = (decoded['aiTokensPerQuestion'] as num?)?.toInt() ?? 0;
if (value > 0) setState(() => _tokensPerQuestion = value);
} catch (_) {
// La ligne « ~N questions » reste masquée.
}
}
Future<void> _save() async {
final instance = _instance;
if (instance == null) return;
final l = AppLocalizations.of(context)!;
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();
instance.guideName = _nameController.text.trim();
instance.guidePersonaPrompt = _personaController.text.trim();
instance.guideVoiceId = _voiceId;
instance.isVisitorQuestionCollectionEnabled = _collectionEnabled;
instance.guideFallbackMessages = [...otherLanguages, ...edited];
try {
await _managerContext.clientAPI!.instanceApi!.instanceUpdateinstance(instance);
if (!mounted) return;
showNotification(kSuccess, kWhite, l.guideIaSaved, context, null);
} catch (_) {
if (!mounted) return;
showNotification(kError, kWhite, l.guideIaSaveError, context, null);
} finally {
if (mounted) setState(() => _saving = false);
}
}
/// Appel direct plutôt que via le client généré : l'endpoint est réservé au support,
/// et `manager_api_new` s'édite à la main — on ne l'alourdit pas pour un bouton interne.
/// Textes en français en dur, pour la même raison : aucun client ne voit cet écran.
Future<void> _reindex() async {
final ctx = _managerContext;
final instanceId = ctx.instanceId;
if (instanceId == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Réindexer tout le contenu ?'),
content: const Text(
'Chaque section sera revectorisée, ce qui consomme du budget d\'embedding.\n\n'
'Inutile si le guide répond mal : neuf fois sur dix le contenu est trop maigre, '
'pas l\'index périmé. À réserver aux cas où l\'index est réellement incomplet.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Annuler')),
TextButton(onPressed: () => Navigator.pop(dialogContext, true), child: const Text('Réindexer')),
],
),
);
if (confirmed != true) return;
setState(() => _reindexing = true);
try {
final response = await http.post(
Uri.parse('${ctx.host}/api/Ai/reindex/$instanceId'),
headers: {'Authorization': 'Bearer ${ctx.accessToken}'},
);
if (!mounted) return;
if (response.statusCode == 202) {
final sections = (jsonDecode(response.body)['sectionsQueued'] as num?)?.toInt() ?? 0;
showNotification(
kSuccess,
kWhite,
'Réindexation lancée — $sections section(s) en file. '
'Le détail par section part dans les logs.',
context,
null);
} else {
showNotification(kError, kWhite, 'Échec (${response.statusCode}) : ${response.body}', context, null);
}
} catch (e) {
if (!mounted) return;
showNotification(kError, kWhite, 'Échec de la réindexation : $e', context, null);
} finally {
if (mounted) setState(() => _reindexing = false);
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
if (_loading) return const Center(child: CircularProgressIndicator());
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(l),
const SizedBox(height: kSpace5),
_tabs(l),
const SizedBox(height: kSpace7),
Expanded(
child: _tab == 0
? _configurationTab(l)
: VisitorQuestionsTab(insights: _insights),
),
],
);
}
/// Deux onglets, pas deux écrans : la configuration du guide et ce que les visiteurs
/// lui demandent sont les deux faces d'un même objet. Les séparer obligerait le client
/// à faire le lien lui-même entre une question sans réponse et le contenu à écrire.
Widget _tabs(AppLocalizations l) {
return Container(
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: kLine))),
child: Row(
children: [
_tabButton(l.guideIaTabConfig, 0),
_tabButton(l.guideIaTabQuestions, 1),
],
),
);
}
Widget _tabButton(String label, int index) {
final selected = _tab == index;
return InkWell(
onTap: () => setState(() => _tab = index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? kBrand : Colors.transparent,
width: 2,
),
),
),
child: Text(
label,
style: TextStyle(
fontSize: 14,
color: selected ? kInk : kInk3,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
),
);
}
Widget _configurationTab(AppLocalizations l) {
final wide = MediaQuery.of(context).size.width > 1100;
final left = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_usageCard(l),
const SizedBox(height: 16),
_channelsCard(l),
const SizedBox(height: 16),
_collectionCard(l),
const SizedBox(height: 16),
_identityCard(l),
],
);
final right = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_previewCard(l),
const SizedBox(height: 16),
_voiceCard(l),
if (_knowledge != null) ...[
const SizedBox(height: 16),
_knowledgeCard(l),
],
if (_managerContext.role == UserRole.SuperAdmin) ...[
const SizedBox(height: 16),
_reindexCard(),
],
],
);
return SingleChildScrollView(
child: wide
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: left),
const SizedBox(width: 16),
Expanded(child: right),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [left, const SizedBox(height: 16), right],
),
);
}
Widget _header(AppLocalizations l) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.menuGuideIa,
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600, color: kPrimaryColor)),
const SizedBox(height: 4),
Text(l.guideIaSubtitle,
style: TextStyle(fontSize: 14, color: kBodyTextColor)),
],
),
),
const SizedBox(width: 16),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: kPrimaryColor),
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: kWhite))
: Text(l.guideIaSave),
),
],
);
}
/// Interrupteur de collecte (RGPD, lot J).
///
/// ⚠️ **C'est le client qui est responsable de traitement**, pas nous : il doit pouvoir
/// refuser. La collecte était inconditionnelle dès que l'assistant était actif.
///
/// Le texte dit ce que couper coûte — sans quoi le gestionnaire le désactive « par
/// prudence » puis s'étonne que son onglet reste vide — et ce que couper ne fait pas :
/// les questions déjà enregistrées vivent jusqu'à leur purge, ce n'est pas un effacement.
Widget _collectionCard(AppLocalizations l) {
return _card(
title: 'Questions de vos visiteurs',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _collectionEnabled,
onChanged: (v) => setState(() => _collectionEnabled = v),
title: const Text('Enregistrer les questions posées au guide',
style: TextStyle(fontSize: 14)),
subtitle: Text(
_collectionEnabled
? 'Alimente l\'onglet « Ce que demandent vos visiteurs » et le rapport des '
'questions restées sans réponse. Conservation 90 jours, puis seuls les '
'regroupements par thème sont gardés.'
: 'Aucune nouvelle question ne sera enregistrée. L\'onglet « Ce que demandent '
'vos visiteurs » cessera de se remplir. Les questions déjà enregistrées '
'restent jusqu\'à leur suppression automatique à 90 jours.',
style: TextStyle(fontSize: 12, color: kBodyTextColor),
),
activeThumbColor: kPrimaryColor,
),
],
),
);
}
Widget _card({required String title, String? subtitle, required Widget child}) {
return Card(
elevation: 0,
color: kWhite,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kPrimaryColor)),
if (subtitle != null) ...[
const SizedBox(height: 3),
Text(subtitle, style: TextStyle(fontSize: 13, color: kBodyTextColor.withValues(alpha: 0.75))),
],
const SizedBox(height: 16),
child,
],
),
),
);
}
/// Visible pour le SuperAdmin seulement — c'est un outil de réparation, pas une
/// fonctionnalité. Exposé au client, il serait cliqué à chaque réponse décevante du guide,
/// pour un coût d'embedding complet à chaque fois et sans rien améliorer.
Widget _reindexCard() {
return _card(
title: 'Réindexer le contenu (support)',
subtitle: 'Visible par le support Unov uniquement.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Le rattrapage est automatique au passage à un plan avec IA. Ce bouton ne sert '
'que si l\'index est resté incomplet — un job d\'indexation en échec, par exemple.',
style: TextStyle(fontSize: 12, color: kBodyTextColor.withValues(alpha: 0.7)),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _reindexing ? null : _reindex,
icon: _reindexing
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.refresh),
label: Text(_reindexing ? 'Lancement…' : 'Réindexer tout le contenu'),
),
],
),
);
}
Widget _usageCard(AppLocalizations l) {
final used = _instance?.aiTokensThisMonth ?? 0;
final quota = _instance?.aiTokensPerMonth ?? 0;
// Le gestionnaire raisonne en questions, pas en jetons de traitement. Le diviseur vient
// du serveur (`aiTokensPerQuestion`), qui le mesure sur les questions réellement posées
// par cette instance — il n'est pas codé ici.
final tokensPerQuestion = _tokensPerQuestion;
final questions = tokensPerQuestion != null ? (used / tokensPerQuestion).round() : null;
final ratio = quota > 0 ? (used / quota).clamp(0.0, 1.0) : 0.0;
return _card(
title: l.guideIaUsageTitle,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (quota > 0) ...[
Text('${(ratio * 100).round()} %',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: kPrimaryColor)),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: ratio.toDouble(),
minHeight: 8,
backgroundColor: kSecond.withValues(alpha: 0.4),
color: kPrimaryColor,
),
),
const SizedBox(height: 10),
if (questions != null)
Text(l.guideIaUsageQuestions(questions), style: TextStyle(fontSize: 13, color: kBodyTextColor)),
] else
Text(l.guideIaUsageNoQuota, style: TextStyle(fontSize: 13, color: kBodyTextColor)),
],
),
);
}
String _channelLabel(AppLocalizations l, AppType? channel) {
switch (channel?.value) {
case 0:
return l.statsChannelMobile;
case 1:
return l.statsChannelTablet;
case 2:
return l.statsChannelWeb;
case 3:
return l.statsChannelVR;
case 4:
return l.statsChannelVoice;
default:
return '';
}
}
Future<void> _toggleChannel(ApplicationInstanceDTO app, AppLocalizations l) async {
final previous = app.isAssistant;
setState(() {
app.isAssistant = !(previous ?? false);
_togglingChannels.add(app.id);
});
try {
final updated = await updateApplicationInstance(_appContext, app);
if (!mounted) return;
setState(() => app.isAssistant = updated?.isAssistant ?? app.isAssistant);
showNotification(kSuccess, kWhite, l.guideIaSaved, context, null);
} catch (_) {
if (!mounted) return;
setState(() => app.isAssistant = previous);
showNotification(kError, kWhite, l.guideIaSaveError, context, null);
} finally {
if (mounted) setState(() => _togglingChannels.remove(app.id));
}
}
Widget _channelsCard(AppLocalizations l) {
final apps = _instance?.applicationInstanceDTOs ?? [];
return _card(
title: l.guideIaChannelsTitle,
subtitle: l.guideIaChannelsSub,
child: Column(
children: apps.map((app) {
final on = app.isAssistant == true;
final busy = _togglingChannels.contains(app.id);
return InkWell(
onTap: busy ? null : () => _toggleChannel(app, l),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
children: [
if (busy)
const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
else
Icon(on ? Icons.check_circle : Icons.remove_circle_outline,
size: 18, color: on ? kPrimaryColor : kSecond),
const SizedBox(width: 10),
Expanded(
child: Text(_channelLabel(l, app.appType),
style: const TextStyle(fontSize: 14))),
Text(on ? l.guideIaChannelOn : l.guideIaChannelOff,
style: TextStyle(fontSize: 12.5, color: kBodyTextColor.withValues(alpha: 0.7))),
],
),
),
);
}).toList(),
),
);
}
Widget _identityCard(AppLocalizations l) {
return _card(
title: l.guideIaIdentityTitle,
subtitle: l.guideIaIdentitySub,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: l.guideIaNameLabel,
helperText: l.guideIaNameHint,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 20),
TextField(
controller: _personaController,
maxLines: 5,
decoration: InputDecoration(
labelText: l.guideIaPersonaLabel,
alignLabelWithHint: true,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
Text(l.guideIaPersonaHint, style: TextStyle(fontSize: 12, color: kBodyTextColor.withValues(alpha: 0.7))),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_exampleChip(l.guideIaExampleWarden, l.guideIaExampleWardenText),
_exampleChip(l.guideIaExampleSober, l.guideIaExampleSoberText),
_exampleChip(l.guideIaExampleKids, l.guideIaExampleKidsText),
],
),
const SizedBox(height: 24),
Text(l.guideIaFallbackLabel,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: kBodyTextColor)),
const SizedBox(height: 8),
...List.generate(_fallbackControllers.length, (i) => _fallbackRow(i)),
const SizedBox(height: 4),
TextButton.icon(
onPressed: () => setState(() => _fallbackControllers.add(TextEditingController())),
icon: const Icon(Icons.add, size: 18),
label: Text(l.guideIaFallbackAdd),
style: TextButton.styleFrom(foregroundColor: kPrimaryColor),
),
const SizedBox(height: 4),
Text(l.guideIaFallbackHint, style: TextStyle(fontSize: 12, color: kBodyTextColor.withValues(alpha: 0.7))),
const SizedBox(height: 10),
_info(l.guideIaFallbackTranslated),
],
),
);
}
Widget _fallbackRow(int index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _fallbackControllers[index],
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
),
),
),
IconButton(
onPressed: _fallbackControllers.length <= 1
? null
: () => setState(() => _fallbackControllers.removeAt(index).dispose()),
icon: const Icon(Icons.close, size: 18),
color: kBodyTextColor,
),
],
),
);
}
Widget _exampleChip(String label, String text) {
return ActionChip(
label: Text(label, style: const TextStyle(fontSize: 12.5)),
onPressed: () => setState(() => _personaController.text = text),
side: BorderSide(color: kSecond),
backgroundColor: kWhite,
);
}
/// Interroge le guide réellement publié, pas une simulation : c'est le seul moyen de
/// vérifier que la personnalité saisie produit le ton attendu. D'où l'avertissement
/// quand des modifications ne sont pas encore enregistrées — sinon le client teste
/// l'ancienne configuration en croyant tester la nouvelle.
Future<void> _sendPreview() async {
final message = _previewController.text.trim();
if (message.isEmpty || _previewPending) return;
final ctx = _managerContext;
setState(() {
_previewTurns.add(_PreviewTurn(message, null));
_previewController.clear();
_previewPending = true;
});
try {
final response = await ctx.clientAPI!.aiApi!.aiChat(AiChatRequest(
message: message,
instanceId: ctx.instanceId,
appType: AppType.Web,
language: _editingLanguage,
// ⚠️ C'est le gestionnaire qui teste sa personnalité, pas un visiteur qui pose une
// question : sans ce drapeau, chaque essai atterrissait dans son propre onglet
// « Ce que demandent vos visiteurs » et gonflait le bloc des questions sans réponse.
isVisitorQuestion: false,
));
if (!mounted) return;
setState(() {
_previewTurns.last = _PreviewTurn(message, response?.reply ?? '');
_previewPending = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_previewTurns.last = _PreviewTurn(message, null);
_previewPending = false;
});
showNotification(kError, kWhite, AppLocalizations.of(context)!.guideIaPreviewError, context, null);
}
}
Widget _previewCard(AppLocalizations l) {
return _card(
title: l.guideIaPreviewTitle,
subtitle: l.guideIaPreviewSub,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: kSurface2,
border: Border.all(color: kLine),
borderRadius: BorderRadius.circular(7),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_previewTurns.isEmpty)
Text(l.guideIaPreviewUnsaved, style: kTextHint)
else
for (final turn in _previewTurns) ..._previewBubbles(turn),
if (_previewPending) ...[
const SizedBox(height: kSpace3),
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
],
),
),
const SizedBox(height: kSpace4),
Row(
children: [
Expanded(
child: TextField(
controller: _previewController,
onSubmitted: (_) => _sendPreview(),
decoration: InputDecoration(
isDense: true,
hintText: l.guideIaPreviewPlaceholder,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: kSpace3),
IconButton(
onPressed: _previewPending ? null : _sendPreview,
icon: const Icon(Icons.send),
color: kBrand,
),
],
),
const SizedBox(height: kSpace4),
_info(l.guideIaPreviewSourcesInfo),
],
),
);
}
List<Widget> _previewBubbles(_PreviewTurn turn) {
return [
Align(
alignment: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(bottom: 9),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
constraints: const BoxConstraints(maxWidth: 340),
decoration: BoxDecoration(
color: kBrand,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(4),
),
),
child: Text(turn.question, style: kTextBody.copyWith(color: kOnBrand)),
),
),
if (turn.answer != null)
Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 9),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
constraints: const BoxConstraints(maxWidth: 340),
decoration: BoxDecoration(
color: kSurface,
border: Border.all(color: kLine),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(12),
),
),
child: Text(turn.answer!, style: kTextBody),
),
),
];
}
/// Mesuré sur l'index vectoriel, pas sur les tables de contenu : une section
/// désactivée est purgée de l'index, une section sans texte exploitable n'y entre pas.
/// Compter les sections publiées donnerait un chiffre plus flatteur et faux.
Widget _knowledgeCard(AppLocalizations l) {
final k = _knowledge!;
final languages = (k['languages'] as List?)?.cast<String>() ?? const <String>[];
final lastIndexed = DateTime.tryParse((k['lastIndexedAt'] ?? '').toString());
return _card(
title: l.guideIaKnowledgeTitle,
subtitle: l.guideIaKnowledgeSub,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_knowledgeRow(l.guideIaKnowledgeSections, '${k['indexedSections'] ?? 0}', first: true),
_knowledgeRow(l.guideIaKnowledgeChunks, '${k['chunks'] ?? 0}'),
_knowledgeRow(
l.guideIaKnowledgeLanguages,
languages.isEmpty ? '' : languages.join(' · '),
),
_knowledgeRow(
l.guideIaKnowledgeLastIndexed,
lastIndexed == null ? '' : _relativeTime(l, lastIndexed),
),
const SizedBox(height: kSpace3),
Text(l.guideIaKnowledgeHint, style: kTextHint),
],
),
);
}
Widget _knowledgeRow(String label, String value, {bool first = false}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 7),
decoration: BoxDecoration(
border: first ? null : Border(top: BorderSide(color: kLineSoft)),
),
child: Row(
children: [
Expanded(child: Text(label, style: kTextBody)),
const SizedBox(width: kSpace4),
Text(value,
style: kTextBody.copyWith(
fontFamily: kMonoFamily, fontWeight: FontWeight.w600)),
],
),
);
}
String _relativeTime(AppLocalizations l, DateTime utc) {
final minutes = DateTime.now().toUtc().difference(utc).inMinutes;
if (minutes < 60) return l.guideIaKnowledgeMinutesAgo(minutes < 1 ? 1 : minutes);
if (minutes < 60 * 24) return l.guideIaKnowledgeHoursAgo(minutes ~/ 60);
return l.guideIaKnowledgeDaysAgo(minutes ~/ (60 * 24));
}
Widget _voiceCard(AppLocalizations l) {
final wakeword = _voiceId == kGuideVoiceMarco ? 'Marco' : 'Viva';
return _card(
title: l.guideIaVoiceTitle,
subtitle: l.guideIaVoiceSub,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: _voiceOption('Viva', l.guideIaVoiceFemale, kGuideVoiceViva, l)),
const SizedBox(width: 10),
Expanded(child: _voiceOption('Marco', l.guideIaVoiceMale, kGuideVoiceMarco, l)),
],
),
const SizedBox(height: 14),
_info(l.guideIaVoiceInfoName(wakeword)),
const SizedBox(height: 8),
_info(l.guideIaVoiceInfoGlasses),
const SizedBox(height: 8),
_info(l.guideIaVoiceInfoMultilang),
],
),
);
}
Widget _voiceOption(String name, String description, String voiceId, AppLocalizations l) {
final selected = _voiceId == voiceId;
return InkWell(
onTap: () => setState(() => _voiceId = voiceId),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(13),
decoration: BoxDecoration(
border: Border.all(color: selected ? kPrimaryColor : kSecond, width: selected ? 2 : 1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kPrimaryColor)),
const SizedBox(height: 2),
Text(description, style: TextStyle(fontSize: 12.5, color: kBodyTextColor)),
const SizedBox(height: 8),
Text(l.guideIaVoiceWakeword(name),
style: TextStyle(fontSize: 11.5, color: kBodyTextColor.withValues(alpha: 0.7))),
],
),
),
);
}
Widget _info(String text) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: kBackgroundColor,
borderRadius: BorderRadius.circular(6),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, size: 16, color: kPrimaryColor),
const SizedBox(width: 9),
Expanded(child: Text(text, style: TextStyle(fontSize: 12.5, color: kBodyTextColor))),
],
),
);
}
}
class _PreviewTurn {
final String question;
final String? answer;
const _PreviewTurn(this.question, this.answer);
}