mirror of
https://github.com/JMP-MS/ASPIC.git
synced 2026-10-27 17:17:08 +00:00
848 lines
39 KiB
JavaScript
848 lines
39 KiB
JavaScript
/**
|
|
* ASPIC — contact_ia_panel.js
|
|
*
|
|
* Outil de mise au point interne (4 septembre 2026, soir) — PAS encore un
|
|
* module utilisateur final, mais la première fois que l'interprétation IA
|
|
* d'une dictée de contact (voir contact_ia_interprete.php/contact_ia_valide.php)
|
|
* est branchée directement sur le micro du bureau (#micro-desk), à la place
|
|
* de micro_test.js — décision explicite de Jean-Marc : la banque de dictées
|
|
* brutes (micro_test.js/dictees_test.php, ASP_DicteesTest) a rempli son rôle
|
|
* de constitution de corpus et "ne sert plus à rien" maintenant que
|
|
* l'interprétation elle-même fonctionne. micro_test.js n'est pas supprimé
|
|
* (garde son utilité d'archive/référence), juste retiré du script chargé en
|
|
* bas d'ecran_fond.html.
|
|
*
|
|
* Reprend la quasi-totalité de la logique de contact_ia_test.js (identifiants
|
|
* data-champ, appels aux mêmes endpoints, mêmes fonctions de remplissage) —
|
|
* seule la construction du DOM change : un panneau flottant construit en JS
|
|
* (même principe que la fenêtre de micro_test.js), pas une page HTML dédiée.
|
|
* contact_ia_test.html/.js restent en place tels quels (page autonome, plus
|
|
* très utile maintenant mais rien ne l'interdit de la garder) : ce fichier
|
|
* ne les modifie pas.
|
|
*
|
|
* Différences de présentation demandées par Jean-Marc (4 septembre 2026,
|
|
* soir) par rapport à contact_ia_test.html :
|
|
* - Panneau qui ne prend pas tout l'écran (comme la fenêtre de micro_test.js),
|
|
* pas une page à part entière.
|
|
* - Champs "bruts" : nom du champ suivi de sa valeur, police terminal,
|
|
* plutôt que des blocs/cartes stylées avec grille — effet "outil d'expert"
|
|
* voulu explicitement.
|
|
* - Pas de texte d'explication en haut : remplacé par une ligne façon prompt
|
|
* SSH ("user@<prénom> : ", blanc sur fond noir). Le prénom vient de
|
|
* preferences.php (USR_Prenom, déjà utilisé ailleurs sur cet écran pour le
|
|
* chevalet) plutôt que d'être écrit en dur — même principe que pour les
|
|
* noms d'agents (jamais figer un nom précis dans le code).
|
|
*
|
|
* Contrairement à micro_test.js, le panneau NE SE FERME PAS quand le micro
|
|
* s'arrête tout seul (redémarrage automatique compris) : seule une action
|
|
* explicite de Jean-Marc (Valider, ou Échap — voir troisième vague plus bas)
|
|
* referme le panneau.
|
|
*
|
|
* Deuxième vague de demandes de présentation (4 septembre 2026, encore le
|
|
* même soir) :
|
|
* - Le panneau grandit vers le bas, mais jamais au-delà du pied réel du
|
|
* micro sur le bureau (silhouette complète de #micro-desk, voir
|
|
* ecran_fond.html/MICRO_ZONE_Y2) — recalculé à chaque ouverture et au
|
|
* redimensionnement de la fenêtre (voir ajusterHauteurPanneau), en lisant
|
|
* directement la position réelle de #micro-desk (getBoundingClientRect)
|
|
* plutôt qu'en dupliquant ici la géométrie de l'image de fond.
|
|
* - Séquence d'ouverture façon terminal : le prompt "user@<prénom> : "
|
|
* s'affiche d'abord seul avec un petit curseur "_" clignotant (une seconde
|
|
* et demie), puis le curseur est remplacé par "micro ouvert", et une
|
|
* deuxième ligne de prompt apparaît en dessous : "user@<prénom> : parlez !"
|
|
* (voir demarrerSequencePrompt/rafraichirTextePrompt).
|
|
* - Plus de bouton "Interpréter" ni "Réinitialiser" : l'interprétation part
|
|
* automatiquement dès que le micro s'arrête proprement (clic volontaire de
|
|
* Jean-Marc, pas un arrêt dû à une erreur bloquante — voir
|
|
* lancerInterpretation, appelée depuis l'événement "end" de la
|
|
* reconnaissance). Un statut "Interprétation en cours…" s'affiche dans la
|
|
* même zone que la dictée brute et les erreurs éventuelles, le temps de
|
|
* l'appel.
|
|
* - Plus de croix de fermeture séparée : "Valider" referme le panneau après
|
|
* confirmation de l'enregistrement (le temps de lire le message).
|
|
*
|
|
* Troisième vague (4 septembre 2026, encore le même soir) :
|
|
* - Bouton "Abandonner" retiré : il faisait double emploi avec la touche
|
|
* Échap, qui reste l'unique façon d'abandonner l'essai en cours sans rien
|
|
* enregistrer ("tant pis si on perd des data", voir abandonnerPanneau et
|
|
* le gestionnaire "keydown" plus bas). "Valider" reste seul, en pleine
|
|
* largeur.
|
|
* - 3 zones de texte libre pour les actions (plus la liste dynamique
|
|
* "+ Ajouter une action") — voir remplirActions/collecterActionsValidees.
|
|
*/
|
|
(function(){
|
|
"use strict";
|
|
|
|
const boutonMicro = document.getElementById("micro-desk");
|
|
if(!boutonMicro) return; // ce script ne concerne que l'écran de fond
|
|
const ReconnaissanceVocale = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
if(!ReconnaissanceVocale){
|
|
boutonMicro.title = "Reconnaissance vocale non disponible sur ce navigateur";
|
|
return;
|
|
}
|
|
|
|
const reconnaissance = new ReconnaissanceVocale();
|
|
reconnaissance.lang = "fr-FR";
|
|
reconnaissance.continuous = true;
|
|
reconnaissance.interimResults = true;
|
|
reconnaissance.maxAlternatives = 1;
|
|
|
|
let ecouteVoulue = false;
|
|
let erreurBloquante = false;
|
|
let ligneEnCours = null;
|
|
let texteComplet = "";
|
|
let derniereExtraction = null;
|
|
|
|
const DUREE_INACTIVITE_MS = 10 * 60 * 1000;
|
|
let minuteurInactivite = null;
|
|
function reinitialiserMinuteurInactivite(){
|
|
if(minuteurInactivite) clearTimeout(minuteurInactivite);
|
|
minuteurInactivite = setTimeout(function(){
|
|
if(!ecouteVoulue) return;
|
|
ecouteVoulue = false;
|
|
reconnaissance.stop();
|
|
}, DUREE_INACTIVITE_MS);
|
|
}
|
|
function arreterMinuteurInactivite(){
|
|
if(minuteurInactivite){ clearTimeout(minuteurInactivite); minuteurInactivite = null; }
|
|
}
|
|
|
|
// Silence prolongé = fin de la dictée (demande de Jean-Marc, 4 septembre
|
|
// 2026, encore le même soir) : "un silence de 3-4 secondes vaut arrêt des
|
|
// opérations" — 3,5s de compromis. Armé seulement à partir du premier
|
|
// résultat reçu (voir l'événement "result" plus bas), pas dès l'ouverture
|
|
// du micro : sinon un temps de réflexion avant de commencer à parler
|
|
// couperait le micro tout seul. Un arrêt par silence est traité exactement
|
|
// comme un clic volontaire sur le micro (ecouteVoulue = false puis
|
|
// reconnaissance.stop()) : ça déclenche l'interprétation automatique au
|
|
// même titre (voir l'événement "end").
|
|
const DUREE_SILENCE_ARRET_MS = 3500;
|
|
let minuteurSilence = null;
|
|
function reinitialiserMinuteurSilence(){
|
|
if(minuteurSilence) clearTimeout(minuteurSilence);
|
|
minuteurSilence = setTimeout(function(){
|
|
if(!ecouteVoulue) return;
|
|
ecouteVoulue = false;
|
|
reconnaissance.stop();
|
|
}, DUREE_SILENCE_ARRET_MS);
|
|
}
|
|
function arreterMinuteurSilence(){
|
|
if(minuteurSilence){ clearTimeout(minuteurSilence); minuteurSilence = null; }
|
|
}
|
|
|
|
// -------- Construction du panneau (une seule fois, comme micro_test.js) --------
|
|
let panneau = null, lignePrompt1, promptTexte1, curseurClignotant, lignePrompt2,
|
|
promptTexte2, zoneLignes, zoneResultat, boutonValider,
|
|
messageCreation, selectStatut, selectProfession, blocNouvelleProfession,
|
|
champNouvelleProfession, selectAssistant, champsActions;
|
|
|
|
function ligneChamp(conteneur, label, nomChamp, multiligne){
|
|
const ligne = document.createElement("div");
|
|
ligne.className = "ligne-champ";
|
|
const lab = document.createElement("span");
|
|
lab.className = "label-champ";
|
|
lab.textContent = label + " :";
|
|
const input = document.createElement(multiligne ? "textarea" : "input");
|
|
input.dataset.champ = nomChamp;
|
|
ligne.appendChild(lab);
|
|
ligne.appendChild(input);
|
|
conteneur.appendChild(ligne);
|
|
return input;
|
|
}
|
|
|
|
function construirePanneau(){
|
|
if(panneau) return;
|
|
|
|
const style = document.createElement("style");
|
|
style.textContent =
|
|
"#panneau-contact-ia{position:fixed;top:6%;left:50%;transform:translateX(-50%);" +
|
|
"width:min(480px,86vw);background:#000;border:1px solid #333;" +
|
|
"border-radius:6px;box-shadow:0 10px 40px rgba(0,0,0,.6);z-index:950;" +
|
|
"display:none;flex-direction:column;overflow:hidden;" +
|
|
"font-family:'Courier New',Consolas,monospace;color:#fff;}" +
|
|
"#panneau-contact-ia.visible{display:flex;}" +
|
|
"#panneau-contact-ia .entete-prompt{color:#fff;background:#000;font-size:13px;" +
|
|
"line-height:1.6;padding:8px 10px;border-bottom:1px solid #222;}" +
|
|
"#panneau-contact-ia #ligne-prompt-2[hidden]{display:none;}" +
|
|
"@keyframes clignoter-curseur{0%,49%{opacity:1;}50%,100%{opacity:0;}}" +
|
|
"#panneau-contact-ia #curseur-clignotant{animation:clignoter-curseur 1s steps(1) infinite;}" +
|
|
"#panneau-contact-ia #curseur-clignotant[hidden]{display:none;}" +
|
|
// Une seule zone de défilement pour TOUT le contenu, y compris l'en-
|
|
// tête façon prompt — demande de Jean-Marc (4 septembre 2026, encore le
|
|
// même soir) : "je veux que l'ascenseur parte du haut de la fenêtre
|
|
// même si ça fait disparaître les deux premières lignes de prompt".
|
|
// L'en-tête n'est donc plus fixe (flex:none retiré ci-dessus) : il fait
|
|
// maintenant partie du flux normal à l'intérieur de .contenu-scrollable,
|
|
// qui est l'unique enfant du panneau et en occupe toute la hauteur.
|
|
// overflow-y:auto + min-height:0 sur cet enfant flex : le classique
|
|
// pour qu'il se réduise réellement au lieu de pousser le panneau à
|
|
// s'agrandir.
|
|
"#panneau-contact-ia .contenu-scrollable{flex:1;min-height:0;overflow-y:auto;}" +
|
|
// Ascenseur personnalisé, plus discret que celui du navigateur par
|
|
// défaut (demande de Jean-Marc) : fin, sans piste visible, pouce gris
|
|
// sombre qui se fond dans le panneau plutôt que la barre grise claire
|
|
// habituelle. scrollbar-width/-color pour Firefox, ::-webkit-scrollbar
|
|
// pour Chrome/Edge (les deux couvrent déjà toute la hauteur de
|
|
// .contenu-scrollable, qui occupe elle-même tout l'espace disponible
|
|
// sous l'en-tête — voir flex:1 juste au-dessus).
|
|
"#panneau-contact-ia .contenu-scrollable{scrollbar-width:thin;scrollbar-color:#3a3a3a transparent;}" +
|
|
"#panneau-contact-ia .contenu-scrollable::-webkit-scrollbar{width:6px;}" +
|
|
"#panneau-contact-ia .contenu-scrollable::-webkit-scrollbar-track{background:transparent;}" +
|
|
"#panneau-contact-ia .contenu-scrollable::-webkit-scrollbar-thumb{" +
|
|
"background:#3a3a3a;border-radius:3px;}" +
|
|
"#panneau-contact-ia .contenu-scrollable::-webkit-scrollbar-thumb:hover{background:#555;}" +
|
|
"#panneau-contact-ia .lignes{padding:8px 10px;" +
|
|
"font-size:13px;line-height:1.5;white-space:pre-wrap;border-bottom:1px solid #222;}" +
|
|
"#panneau-contact-ia .lignes .en-cours{color:#8a8a8a;font-style:italic;}" +
|
|
"#panneau-contact-ia .lignes .erreur{color:#e14a5c;}" +
|
|
"#panneau-contact-ia .resultat{padding:8px 10px;display:none;}" +
|
|
"#panneau-contact-ia .resultat.visible{display:block;}" +
|
|
"#panneau-contact-ia .ligne-champ{display:flex;align-items:baseline;gap:6px;" +
|
|
"margin-bottom:5px;font-size:13px;}" +
|
|
"#panneau-contact-ia .label-champ{flex:0 0 100px;color:#888;text-align:right;}" +
|
|
"#panneau-contact-ia .ligne-champ input,#panneau-contact-ia .ligne-champ select," +
|
|
"#panneau-contact-ia .ligne-champ textarea{flex:1;font:inherit;font-size:13px;" +
|
|
"background:transparent;color:#fff;border:none;border-bottom:1px solid #333;" +
|
|
"padding:2px 0;}" +
|
|
"#panneau-contact-ia .ligne-champ input.interprete," +
|
|
"#panneau-contact-ia .ligne-champ select.interprete{border-bottom-color:#3a6d3a;}" +
|
|
"#panneau-contact-ia .ligne-champ textarea{min-height:40px;resize:vertical;}" +
|
|
// Options des listes (Statut, Profession, Assistant) illisibles à
|
|
// l'ouverture — blanc sur blanc pour les lignes normales, blanc sur
|
|
// bleu (surlignage par défaut du navigateur) pour celle sous le
|
|
// pointeur — remonté par Jean-Marc le 4 septembre 2026 (encore le même
|
|
// soir) après essai réel. "background/color" sur <option> stylent bien
|
|
// les lignes normales dans tous les navigateurs ; ":hover"/":checked"
|
|
// sur <option> ne sont fiables qu'avec Firefox (Chrome garde son
|
|
// surlignage natif pour la ligne active) — au pire ce surlignage natif
|
|
// reste visible, seul le blanc sur blanc des lignes normales était un
|
|
// vrai bug bloquant, corrigé ici.
|
|
"#panneau-contact-ia .ligne-champ select option{background:#1a1a1a;color:#999;}" +
|
|
"#panneau-contact-ia .ligne-champ select option:checked," +
|
|
"#panneau-contact-ia .ligne-champ select option:hover{background:#999;color:#111;}" +
|
|
// Bouton "Abandonner" retiré (demande de Jean-Marc, 4 septembre 2026,
|
|
// encore le même soir) : il faisait double emploi avec la touche Échap
|
|
// (voir abandonnerPanneau, toujours utilisée par Échap seule
|
|
// désormais). "Valider" reste seul, en pleine largeur.
|
|
"#panneau-contact-ia #bouton-valider{display:block;width:100%;margin:12px 0 0;" +
|
|
"font:inherit;font-size:13px;background:#2a4d2a;color:#bdf7bd;" +
|
|
"border:1px solid #3a6d3a;border-radius:5px;padding:8px 16px;cursor:pointer;}" +
|
|
"#panneau-contact-ia #bouton-valider:disabled{opacity:.4;cursor:default;}" +
|
|
"#panneau-contact-ia #message-creation{margin-top:8px;font-size:12px;}" +
|
|
"#panneau-contact-ia #message-creation.ok{color:#7be07b;}" +
|
|
"#panneau-contact-ia #message-creation.ko{color:#e14a5c;}";
|
|
document.head.appendChild(style);
|
|
|
|
panneau = document.createElement("div");
|
|
panneau.id = "panneau-contact-ia";
|
|
panneau.innerHTML =
|
|
'<div class="contenu-scrollable">' +
|
|
'<div class="entete-prompt">' +
|
|
'<div id="ligne-prompt-1"><span id="prompt-texte-1"></span><span id="curseur-clignotant">_</span></div>' +
|
|
'<div id="ligne-prompt-2" hidden><span id="prompt-texte-2"></span></div>' +
|
|
'</div>' +
|
|
'<div class="lignes" id="zone-lignes"></div>' +
|
|
'<div class="resultat" id="resultat"></div>' +
|
|
'</div>';
|
|
document.body.appendChild(panneau);
|
|
|
|
lignePrompt1 = panneau.querySelector("#ligne-prompt-1");
|
|
promptTexte1 = panneau.querySelector("#prompt-texte-1");
|
|
curseurClignotant = panneau.querySelector("#curseur-clignotant");
|
|
lignePrompt2 = panneau.querySelector("#ligne-prompt-2");
|
|
promptTexte2 = panneau.querySelector("#prompt-texte-2");
|
|
zoneLignes = panneau.querySelector("#zone-lignes");
|
|
zoneResultat = panneau.querySelector("#resultat");
|
|
|
|
// -------- Champs "bruts" : label + valeur, construits une fois --------
|
|
ligneChamp(zoneResultat, "Nom", "CNT_Nom");
|
|
ligneChamp(zoneResultat, "Prénom", "CNT_Prenom");
|
|
|
|
selectStatut = document.createElement("select");
|
|
selectStatut.dataset.champTag = "statut";
|
|
(function(){
|
|
const ligne = document.createElement("div");
|
|
ligne.className = "ligne-champ";
|
|
const lab = document.createElement("span");
|
|
lab.className = "label-champ";
|
|
lab.textContent = "Statut :";
|
|
ligne.appendChild(lab);
|
|
ligne.appendChild(selectStatut);
|
|
zoneResultat.appendChild(ligne);
|
|
})();
|
|
|
|
selectProfession = document.createElement("select");
|
|
(function(){
|
|
const ligne = document.createElement("div");
|
|
ligne.className = "ligne-champ";
|
|
const lab = document.createElement("span");
|
|
lab.className = "label-champ";
|
|
lab.textContent = "Profession :";
|
|
ligne.appendChild(lab);
|
|
ligne.appendChild(selectProfession);
|
|
zoneResultat.appendChild(ligne);
|
|
})();
|
|
champNouvelleProfession = ligneChamp(zoneResultat, "Nlle profession", "__profession_nouvelle_affichage__");
|
|
champNouvelleProfession.removeAttribute("data-champ"); // pas un champ contact réel, juste un input libre
|
|
blocNouvelleProfession = champNouvelleProfession.closest(".ligne-champ");
|
|
blocNouvelleProfession.style.display = "none";
|
|
|
|
ligneChamp(zoneResultat, "Société", "CNT_Société");
|
|
ligneChamp(zoneResultat, "Service", "CNT_Service");
|
|
ligneChamp(zoneResultat, "Fonction", "CNT_Fonction");
|
|
ligneChamp(zoneResultat, "SIRET", "CNT_SIRET");
|
|
ligneChamp(zoneResultat, "Adresse 1", "CNT_AdrLig1");
|
|
ligneChamp(zoneResultat, "Adresse 2", "CNT_AdrLig2");
|
|
ligneChamp(zoneResultat, "Adresse 3", "CNT_AdrLig3");
|
|
ligneChamp(zoneResultat, "CP", "CNT_AdrCP");
|
|
ligneChamp(zoneResultat, "Ville", "CNT_AdrVille");
|
|
ligneChamp(zoneResultat, "Tél", "CNT_Tel");
|
|
ligneChamp(zoneResultat, "Portable", "CNT_Portable");
|
|
ligneChamp(zoneResultat, "Mail", "CNT_Mail");
|
|
ligneChamp(zoneResultat, "Notes", "CNT_Notes", true);
|
|
|
|
selectAssistant = document.createElement("select");
|
|
(function(){
|
|
const ligne = document.createElement("div");
|
|
ligne.className = "ligne-champ";
|
|
const lab = document.createElement("span");
|
|
lab.className = "label-champ";
|
|
lab.textContent = "Assistant :";
|
|
ligne.appendChild(lab);
|
|
ligne.appendChild(selectAssistant);
|
|
zoneResultat.appendChild(ligne);
|
|
})();
|
|
|
|
// Trois zones de saisie libre pour les actions demandées, plutôt que la
|
|
// liste dynamique "+ Ajouter une action" (select existant/nouveau par
|
|
// ligne) — demande de Jean-Marc (4 septembre 2026, encore le même soir) :
|
|
// la procédure précédente était trop complexe pour ce qui reste un essai
|
|
// rapide à la voix, et l'objectif de cette phase de test est justement
|
|
// de repérer les actions non encore couvertes par ASP_Actions — un texte
|
|
// libre capture ça sans qu'il faille d'abord chercher une correspondance
|
|
// dans une liste. Rien n'est perdu pour l'analyse : PCT_Extraction garde
|
|
// la sortie brute complète du LLM (avec sa tentative de rapprochement à
|
|
// ASP_Actions), seule PCT_Valide simplifie à ces 3 champs — voir
|
|
// remplirActions/collecterActionsValidees.
|
|
champsActions = [1, 2, 3].map(function(n){
|
|
const champ = ligneChamp(zoneResultat, "Action " + n, "__action_" + n + "_affichage__");
|
|
champ.removeAttribute("data-champ"); // pas un champ contact réel, juste un input libre
|
|
return champ;
|
|
});
|
|
|
|
boutonValider = document.createElement("button");
|
|
boutonValider.type = "button";
|
|
boutonValider.id = "bouton-valider";
|
|
boutonValider.textContent = "Valider";
|
|
zoneResultat.appendChild(boutonValider);
|
|
|
|
messageCreation = document.createElement("div");
|
|
messageCreation.id = "message-creation";
|
|
zoneResultat.appendChild(messageCreation);
|
|
|
|
brancherEvenements();
|
|
chargerPrenomPrompt();
|
|
}
|
|
|
|
// Prénom réel affiché dans le prompt façon SSH ("user@<prénom> : "), lu
|
|
// depuis preferences.php (déjà utilisé ailleurs sur cet écran pour le
|
|
// chevalet) plutôt qu'écrit en dur — repli silencieux sur "aspic" si
|
|
// indisponible, même logique que le reste d'ecran_fond.html. La valeur est
|
|
// gardée en mémoire (prenomUtilisateur) et réutilisée par
|
|
// rafraichirTextePrompt() à chaque étape de la séquence d'ouverture, plutôt
|
|
// que d'écrire le texte du prompt directement ici (le fetch peut répondre
|
|
// après que la séquence a déjà commencé).
|
|
let prenomUtilisateur = "aspic";
|
|
function chargerPrenomPrompt(){
|
|
fetch("preferences.php").then(function(r){ return r.json(); }).then(function(prefs){
|
|
prenomUtilisateur = (prefs.USR_Prenom || "aspic").toLowerCase();
|
|
rafraichirTextePrompt();
|
|
}).catch(function(){ /* repli silencieux, garde "user@aspic :" */ });
|
|
}
|
|
|
|
// -------- Séquence d'ouverture façon terminal (demande de Jean-Marc,
|
|
// 4 septembre 2026 soir) --------
|
|
// Phase "attente" : "user@<prénom> : " + curseur "_" clignotant.
|
|
// Phase "pret" (après DUREE_ATTENTE_PROMPT_MS) : "user@<prénom> : micro
|
|
// ouvert" puis, sur une deuxième ligne, "user@<prénom> : parlez !".
|
|
const DUREE_ATTENTE_PROMPT_MS = 1500;
|
|
let phasePrompt = "attente";
|
|
let minuteurSequencePrompt = null;
|
|
|
|
function rafraichirTextePrompt(){
|
|
const prefixe = "user@" + prenomUtilisateur + " : ";
|
|
if(phasePrompt === "pret"){
|
|
promptTexte1.textContent = prefixe + "micro ouvert";
|
|
curseurClignotant.hidden = true;
|
|
promptTexte2.textContent = prefixe + "parlez !";
|
|
lignePrompt2.hidden = false;
|
|
} else {
|
|
promptTexte1.textContent = prefixe;
|
|
curseurClignotant.hidden = false;
|
|
lignePrompt2.hidden = true;
|
|
}
|
|
}
|
|
|
|
function demarrerSequencePrompt(){
|
|
if(minuteurSequencePrompt) clearTimeout(minuteurSequencePrompt);
|
|
phasePrompt = "attente";
|
|
rafraichirTextePrompt();
|
|
minuteurSequencePrompt = setTimeout(function(){
|
|
phasePrompt = "pret";
|
|
rafraichirTextePrompt();
|
|
}, DUREE_ATTENTE_PROMPT_MS);
|
|
}
|
|
|
|
// -------- Hauteur du panneau bornée par le pied réel du micro --------
|
|
// Lit la position/taille effectives de #micro-desk (posées par
|
|
// positionner() dans ecran_fond.html, recalculées à chaque redimensionnement
|
|
// de la fenêtre) plutôt que de dupliquer ici la géométrie de l'image de
|
|
// fond — plus simple et jamais désynchronisé. MARGE_SOUS_PIED_MICRO_PX
|
|
// laisse un petit espace pour ne pas coller pile au pied du micro.
|
|
//
|
|
// "height" fixe et non plus "max-height" (demande de Jean-Marc, 4 septembre
|
|
// 2026, encore le même soir) : le panneau prend tout de suite sa taille
|
|
// maximale à l'ouverture, plutôt que de grandir au fur et à mesure que le
|
|
// contenu (dictée, puis analyse) se remplit — l'ascenseur de
|
|
// .contenu-scrollable s'affiche ensuite ou pas selon que ce contenu dépasse
|
|
// ou non, mais la fenêtre elle-même ne bouge plus.
|
|
const MARGE_SOUS_PIED_MICRO_PX = 16;
|
|
const HAUTEUR_MIN_PANNEAU_PX = 200;
|
|
function ajusterHauteurPanneau(){
|
|
if(!panneau || !panneau.classList.contains("visible")) return;
|
|
const piedMicro = boutonMicro.getBoundingClientRect().bottom;
|
|
const hautPanneau = panneau.getBoundingClientRect().top;
|
|
const hauteurDisponible = piedMicro - hautPanneau - MARGE_SOUS_PIED_MICRO_PX;
|
|
panneau.style.height = Math.max(HAUTEUR_MIN_PANNEAU_PX, hauteurDisponible) + "px";
|
|
}
|
|
window.addEventListener("resize", ajusterHauteurPanneau);
|
|
|
|
// Échap referme le panneau sans rien enregistrer (demande de Jean-Marc,
|
|
// "tant pis si on perd des data") — uniquement quand le panneau est
|
|
// effectivement ouvert, pour ne jamais interférer avec un usage normal
|
|
// d'Échap ailleurs sur l'écran de fond.
|
|
document.addEventListener("keydown", function(evenement){
|
|
if(evenement.key !== "Escape" && evenement.key !== "Esc") return;
|
|
if(!panneau || !panneau.classList.contains("visible")) return;
|
|
abandonnerPanneau();
|
|
});
|
|
|
|
function ouvrirPanneau(){
|
|
construirePanneau();
|
|
panneau.classList.add("visible");
|
|
ajusterHauteurPanneau();
|
|
}
|
|
|
|
function afficherErreur(texte){
|
|
const div = document.createElement("div");
|
|
div.className = "erreur";
|
|
div.textContent = texte;
|
|
zoneLignes.appendChild(div);
|
|
zoneLignes.scrollTop = zoneLignes.scrollHeight;
|
|
}
|
|
|
|
function afficherEnCours(texte){
|
|
if(!ligneEnCours){
|
|
ligneEnCours = document.createElement("div");
|
|
ligneEnCours.className = "en-cours";
|
|
zoneLignes.appendChild(ligneEnCours);
|
|
}
|
|
ligneEnCours.textContent = texte;
|
|
zoneLignes.scrollTop = zoneLignes.scrollHeight;
|
|
}
|
|
|
|
function figerLigne(texte){
|
|
if(!ligneEnCours){
|
|
ligneEnCours = document.createElement("div");
|
|
zoneLignes.appendChild(ligneEnCours);
|
|
}
|
|
ligneEnCours.className = "";
|
|
ligneEnCours.textContent = texte;
|
|
ligneEnCours = null;
|
|
zoneLignes.scrollTop = zoneLignes.scrollHeight;
|
|
texteComplet = (texteComplet + " " + texte).trim();
|
|
}
|
|
|
|
// Remet le panneau à zéro (dictée brute, analyse, message) — utilisé par
|
|
// abandonnerPanneau() et après un "Valider" réussi. Ne ferme pas le
|
|
// panneau lui-même (voir brancherEvenements) : les deux appelants
|
|
// décident séparément s'ils le referment.
|
|
function reinitialiserPanneau(){
|
|
texteComplet = "";
|
|
ligneEnCours = null;
|
|
zoneLignes.innerHTML = "";
|
|
viderFormulaire();
|
|
zoneResultat.classList.remove("visible");
|
|
messageCreation.textContent = "";
|
|
messageCreation.className = "";
|
|
}
|
|
|
|
// Coupe le micro s'il tourne, remet le panneau à zéro et le referme sans
|
|
// rien enregistrer — seule la touche Échap y fait appel désormais (voir
|
|
// plus bas ; l'ancien bouton "Abandonner" faisait double emploi et a été
|
|
// retiré, demande de Jean-Marc : "tant pis si on perd des data").
|
|
function abandonnerPanneau(){
|
|
if(ecouteVoulue){ ecouteVoulue = false; reconnaissance.stop(); }
|
|
reinitialiserPanneau();
|
|
panneau.classList.remove("visible");
|
|
}
|
|
|
|
function viderFormulaire(){
|
|
zoneResultat.querySelectorAll("[data-champ]").forEach(function(el){ el.value = ""; el.classList.remove("interprete"); });
|
|
selectStatut.innerHTML = '<option value="">—</option>';
|
|
selectProfession.innerHTML = '<option value="">—</option><option value="__nouvelle__">+ Nouvelle profession…</option>';
|
|
blocNouvelleProfession.style.display = "none";
|
|
champNouvelleProfession.value = "";
|
|
selectAssistant.innerHTML = '<option value="">—</option>';
|
|
champsActions.forEach(function(champ){ champ.value = ""; champ.classList.remove("interprete"); });
|
|
}
|
|
|
|
function remplirChampsTexte(champs){
|
|
zoneResultat.querySelectorAll("[data-champ]").forEach(function(el){
|
|
const nomChamp = el.dataset.champ;
|
|
if(nomChamp === "CNT_Statut") return;
|
|
const valeur = champs[nomChamp];
|
|
if(valeur){ el.value = valeur; el.classList.add("interprete"); }
|
|
});
|
|
}
|
|
|
|
function remplirStatuts(statuts, statutChoisi){
|
|
(statuts || []).forEach(function(s){
|
|
const option = document.createElement("option");
|
|
option.value = s.STA_Code;
|
|
option.textContent = s.STA_Libellé;
|
|
selectStatut.appendChild(option);
|
|
});
|
|
if(statutChoisi){ selectStatut.value = statutChoisi; selectStatut.classList.add("interprete"); }
|
|
}
|
|
|
|
function remplirProfessions(professions, idExistant, libelleNouveau){
|
|
(professions || []).forEach(function(p){
|
|
const option = document.createElement("option");
|
|
option.value = String(p.PRF_Incr);
|
|
option.textContent = p.PRF_Libelle;
|
|
selectProfession.insertBefore(option, selectProfession.lastElementChild);
|
|
});
|
|
if(idExistant){
|
|
selectProfession.value = String(idExistant);
|
|
selectProfession.classList.add("interprete");
|
|
} else if(libelleNouveau){
|
|
selectProfession.value = "__nouvelle__";
|
|
selectProfession.classList.add("interprete");
|
|
blocNouvelleProfession.style.display = "flex";
|
|
champNouvelleProfession.value = libelleNouveau;
|
|
}
|
|
}
|
|
|
|
// Pas de pré-sélection d'un assistant par défaut ici quand idChoisi est
|
|
// vide (précision de Jean-Marc, 4 septembre 2026 soir) : ce panneau reste
|
|
// un simple reflet honnête de ce qui a été dit et validé — attribuer un
|
|
// assistant par défaut à une dictée où personne n'est nommé est une
|
|
// décision métier qui appartient au traitement n8n déclenché en aval, au
|
|
// moment de transformer une proposition validée (ASP_PropositionsContact)
|
|
// en post-it réel, pas à cet écran.
|
|
//
|
|
// Si la liste "agents" arrive vide, une option le dit explicitement
|
|
// ("aucun assistant en base") plutôt que de laisser un menu qui a l'air
|
|
// simplement vide sans qu'on sache si c'est un manque de données côté
|
|
// ASP_Agents ou autre chose — remonté par Jean-Marc le 4 septembre 2026
|
|
// (encore le même soir) : le menu n'affichait rien à choisir sur son essai,
|
|
// à vérifier d'abord côté données (ASP_Agents peuplée sur la base utilisée
|
|
// pour ce test ?) avant de chercher plus loin, la requête étant strictement
|
|
// identique à celle d'agents.php, déjà en service ailleurs sur cet écran.
|
|
function remplirAssistant(agents, idChoisi){
|
|
(agents || []).forEach(function(a){
|
|
const option = document.createElement("option");
|
|
option.value = String(a.AGN_Incr);
|
|
option.textContent = a.AGN_Nom + (a.AGN_Fonction ? " (" + a.AGN_Fonction + ")" : "");
|
|
selectAssistant.appendChild(option);
|
|
});
|
|
if(!agents || !agents.length){
|
|
const optionVide = document.createElement("option");
|
|
optionVide.value = "";
|
|
optionVide.textContent = "(aucun assistant en base)";
|
|
optionVide.disabled = true;
|
|
selectAssistant.appendChild(optionVide);
|
|
}
|
|
if(idChoisi){ selectAssistant.value = String(idChoisi); selectAssistant.classList.add("interprete"); }
|
|
}
|
|
|
|
// Trois zones de texte libre plutôt qu'une liste dynamique existant/nouveau
|
|
// par ligne (voir la construction du DOM plus haut, même justification).
|
|
// Pré-remplit les 3 premières actions extraites (libellé d'une action
|
|
// existante résolu via actionsDispo, ou le libellé libre proposé par le
|
|
// LLM) ; au-delà de 3, les actions supplémentaires ne sont plus éditables
|
|
// ici mais restent intactes dans PCT_Extraction (rien n'est perdu pour
|
|
// l'analyse, seule la validation se limite à 3 — cohérent avec l'objectif
|
|
// de cette phase : un essai vocal rapide, pas un formulaire exhaustif).
|
|
// Libellé fixe utilisé par le prompt (contact_ia_interprete.php) pour
|
|
// l'action "créer/ajouter le contact" quand elle est demandée — le prompt
|
|
// demande déjà au LLM de la placer en premier dans le tableau, ce filet
|
|
// la remonte en tête côté client si jamais ce n'était pas le cas (demande
|
|
// de Jean-Marc, 4 septembre 2026 soir : cette action doit apparaître en
|
|
// premier dans la liste, il teste justement la création de contacts).
|
|
const LIBELLE_ACTION_CREATION = "Ajouter le contact";
|
|
function remplirActions(actionsDispo, actionsExtraites){
|
|
const libellesParCode = {};
|
|
(actionsDispo || []).forEach(function(a){ libellesParCode[a.ACT_Code] = a.ACT_Libelle; });
|
|
const textes = (actionsExtraites || [])
|
|
.map(function(a){
|
|
return a.action_existante_code
|
|
? (libellesParCode[a.action_existante_code] || a.action_existante_code)
|
|
: (a.action_nouvelle_libelle || "");
|
|
})
|
|
.filter(function(t){ return t; })
|
|
.sort(function(a, b){
|
|
const aCreation = a === LIBELLE_ACTION_CREATION;
|
|
const bCreation = b === LIBELLE_ACTION_CREATION;
|
|
return aCreation === bCreation ? 0 : (aCreation ? -1 : 1);
|
|
});
|
|
champsActions.forEach(function(champ, i){
|
|
if(textes[i]){ champ.value = textes[i]; champ.classList.add("interprete"); }
|
|
});
|
|
}
|
|
|
|
function collecterChamps(){
|
|
const champs = {};
|
|
zoneResultat.querySelectorAll("[data-champ]").forEach(function(el){
|
|
champs[el.dataset.champ] = el.value.trim();
|
|
});
|
|
return champs;
|
|
}
|
|
|
|
function collecterProfessionValidee(){
|
|
if(selectProfession.value === "__nouvelle__"){
|
|
return {profession_existante_id: null, profession_nouvelle_libelle: champNouvelleProfession.value.trim() || null};
|
|
}
|
|
if(selectProfession.value){
|
|
return {profession_existante_id: parseInt(selectProfession.value, 10), profession_nouvelle_libelle: null};
|
|
}
|
|
return {profession_existante_id: null, profession_nouvelle_libelle: null};
|
|
}
|
|
|
|
// Renvoie un simple tableau de textes (plus les objets
|
|
// {action_existante_code, action_nouvelle_libelle} d'avant, devenus sans
|
|
// objet avec la saisie libre) — voir remarque sur PCT_Valide dans
|
|
// contact_ia_valide.php au sujet de ce changement de forme.
|
|
function collecterActionsValidees(){
|
|
return champsActions
|
|
.map(function(champ){ return champ.value.trim(); })
|
|
.filter(function(v){ return v; });
|
|
}
|
|
|
|
// Appelée automatiquement quand le micro s'arrête proprement (voir
|
|
// l'événement "end" de la reconnaissance, plus bas) — plus de bouton
|
|
// "Interpréter" à cliquer (demande de Jean-Marc, 4 septembre 2026 soir).
|
|
// Le statut "Interprétation en cours…" est affiché dans la même zone que
|
|
// la dictée brute et les erreurs, comme une ligne de terminal parmi
|
|
// d'autres, puis retiré une fois la réponse arrivée (succès ou échec).
|
|
function lancerInterpretation(){
|
|
if(!texteComplet) return;
|
|
|
|
const ligneStatut = document.createElement("div");
|
|
ligneStatut.className = "en-cours";
|
|
ligneStatut.textContent = "Interprétation en cours…";
|
|
zoneLignes.appendChild(ligneStatut);
|
|
zoneLignes.scrollTop = zoneLignes.scrollHeight;
|
|
|
|
fetch("contact_ia_interprete.php", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({texte: texteComplet})
|
|
}).then(function(reponse){
|
|
return reponse.json().then(function(corps){
|
|
if(!reponse.ok) throw new Error(corps.error || ("HTTP " + reponse.status));
|
|
return corps;
|
|
});
|
|
}).then(function(corps){
|
|
ligneStatut.remove();
|
|
derniereExtraction = corps.extraction;
|
|
viderFormulaire();
|
|
remplirChampsTexte(corps.extraction.champs);
|
|
remplirStatuts(corps.statuts, corps.extraction.champs.CNT_Statut);
|
|
remplirProfessions(corps.professions, corps.extraction.profession_existante_id, corps.extraction.profession_nouvelle_libelle);
|
|
remplirAssistant(corps.agents, corps.extraction.assistant_id);
|
|
remplirActions(corps.actionsDisponibles, corps.extraction.actions);
|
|
zoneResultat.classList.add("visible");
|
|
zoneResultat.scrollIntoView({behavior:"smooth", block:"start"});
|
|
}).catch(function(erreur){
|
|
ligneStatut.remove();
|
|
afficherErreur("Échec de l'interprétation : " + erreur.message);
|
|
});
|
|
}
|
|
|
|
function brancherEvenements(){
|
|
selectProfession.addEventListener("change", function(){
|
|
blocNouvelleProfession.style.display = (selectProfession.value === "__nouvelle__") ? "flex" : "none";
|
|
});
|
|
|
|
boutonValider.addEventListener("click", function(){
|
|
const champs = collecterChamps();
|
|
if(!champs.CNT_Nom){
|
|
messageCreation.className = "ko";
|
|
messageCreation.textContent = "Le nom est obligatoire.";
|
|
return;
|
|
}
|
|
boutonValider.disabled = true;
|
|
boutonValider.textContent = "Enregistrement en cours…";
|
|
messageCreation.textContent = "";
|
|
messageCreation.className = "";
|
|
|
|
const valide = Object.assign(
|
|
{champs: champs},
|
|
collecterProfessionValidee(),
|
|
{
|
|
assistant_id: selectAssistant.value ? parseInt(selectAssistant.value, 10) : null,
|
|
actions: collecterActionsValidees()
|
|
}
|
|
);
|
|
|
|
fetch("contact_ia_valide.php", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({texteBrut: texteComplet, extraction: derniereExtraction, valide: valide})
|
|
}).then(function(reponse){
|
|
return reponse.json().then(function(corps){
|
|
if(!reponse.ok) throw new Error(corps.error || ("HTTP " + reponse.status));
|
|
return corps;
|
|
});
|
|
}).then(function(){
|
|
messageCreation.className = "ok";
|
|
messageCreation.textContent = "Essai enregistré (aucun contact réel créé) — merci !";
|
|
// Le panneau NE se referme PLUS après Valider (demande de Jean-Marc,
|
|
// 4 septembre 2026 soir : il enchaîne plusieurs dictées à la suite,
|
|
// sortir de la fenêtre à chaque fois n'était pas pratique). Il reste
|
|
// ouvert, remis à zéro et prêt pour la dictée suivante — Échap reste
|
|
// le seul moyen de le refermer, quand il n'y a plus rien à saisir.
|
|
//
|
|
// Le micro se rouvre aussi automatiquement (demande de Jean-Marc,
|
|
// encore le même soir : "on revient en écran vierge mais il ne se
|
|
// passe rien, je pense que le micro est fermé" — exact, il l'était).
|
|
// Sans ça, revenir à un écran vierge sans micro actif ne servait à
|
|
// rien pour enchaîner. Le garde-fou !ecouteVoulue évite de rappeler
|
|
// reconnaissance.start() si Jean-Marc a déjà recliqué le micro
|
|
// pendant les 1,8s d'affichage de la confirmation (start() sur une
|
|
// reconnaissance déjà démarrée lève une erreur).
|
|
setTimeout(function(){
|
|
reinitialiserPanneau();
|
|
boutonValider.disabled = false;
|
|
boutonValider.textContent = "Valider";
|
|
if(!ecouteVoulue) demarrerEcoute();
|
|
}, 1800);
|
|
}).catch(function(erreur){
|
|
messageCreation.className = "ko";
|
|
messageCreation.textContent = "Échec de l'enregistrement : " + erreur.message;
|
|
boutonValider.disabled = false;
|
|
boutonValider.textContent = "Valider";
|
|
});
|
|
});
|
|
}
|
|
|
|
// -------- Micro (#micro-desk) --------
|
|
let dernierDemarrageHorodatage = 0;
|
|
let cyclesRapidesConsecutifs = 0;
|
|
const SEUIL_CYCLE_RAPIDE_MS = 800;
|
|
const MAX_CYCLES_RAPIDES = 5;
|
|
|
|
function passerEnEcoute(){
|
|
boutonMicro.classList.add("ecoute");
|
|
boutonMicro.title = "Le micro est ouvert (interprétation IA de contact — outil de mise au point)";
|
|
ouvrirPanneau();
|
|
demarrerSequencePrompt();
|
|
reinitialiserMinuteurInactivite();
|
|
}
|
|
|
|
function revenirAuRepos(){
|
|
ecouteVoulue = false;
|
|
arreterMinuteurInactivite();
|
|
arreterMinuteurSilence();
|
|
boutonMicro.classList.remove("ecoute");
|
|
boutonMicro.title = "Le micro est inactif";
|
|
// Le panneau reste visible volontairement (voir en-tête) : seule
|
|
// l'écoute s'arrête, pour laisser Jean-Marc relire/corriger/valider.
|
|
}
|
|
|
|
// Factorisé pour être appelable aussi bien depuis le clic sur le micro que
|
|
// depuis la reprise automatique après un "Valider" réussi (voir
|
|
// brancherEvenements) — même logique, même gestion d'erreur.
|
|
function demarrerEcoute(){
|
|
ecouteVoulue = true;
|
|
erreurBloquante = false;
|
|
try{
|
|
reconnaissance.start();
|
|
}catch(erreur){
|
|
ecouteVoulue = false;
|
|
ouvrirPanneau();
|
|
afficherErreur("Impossible de démarrer le micro (" + erreur.name + ").");
|
|
}
|
|
}
|
|
|
|
boutonMicro.addEventListener("click", function(){
|
|
if(ecouteVoulue){
|
|
ecouteVoulue = false;
|
|
reconnaissance.stop();
|
|
return;
|
|
}
|
|
demarrerEcoute();
|
|
});
|
|
|
|
reconnaissance.addEventListener("start", function(){
|
|
dernierDemarrageHorodatage = Date.now();
|
|
passerEnEcoute();
|
|
});
|
|
|
|
reconnaissance.addEventListener("end", function(){
|
|
if(ecouteVoulue && !erreurBloquante){
|
|
const ecoule = Date.now() - dernierDemarrageHorodatage;
|
|
cyclesRapidesConsecutifs = (ecoule < SEUIL_CYCLE_RAPIDE_MS) ? (cyclesRapidesConsecutifs + 1) : 0;
|
|
if(cyclesRapidesConsecutifs >= MAX_CYCLES_RAPIDES){
|
|
erreurBloquante = true;
|
|
revenirAuRepos();
|
|
afficherErreur("Le micro redémarre en boucle anormalement vite — arrêt automatique. Réessaie dans quelques secondes.");
|
|
return;
|
|
}
|
|
try{
|
|
reconnaissance.start();
|
|
}catch(erreur){
|
|
revenirAuRepos();
|
|
afficherErreur("Le micro s'est arrêté et n'a pas pu redémarrer automatiquement.");
|
|
}
|
|
return;
|
|
}
|
|
// Arrêt propre (clic volontaire de Jean-Marc, ecouteVoulue déjà à false,
|
|
// pas une erreur bloquante) : c'est le signal pour lancer l'interprétation
|
|
// automatiquement, plus de bouton dédié (voir en-tête et lancerInterpretation).
|
|
const arretPropre = !erreurBloquante;
|
|
revenirAuRepos();
|
|
if(arretPropre) lancerInterpretation();
|
|
});
|
|
|
|
reconnaissance.addEventListener("result", function(evenement){
|
|
const dernier = evenement.results[evenement.results.length - 1];
|
|
const texte = dernier[0].transcript.trim();
|
|
if(!texte) return;
|
|
// Tout résultat (intermédiaire ou final) repousse l'arrêt par silence :
|
|
// Jean-Marc parle encore, ou vient de parler. Voir DUREE_SILENCE_ARRET_MS.
|
|
reinitialiserMinuteurSilence();
|
|
if(dernier.isFinal){
|
|
figerLigne(texte);
|
|
reinitialiserMinuteurInactivite();
|
|
} else {
|
|
afficherEnCours(texte);
|
|
}
|
|
});
|
|
|
|
reconnaissance.addEventListener("error", function(evenement){
|
|
const messagesBloquants = {
|
|
"not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
|
|
"service-not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.",
|
|
"audio-capture": "Aucun micro détecté."
|
|
};
|
|
if(messagesBloquants[evenement.error]){
|
|
erreurBloquante = true;
|
|
ouvrirPanneau();
|
|
afficherErreur(messagesBloquants[evenement.error]);
|
|
}
|
|
});
|
|
})();
|