manager-app/lib/Screens/GuideIa/guide_ia_screen.dart

1050 lines
38 KiB
Dart

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';
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/Services/ai_translate_service.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 = {};
/// 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<String> _loadedFallbacks = [];
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 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<String> get _visitorLanguages => (_instance?.applicationInstanceDTOs ?? [])
.expand<String>((app) => app.languages ?? const [])
.map((lang) => lang.toUpperCase())
.where((lang) => lang != _editingLanguage)
.toSet()
.toList();
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());
}
_loadedFallbacks = _fallbackControllers.map((c) => c.text.trim()).where((t) => t.isNotEmpty).toList();
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.
}
}
/// 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<List<TranslationDTO>?> _translateFallbacks(List<String> sources, List<TranslationDTO> 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 = <TranslationDTO>[];
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<void> _save() async {
final instance = _instance;
if (instance == null) return;
final l = AppLocalizations.of(context)!;
setState(() => _saving = true);
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<TranslationDTO>? 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
? <TranslationDTO>[]
: 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 = [...preserved, ...edited, ...(translated ?? [])];
try {
await _managerContext.clientAPI!.instanceApi!.instanceUpdateinstance(instance);
if (!mounted) return;
_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);
} 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: l.guideIaCollectionTitle,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _collectionEnabled,
onChanged: (v) => setState(() => _collectionEnabled = v),
title: Text(l.guideIaCollectionSwitch,
style: TextStyle(fontSize: 14)),
subtitle: Text(
_collectionEnabled
? l.guideIaCollectionOnDesc
: l.guideIaCollectionOffDesc,
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 ? 'Hey Marco' : 'Hey 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('Hey $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);
}