Socle visuel (constants.dart)
- 11 rôles typographiques, 8 espacements, 5 rayons, paddings de carte et de page,
extraits du CSS des maquettes validées (DOCS/claude design/).
- Les couleurs restent celles de l'app : un écran neuf se fond dans manager-app,
il n'y ouvre pas une seconde palette. Seules les valeurs sans équivalent
existant sont reprises — remplissages discrets, bordures, gris atténué, ambre.
- Mesuré avant : 11 tailles de police différentes dans lib/Components/, de 9 à 25 px.
Écran Guide IA
- Coquille à deux onglets : Configuration / Ce que demandent vos visiteurs.
- Aperçu de conversation branché sur le vrai POST /api/AI/chat, pas une simulation.
- Carte « Ce que connaît votre guide » sur GET /api/Ai/knowledge/{id} : mesuré sur
l'index vectoriel, pas sur les tables de contenu — une section désactivée en est
purgée. Les « points d'intérêt » de la maquette deviennent « morceaux de contenu » :
les points d'une carte sont indexés dans le texte de leur SectionMap.
- Onglet des questions visiteurs alimenté par GET /api/Ai/insights/{id}. Une carte
sans données se masque au lieu d'afficher un cadre creux.
Client API
- AIApi était dans le client généré mais exposé nulle part dans client.dart, où
les 18 autres façades le sont. Câblé.
34 clés i18n FR/EN/NL. flutter analyze propre, flutter build web ✅.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
860 lines
30 KiB
Dart
860 lines
30 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/constants.dart';
|
|
import 'package:manager_app/l10n/app_localizations.dart';
|
|
import 'package:manager_app/Models/managerContext.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;
|
|
|
|
int _tab = 0;
|
|
|
|
final _previewController = TextEditingController();
|
|
final List<_PreviewTurn> _previewTurns = [];
|
|
bool _previewPending = false;
|
|
|
|
GuideIaInsights? _insights;
|
|
|
|
Map<String, dynamic>? _knowledge;
|
|
|
|
@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();
|
|
}
|
|
|
|
ManagerAppContext get _managerContext =>
|
|
Provider.of<AppContext>(context, listen: false).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;
|
|
|
|
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();
|
|
} 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.
|
|
}
|
|
}
|
|
|
|
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.guideFallbackMessages = [...otherLanguages, ...edited];
|
|
|
|
try {
|
|
await _managerContext.clientAPI!.instanceApi!.instanceUpdateinstance(instance);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.guideIaSaved), backgroundColor: kPrimaryColor),
|
|
);
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.guideIaSaveError), backgroundColor: Colors.redAccent),
|
|
);
|
|
} 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;
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text('Réindexation lancée — $sections section(s) en file. '
|
|
'Le détail par section part dans les logs.'),
|
|
backgroundColor: kPrimaryColor,
|
|
));
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text('Échec (${response.statusCode}) : ${response.body}'),
|
|
backgroundColor: Colors.redAccent,
|
|
));
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Échec de la réindexation : $e'), backgroundColor: Colors.redAccent),
|
|
);
|
|
} 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),
|
|
_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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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.
|
|
final questions = (used / 1000).round();
|
|
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),
|
|
Text(l.guideIaUsageQuestions(questions), style: TextStyle(fontSize: 13, color: kBodyTextColor)),
|
|
] else
|
|
Text(l.guideIaUsageNoQuota, style: TextStyle(fontSize: 13, color: kBodyTextColor)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
|
child: Row(
|
|
children: [
|
|
Icon(on ? Icons.check_circle : Icons.remove_circle_outline,
|
|
size: 18, color: on ? kPrimaryColor : kSecond),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text((app.appType?.value ?? '—').toString(),
|
|
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,
|
|
));
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_previewTurns.last = _PreviewTurn(message, response?.reply ?? '');
|
|
_previewPending = false;
|
|
});
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_previewTurns.last = _PreviewTurn(message, null);
|
|
_previewPending = false;
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppLocalizations.of(context)!.guideIaPreviewError),
|
|
backgroundColor: Colors.redAccent,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|