v3
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"app": "/opt/alfred/app",
|
||||
"existing": [
|
||||
"static/js/dona-holographic-v1.js",
|
||||
"static/css/dona-holographic-v1.css",
|
||||
"templates/index.html"
|
||||
],
|
||||
"created": [
|
||||
"static/js/dona-markdown-renderer.js"
|
||||
]
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/* DONA_HOLOGRAPHIC_V1
|
||||
* Thème transversal non destructif. Les contrôleurs et APIs existants restent les mêmes.
|
||||
* Chargement APRÈS les feuilles Glass, y compris les correctifs précédents.
|
||||
*/
|
||||
:root { color-scheme:dark; --dh-white:#f1f4ff;--dh-muted:#a8b8d3;--dh-border:rgba(155,183,234,.23);--dh-panel:rgba(4,10,22,.81);--dh-blue:#5796ff;--dh-teal:#74e6d4;--dh-purple:#ab78ff;--dh-orange:#ffae73;--dh-shadow:0 24px 72px rgba(0,0,0,.38); }
|
||||
html {scrollbar-color:#596b8c #060a14;}
|
||||
body.dona-holo-v1 { color:var(--dh-white); background-color:#070913!important; background-image:
|
||||
radial-gradient(ellipse 35% 39% at 3% 70%,rgba(251,101,44,.5),transparent 95%),
|
||||
radial-gradient(ellipse 36% 52% at 96% 26%,rgba(18,125,236,.42),transparent 85%),
|
||||
radial-gradient(ellipse 42% 35% at 50% 0%,rgba(119,68,173,.31),transparent 90%),
|
||||
radial-gradient(ellipse 24% 32% at 82% 94%,rgba(243,97,42,.29),transparent 92%),
|
||||
linear-gradient(125deg,#070810,#0a1020 54%,#090914) !important;
|
||||
background-attachment:fixed!important; }
|
||||
body.dona-holo-v1::before{content:"";position:fixed;z-index:0;inset:-10%;pointer-events:none;
|
||||
background:radial-gradient(ellipse at 12% 18%,rgba(56,111,251,.17),transparent 33%),radial-gradient(ellipse at 78% 65%,rgba(227,100,165,.15),transparent 32%);
|
||||
filter:blur(68px);animation:dh-ambient 19s ease-in-out infinite alternate;}
|
||||
body.dona-holo-v1::after{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
|
||||
background:linear-gradient(90deg,rgba(1,5,13,.13),rgba(1,5,13,.45) 43%,rgba(1,5,13,.18));}
|
||||
@keyframes dh-ambient {0%{transform:translate(-1.5%,0)}100%{transform:translate(2%,1.4%)}}
|
||||
/* Écrans d'administration, qui utilisent des conteneurs et feuilles CSS différents. */
|
||||
body.dona-holo-v1 :is(.admin-layout,.id-shell,.id-wrap,.id-layout,.dv-main,.lv-layout,.lg-layout,.log-layout,.logs-layout){position:relative;z-index:1;}
|
||||
body.dona-holo-v1 .alfred-main-v1{z-index:1;}
|
||||
/* Priorité au panneau noir central de la maquette. Le décor reste diffus en périphérie. */
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-head {background:transparent!important;}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-logo {background:linear-gradient(145deg,#95c2ff,#5187ff 55%,#c58bff);background-clip:text;-webkit-background-clip:text;color:transparent;filter:drop-shadow(0 1px 18px #4486ff73);}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-brand small{color:#c4cde1;}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-round{background:rgba(5,9,22,.74)!important;border:1px solid rgba(155,174,224,.24)!important;box-shadow:inset 0 1px rgba(255,255,255,.04);}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-round[aria-pressed="true"]{background:rgba(108,28,58,.75)!important;border-color:rgba(255,116,151,.66)!important;}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-summary{gap:clamp(9px,1.05vw,18px);}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-stat-card{color:var(--dh-white)!important;
|
||||
background:linear-gradient(140deg,rgba(6,13,31,.79),rgba(8,16,34,.68))!important;
|
||||
border:1px solid rgba(126,157,227,.24)!important;border-radius:22px!important;
|
||||
box-shadow:0 12px 44px #01051255,inset 0 1px #ffffff0a!important;
|
||||
backdrop-filter:blur(20px) saturate(120%)!important;-webkit-backdrop-filter:blur(20px) saturate(120%)!important;
|
||||
transition:transform .2s,border-color .2s,box-shadow .2s;}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-stat-card:hover{transform:translateY(-2px);border-color:#8bb9ff83!important;box-shadow:0 14px 45px #0206128b,0 0 23px #336ce22c!important;background:rgba(10,21,42,.85)!important;}
|
||||
body.dona-holo-v1 .av-circle{background:#1e376566;color:#94c9ff;}
|
||||
body.dona-holo-v1 .av-circle.ai{background:#492b7a80;color:#d2b3ff;}
|
||||
body.dona-holo-v1 .av-weather{background:linear-gradient(140deg,rgba(12,28,44,.74),rgba(8,19,37,.8))!important;}
|
||||
body.dona-holo-v1 .av-shortcuts button{background:rgba(5,10,24,.82)!important;border:1px solid rgba(151,177,221,.27)!important;border-radius:21px!important;box-shadow:inset 0 1px rgba(255,255,255,.035);}
|
||||
body.dona-holo-v1 .av-shortcuts button:hover{background:#102344e6!important;border-color:#65a1ff82!important;}
|
||||
body.dona-holo-v1 .av-shortcuts .a-icon{color:#a9c6ff;}
|
||||
body.dona-holo-v1 .av-status-line{background:rgba(6,11,27,.65);border-color:rgba(157,186,229,.23);}
|
||||
body.dona-holo-v1 .av-wave-host canvas{filter:drop-shadow(0 0 9px rgba(91,137,255,.65))!important;}
|
||||
/* La réponse qui était une énorme div pleine page devient une scène avec cartes vivantes. */
|
||||
body.dona-holo-v1.alfred-glass-theme #stage {top:clamp(93px,11vh,128px)!important;right:clamp(12px,2vw,34px)!important;bottom:clamp(12px,2.3vh,30px)!important;left:clamp(12px,2vw,34px)!important;
|
||||
background:linear-gradient(146deg,rgba(2,5,13,.97),rgba(3,7,18,.96) 55%,rgba(6,10,26,.95))!important;
|
||||
border:1px solid rgba(154,180,231,.25)!important;border-radius:clamp(18px,2vw,31px)!important;
|
||||
padding:clamp(60px,8vh,100px) clamp(14px,3vw,46px) clamp(14px,3vh,28px)!important;
|
||||
box-shadow:0 36px 95px rgba(0,0,0,.55),inset 0 1px #d7dfff0d;
|
||||
display:flex!important;align-items:center!important;justify-content:center!important;overflow:auto!important;}
|
||||
body.dona-holo-v1.alfred-glass-theme #stage #content{width:min(1300px,100%)!important;max-width:none!important;max-height:none!important;min-height:0!important;overflow:visible!important;margin:auto;}
|
||||
body.dona-holo-v1.alfred-glass-theme #stage .av-reponse-actions {top:16px!important;right:20px!important;}
|
||||
body.dona-holo-v1.alfred-glass-theme #stage :is(#avBack.av-reponse-home,.av-reponse-close){background:rgba(17,27,51,.91)!important;border:1px solid #9bb9ec55!important;color:#f4f7ff!important;border-radius:15px!important;}
|
||||
body.dona-holo-v1.alfred-glass-theme #stage .av-reponse-close{border-radius:50%!important;}
|
||||
/* Réponse illustrée construite sur les données déjà présentes dans la réponse Hermes.
|
||||
Les éléments ne sont pas des chiffres simulés : un métrique n'apparaît que s'il est cité. */
|
||||
#content .dh-response{display:grid;grid-template-columns:minmax(250px,1.12fr) minmax(300px,.95fr);gap:clamp(12px,1.6vw,22px);align-items:stretch;min-width:0;}
|
||||
#content .dh-primary{position:relative;min-width:0;min-height:335px;display:flex;flex-direction:column;gap:20px;
|
||||
padding:clamp(22px,2.3vw,36px);border:1px solid rgba(96,142,255,.72);border-radius:24px;
|
||||
background:linear-gradient(135deg,rgba(13,30,62,.9),rgba(6,14,31,.89) 62%,rgba(15,36,42,.82));
|
||||
box-shadow:inset 0 1px #ffffff12,0 0 23px #3976fc2b,0 20px 35px #0004;}
|
||||
#content .dh-primary:before{content:"";position:absolute;inset:-1px;pointer-events:none;border-radius:24px;background:linear-gradient(120deg,#5b83ff33,transparent 44%,#72e8cd17);opacity:.7;}
|
||||
#content .dh-kicker{display:flex;align-items:center;gap:11px;font-size:11px!important;color:#a9bee6!important;letter-spacing:.16em;font-weight:700;text-transform:uppercase;}
|
||||
#content .dh-kicker:before{content:"";height:9px;width:9px;background:#61a3ff;border-radius:50%;box-shadow:0 0 12px #579cff;}
|
||||
#content .dh-headline{font-size:clamp(21px,2.1vw,33px)!important;letter-spacing:-.025em;font-weight:590;line-height:1.28;color:#f4f6ff;overflow-wrap:anywhere;}
|
||||
#content .dh-excerpt{color:#c8d2e6!important;font-size:clamp(13px,1.02vw,16px)!important;line-height:1.7;white-space:pre-wrap;overflow-wrap:anywhere;flex:1;}
|
||||
#content .dh-more{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:52px;padding:13px 16px;
|
||||
background:rgba(37,58,95,.44);border:1px solid #aac9ff42;border-radius:13px;color:#f0f5ff;font:600 13px/1.3 inherit;cursor:pointer;text-align:left;}
|
||||
#content .dh-more:hover{background:#294b748e;border-color:#95caff92;}
|
||||
#content .dh-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:minmax(145px,1fr);gap:14px;min-width:0;}
|
||||
#content .dh-fact{border:1px solid rgba(113,151,218,.35);border-radius:20px;padding:clamp(16px,1.7vw,25px);min-width:0;
|
||||
background:linear-gradient(135deg,rgba(17,34,63,.94),rgba(9,22,48,.85));box-shadow:inset 0 1px #ffffff0d;display:flex;flex-direction:column;gap:8px;}
|
||||
#content .dh-fact:nth-child(3n + 2){border-color:rgba(101,209,189,.29);background:linear-gradient(145deg,rgba(12,38,52,.94),rgba(13,26,45,.85));}
|
||||
#content .dh-fact:nth-child(3n + 3){border-color:rgba(239,150,94,.27);background:linear-gradient(135deg,rgba(45,29,40,.91),rgba(29,22,37,.86));}
|
||||
#content .dh-fact:nth-child(4n){border-color:rgba(167,124,239,.28);background:linear-gradient(135deg,rgba(31,26,60,.94),rgba(16,20,43,.86));}
|
||||
#content .dh-fact-label{font-size:clamp(11px,.85vw,14px)!important;line-height:1.35;color:#c1cee8;}
|
||||
#content .dh-fact-icon{font-size:21px;margin-bottom:6px;opacity:.9;}
|
||||
#content .dh-fact-value{font-size:clamp(19px,2.25vw,36px)!important;font-weight:570;line-height:1.15;color:#f9faff;letter-spacing:-.04em;overflow-wrap:anywhere;}
|
||||
#content .dh-fact-note{font-size:11px!important;color:#93a6c9;line-height:1.5;margin-top:auto;overflow-wrap:anywhere;}
|
||||
#content .dh-tags{display:flex;flex-wrap:wrap;gap:7px;margin-top:6px;}
|
||||
#content .dh-tag{padding:7px 11px;background:rgba(88,111,171,.2);border:1px solid rgba(143,166,218,.24);border-radius:999px;color:#d1daf6;font-size:12px;}
|
||||
#content .dh-full{grid-column:1/-1;border:1px solid rgba(145,174,227,.27);background:rgba(11,20,39,.82);border-radius:18px;padding:20px;}
|
||||
#content .dh-full[hidden]{display:none!important;}
|
||||
#content .dh-full h3{font-size:16px;margin:0 0 12px;color:#deecff;text-align:left;}
|
||||
#content .dh-full-text{white-space:pre-wrap;color:#ced8ea;line-height:1.65;font-size:14px;overflow-wrap:anywhere;}
|
||||
#content .dh-source{font-size:11px;color:#8b9fc1;margin-top:9px;}
|
||||
#content .dh-response:after{content:"";grid-column:1/-1;justify-self:center;width:min(490px,64%);height:3px;
|
||||
background:linear-gradient(90deg,transparent,#3e92fe 30%,#c876f8 55%,#f7a569 73%,transparent);border-radius:50%;box-shadow:0 0 18px 5px #529bff5b;margin:7px 0 0;}
|
||||
/* Les anciens types de cartes émis par les renderers restent pris en charge. */
|
||||
body.dona-holo-v1 #stage #content > .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:17px;text-align:left;}
|
||||
body.dona-holo-v1 #stage #content > .cards .card{padding:24px;border-radius:21px;background:linear-gradient(145deg,#13243ddf,#0a162ce5);border:1px solid #7c9cde57;text-align:left;}
|
||||
body.dona-holo-v1 #stage #content > img.hero {max-height:calc(100dvh - 260px);width:auto;max-width:100%;object-fit:contain;border-radius:16px;}
|
||||
/* Configuration et Surveillance */
|
||||
body.dona-holo-v1.alfred-admin-glass .admin-layout{z-index:1;}
|
||||
body.dona-holo-v1.alfred-admin-glass .ag-rail{background:rgba(3,7,20,.89)!important;border-color:var(--dh-border)!important;}
|
||||
body.dona-holo-v1.alfred-admin-glass .admin-header{background:transparent!important;}
|
||||
body.dona-holo-v1.alfred-admin-glass .brand-orb{background:linear-gradient(135deg,#438bff,#9368fa)!important;border-radius:18px;}
|
||||
body.dona-holo-v1.alfred-admin-glass :is(.panel,.metric-card,.auth-box,.ag-row,.mcp-item){
|
||||
background:linear-gradient(145deg,rgba(5,13,30,.91),rgba(9,18,36,.84))!important;
|
||||
border:1px solid rgba(133,164,229,.29)!important;border-radius:21px!important;box-shadow:var(--dh-shadow);backdrop-filter:blur(18px);}
|
||||
body.dona-holo-v1.alfred-admin-glass .page-top .eyebrow{color:#9cb9ff;}
|
||||
body.dona-holo-v1.alfred-admin-glass .button.primary{background:linear-gradient(125deg,#3063d0,#556dca)!important;border-color:#98baff88!important;}
|
||||
body.dona-holo-v1.alfred-admin-glass :is(.field input:not([type=range]):not([type=checkbox]),.field select,.ag-field input,.ag-field select){background:rgba(4,11,25,.87)!important;border-color:#799acc55!important;color:#f2f6ff!important;}
|
||||
body.dona-holo-v1.alfred-admin-glass .admin-header nav :is(a,button){background:rgba(6,13,31,.78)!important;border-color:#8aace950!important;}
|
||||
body.dona-holo-v1.alfred-admin-glass .admin-header nav .selected{border-color:#76a5fd!important;background:rgba(38,73,138,.92)!important;}
|
||||
/* Pages dont les classes proviennent d'extensions installées séparément. */
|
||||
body.dona-holo-v1 :is(.id-panel,.dv-panel,.lg-panel,.lv-panel,.log-panel,.logs-panel,.glass-panel,.id-editor,.dv-task,.dv-list,.dv-detail){background:linear-gradient(135deg,rgba(6,14,31,.92),rgba(12,20,42,.87))!important;
|
||||
border:1px solid rgba(127,158,221,.3)!important;border-radius:22px!important;box-shadow:0 15px 35px rgba(0,0,0,.22);backdrop-filter:blur(15px);}
|
||||
body.dona-holo-v1 :is(.dv-head,.id-header,.id-head,.lv-head,.lg-head,.logs-header,.log-header){position:relative;z-index:2;background:rgba(5,10,23,.66)!important;border-color:#819bd046!important;backdrop-filter:blur(15px);}
|
||||
body.dona-holo-v1 :is(.dv-head,.id-header,.id-head) :is(a,button){border-radius:12px;}
|
||||
body.dona-holo-v1 :is(.dv-main,.id-main,.lg-main,.lv-main,.logs-main,.log-main) h1{color:#f5f8ff;letter-spacing:-.035em;}
|
||||
body.dona-holo-v1 :is(input,select,textarea){accent-color:#7fa7ff;}
|
||||
body.dona-holo-v1 :is(.id-panel,.dv-panel,.lg-panel,.lv-panel,.log-panel,.logs-panel) :is(input:not([type=checkbox]):not([type=range]),textarea,select){background:rgba(5,11,27,.91)!important;color:#f2f6ff!important;border:1px solid #96b9ef55!important;border-radius:12px!important;}
|
||||
body.dona-holo-v1 :is(.dv-primary,.id-button.primary){background:linear-gradient(120deg,#376cdd,#6763d5)!important;color:white!important;}
|
||||
body.dona-holo-v1 :is(a,button,input,select,textarea):focus-visible{outline:2px solid #a9c5ff;outline-offset:3px;}
|
||||
/* Page Journaux : log monospace lisible, pas de glow derrière le contenu. */
|
||||
body.dona-holo-v1 :is(.lg-list,.logs-feed,.log-feed,.logs-table,.log-list,.lg-entries,pre.dv-diff){background:rgba(2,6,15,.79)!important;border:1px solid rgba(122,149,208,.25)!important;border-radius:14px!important;color:#d2e5ff;}
|
||||
/* Les modules secondaires restent visibles au-dessus des effets de fond. */
|
||||
body.dona-holo-v1 :is(dialog,.av-image-dialog,.camera-popup,#alfredTimersDock,#alfredTimerToast,.av-more-dialog,.audio-popover){z-index:100;}
|
||||
@media(max-width:1050px){#content .dh-response{grid-template-columns:1fr;}#content .dh-primary{min-height:220px}#content .dh-facts{grid-auto-rows:minmax(125px,auto);}body.dona-holo-v1.alfred-glass-theme #stage{align-items:flex-start!important;}}
|
||||
@media(max-width:700px){body.dona-holo-v1.alfred-glass-theme #stage{top:85px!important;left:8px!important;right:8px!important;bottom:8px!important;padding:80px 12px 18px!important;}#content .dh-response{display:block;}#content .dh-primary{padding:20px;margin-bottom:13px;}#content .dh-facts{grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;}#content .dh-fact{padding:14px;min-height:130px;}#content .dh-full{margin-top:12px;}.alfred-glass-theme #stage .av-reponse-actions{top:12px!important;right:12px!important;}}
|
||||
@media(prefers-reduced-motion:reduce){body.dona-holo-v1::before{animation:none}body.dona-holo-v1 .av-stat-card{transition:none}}
|
||||
/* Finitions du panneau et affichage sans métriques */
|
||||
#content .dh-response{text-align:left;}
|
||||
#content .dh-response.dh-no-facts{grid-template-columns:minmax(0,900px);justify-content:center;}
|
||||
#content .dh-more{font-family:inherit;font-size:13px;font-weight:600;line-height:1.3;}
|
||||
body.dona-holo-v1, #content .dh-response{font-family:Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;}
|
||||
/* Fenêtres secondaires de l'accueil (listes, ComfyUI, caméra) dans la même palette. */
|
||||
body.dona-holo-v1 :is(#alfredListsGlassDialog,.av-image-dialog,.av-comfy-dialog,.av-more-dialog,.av-popup,.camera-popup,.audio-popover){
|
||||
background:linear-gradient(145deg,rgba(4,10,24,.97),rgba(9,19,41,.96))!important;
|
||||
border:1px solid rgba(142,176,242,.38)!important;border-radius:22px!important;
|
||||
box-shadow:0 32px 90px #000a,0 0 26px #4f7fff18!important;color:#f4f7ff!important;
|
||||
backdrop-filter:blur(22px)!important;}
|
||||
body.dona-holo-v1 :is(.al-list-section,.al-item){background:rgba(14,25,48,.75)!important;border-color:#8aa9e34a!important;}
|
||||
body.dona-holo-v1 :is(.al-add-form button,.al-footer button){background:linear-gradient(120deg,#254a9f,#4f56bb)!important;border-color:#8baffb77!important;}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
/* DONA_HOLOGRAPHIC_V1
|
||||
* Cartes contextuelles issues du TEXTE réellement reçu d'Hermes.
|
||||
* Aucune donnée, état de serveur, distance ou durée n'est calculé/inventé.
|
||||
* Les autres vues (images, calendriers, cartes de renderer) restent inchangées.
|
||||
*/
|
||||
(() => {
|
||||
'use strict';
|
||||
const words = Object.freeze({zero:0,un:1,une:1,deux:2,trois:3,quatre:4,cinq:5,six:6,sept:7,huit:8,neuf:9,dix:10,onze:11,douze:12,treize:13,quatorze:14,quinze:15,seize:16,dixsept:17,dixhuit:18,dixneuf:19,vingt:20});
|
||||
function numberValue(s){
|
||||
if(!s)return null;
|
||||
const normalized=String(s).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/[\s-]/g,'');
|
||||
if(Object.prototype.hasOwnProperty.call(words,normalized))return String(words[normalized]);
|
||||
const str=String(s).trim();return /^\d{1,5}$/.test(str)?str:null;
|
||||
}
|
||||
const number='(\\d{1,5}|vingt|dix[- ]?neuf|dix[- ]?huit|dix[- ]?sept|seize|quinze|quatorze|treize|douze|onze|dix|neuf|huit|sept|six|cinq|quatre|trois|deux|une|un)';
|
||||
function getFacts(input) {
|
||||
const text=String(input||'').replace(/\u00a0/g,' ').slice(0,16000);
|
||||
const facts=[];const seen=new Set();
|
||||
function add(key,icon,label,value,note='Mentionné dans la réponse'){
|
||||
if(!value||seen.has(key)||facts.length>=4)return;
|
||||
facts.push({key,icon,label,value:String(value),note});seen.add(key);
|
||||
}
|
||||
let m;
|
||||
m=text.match(new RegExp('\\b'+number+'\\s+(?:machines?\\s+virtuelles?|VMs?\\b)','i'));
|
||||
if(m)add('vm','⬡','Machines virtuelles',numberValue(m[1]),'Chiffre annoncé par Hermes');
|
||||
m=text.match(new RegExp('\\b'+number+'\\s+(?:n[œo]uds?|nodes?)(?:\\s|\\.|,|$)','i'));
|
||||
if(m)add('nodes','▦','Nœuds mentionnés',numberValue(m[1]),'Chiffre annoncé par Hermes');
|
||||
m=text.match(/\b(\d{1,3})\s*\/\s*(\d{1,3})\s*(?:n[œo]uds|serveurs|nodes?)\b/i);
|
||||
if(m)add('online','●','Nœuds disponibles',m[1]+' / '+m[2],'Valeur citée par Hermes');
|
||||
const route=/\b(trajet|distance|route|entre\s+\S+\s+et\s+\S+|autoroute|en voiture)\b/i.test(text);
|
||||
const building=/\b(hauteur|haut(?:e|s)?|b[aâ]timent|immeuble|tour|monument|construction)\b/i.test(text);
|
||||
if(route){
|
||||
m=text.match(/\b(\d{1,5}(?:[,.]\d+)?)\s*(kilom[eè]tres?|km)\b/i);
|
||||
if(m)add('distance','↗','Distance indiquée',m[1].replace(',', '.')+' km','Distance issue de la réponse');
|
||||
m=text.match(/\b(\d{1,3})\s*(?:à|a|–|-)\s*(\d{1,3})\s*(minutes?|min|heures?|h)\b/i);
|
||||
if(m)add('duration','◷','Durée annoncée',m[1]+' à '+m[2]+' '+m[3],'Estimation communiquée par Hermes');
|
||||
else {
|
||||
m=text.match(/\b(\d{1,3})\s*(minutes?|min|heures?|h)\b/i);
|
||||
if(m)add('duration','◷','Durée annoncée',m[1]+' '+m[2],'Estimation communiquée par Hermes');
|
||||
}
|
||||
}
|
||||
if(building&&!route){
|
||||
m=text.match(/\b(\d{1,5}(?:[,.]\d+)?)\s*(?:m[eè]tres?|m)\b/i);
|
||||
if(m)add('height','♜','Hauteur indiquée',m[1]+' m','Valeur issue de la réponse');
|
||||
}
|
||||
if(/\b(temp[eé]rature|m[eé]t[eé]o|degr[eé]s)\b/i.test(text)){
|
||||
m=text.match(/(-?\d{1,3}(?:[,.]\d+)?)\s*°\s*C\b/i);
|
||||
if(m)add('temp','☀','Température citée',m[1]+' °C','Valeur issue de la réponse');
|
||||
}
|
||||
// Ne jamais induire qu'un nœud est en ligne à partir du seul nom.
|
||||
if(facts.length<4){
|
||||
m=text.match(/[«"']\s*([\wÀ-ÿ][\wÀ-ÿ_.-]{1,39})\s*[»"'][^.!?\n]{0,90}(arr[eê]t[eé]e?|[eé]teint(?:e)?)(?=[\s,.!?]|$)/i);
|
||||
if(m)add('stopped','⏻','Équipement signalé arrêté',m[1],'État décrit dans la réponse');
|
||||
}
|
||||
if(facts.length===0){
|
||||
m=text.match(/\b(?:il est(?: actuellement)?|heure(?: locale)?\s*[::])\s*(\d{1,2}[:h]\d{2})\b/i);
|
||||
if(m)add('time','◷','Heure indiquée',m[1].replace('h',':'),'Issue de la réponse, non vérifiée ici');
|
||||
}
|
||||
const names=[];
|
||||
const re=/(?:n[œo]ud|node)\s*['"«]?([\wÀ-ÿ_.-]{2,35})['"»]?\s*:/gi;
|
||||
while((m=re.exec(text))&&names.length<10){if(!names.includes(m[1])) names.push(m[1]);}
|
||||
return {facts,names};
|
||||
}
|
||||
function summaryOf(text,title){
|
||||
const clean=String(text||'').trim();const t=String(title||'').trim();
|
||||
const first=clean.split(/(?<=[.!?])\s+|\n{2,}/)[0]?.trim()||clean;
|
||||
const headline=t && !/^(r[eé]ponse|hermes|texte)$/i.test(t)?t:first;
|
||||
const truncated=headline.length>170?headline.slice(0,167).replace(/\s+\S*$/,'')+'…':headline;
|
||||
let excerpt=clean;
|
||||
if(clean.startsWith(first)&&first.length>35)excerpt=clean.slice(first.length).replace(/^\s+/,'');
|
||||
if(!excerpt)excerpt=clean;
|
||||
if(excerpt.length>290)excerpt=excerpt.slice(0,287).replace(/\s+\S*$/,'')+'…';
|
||||
return {headline:truncated,excerpt};
|
||||
}
|
||||
if(typeof module==='object'&&module.exports){module.exports={getFacts,summaryOf,numberValue};}
|
||||
if(typeof document==='undefined')return;
|
||||
const body=document.body;
|
||||
if(!body||body.dataset.donaHoloV1==='active')return;
|
||||
body.dataset.donaHoloV1='active';body.classList.add('dona-holo-v1');
|
||||
const main=document.getElementById('alfredMainV1');
|
||||
const content=document.getElementById('content');
|
||||
if(!main||!content)return; // Pages secondaires : seulement le thème transversal.
|
||||
function el(tag,className,txt){const node=document.createElement(tag);if(className)node.className=className;if(txt!==undefined)node.textContent=String(txt);return node;}
|
||||
let busy=false;let original='';
|
||||
function renderCards(){
|
||||
if(busy || content.firstElementChild?.classList.contains('dh-response'))return;
|
||||
if(content.querySelector('.cards, img.hero, video, canvas, iframe'))return;
|
||||
// N'afficher les cartes que pour une réponse réellement ouverte par Alfred.
|
||||
if(!main.classList.contains('is-focused'))return;
|
||||
const children=[...content.children];
|
||||
if(!children.length)return;
|
||||
const text=children.filter(n=>n.matches('h1,h2,p,pre')).map(n=>n.textContent.trim()).filter(Boolean).join('\n\n').trim();
|
||||
if(!text || text.length<13)return;
|
||||
if(/^(bonjour\.|à votre écoute\.|en attente|chargement)/i.test(text))return;
|
||||
if(text===original && content.dataset.holoTransformed==='done')return;
|
||||
const title=content.querySelector('h1,h2')?.textContent||'';
|
||||
const raw=(title && text.startsWith(title+'\n\n'))?text.slice(title.length+2):text;
|
||||
const {facts,names}=getFacts(text);
|
||||
const summary=summaryOf(raw,title);
|
||||
const wrap=el('section','dh-response');
|
||||
wrap.setAttribute('aria-label','Synthèse contextuelle de la réponse Hermes');
|
||||
const primary=el('article','dh-primary');
|
||||
primary.append(el('div','dh-kicker','RÉPONSE · DONA'),el('div','dh-headline',summary.headline));
|
||||
if(summary.excerpt && summary.excerpt!==summary.headline)primary.append(el('div','dh-excerpt',summary.excerpt));
|
||||
else primary.append(el('div','dh-excerpt','Réponse disponible dans son intégralité.'));
|
||||
const more=el('button','dh-more');more.type='button';more.setAttribute('aria-expanded','false');more.append(el('span','', '▤ Afficher la réponse complète'),el('span','','›'));
|
||||
primary.append(more);wrap.append(primary);
|
||||
if(facts.length||names.length){
|
||||
const grid=el('div','dh-facts');
|
||||
facts.forEach(f=>{
|
||||
const c=el('article','dh-fact');
|
||||
c.append(el('span','dh-fact-icon',f.icon),el('div','dh-fact-label',f.label),el('strong','dh-fact-value',f.value),el('div','dh-fact-note',f.note));
|
||||
grid.append(c);
|
||||
});
|
||||
if(names.length && grid.children.length<4){
|
||||
const c=el('article','dh-fact');c.append(el('span','dh-fact-icon','⬡'),el('div','dh-fact-label','Nœuds cités'));
|
||||
const tags=el('div','dh-tags');names.forEach(n=>tags.append(el('span','dh-tag',n)));c.append(tags,el('div','dh-fact-note','Noms présents dans la réponse'));grid.append(c);
|
||||
}
|
||||
wrap.append(grid);
|
||||
} else {
|
||||
wrap.classList.add('dh-no-facts');
|
||||
}
|
||||
const full=el('section','dh-full');full.hidden=true;full.id='dhFullReply';
|
||||
full.append(el('h3','', 'Réponse complète d’Hermes'),el('div','dh-full-text',text),el('p','dh-source','Affichage fidèle au texte reçu. Les faits n’ont pas été vérifiés séparément.'));
|
||||
more.setAttribute('aria-controls','dhFullReply');
|
||||
more.addEventListener('click',()=>{
|
||||
const opened=full.hidden;full.hidden=!opened;more.setAttribute('aria-expanded',String(opened));
|
||||
more.firstElementChild.textContent=opened?'▤ Masquer la réponse complète':'▤ Afficher la réponse complète';
|
||||
more.lastElementChild.textContent=opened?'⌃':'›';
|
||||
});
|
||||
wrap.append(full);
|
||||
busy=true;content.replaceChildren(wrap);content.dataset.holoTransformed='done';original=text;busy=false;
|
||||
}
|
||||
const obs=new MutationObserver(()=>{if(!busy)renderCards();});
|
||||
obs.observe(content,{childList:true});
|
||||
// Accès à l'écran de réponse après mise à jour de la classe focus, sans boucle.
|
||||
main.addEventListener('click',()=>setTimeout(renderCards,0));
|
||||
// Le renderer/socket réactualise #content : la mutation constitue l'événement canonique.
|
||||
setTimeout(renderCards,100);
|
||||
})();
|
||||
@@ -0,0 +1,224 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
|
||||
<!-- ALFRED_MAIN_VISUAL_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
|
||||
<!-- ALFRED_LISTES_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
|
||||
<!-- ALFRED_GLASS_V2 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
|
||||
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
|
||||
<!-- ALFRED_REPONSE_CLOSE_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
|
||||
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
|
||||
<!-- DONA_FAMILY_UI_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
|
||||
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
|
||||
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
|
||||
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
|
||||
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
|
||||
<div id="alfredTimersList"></div>
|
||||
</aside>
|
||||
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
|
||||
|
||||
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
|
||||
aria-modal="true" hidden>
|
||||
<article class="alfred-context-card">
|
||||
<button id="alfredContextClose" class="alfred-context-close" type="button"
|
||||
aria-label="Fermer l'illustration" title="Fermer">×</button>
|
||||
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
|
||||
<div class="alfred-context-details">
|
||||
<h2 id="alfredContextTitle"></h2>
|
||||
<p id="alfredContextText"></p>
|
||||
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<script src="/static/js/alfred-timers-context.js"></script>
|
||||
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
|
||||
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
|
||||
<script src="/static/js/alfred-main-v1.js?v=1"></script>
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<script src="/static/js/alfred-maison-card.js?v=1"></script>
|
||||
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
|
||||
<script src="/static/js/alfred-main-v2.js?v=2"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
|
||||
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
|
||||
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
|
||||
<script defer src="/static/js/alfred-reply-auto-close.js?v=1"></script>
|
||||
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
|
||||
<script defer src="/static/js/dona-spotify.js?v=6"></script>
|
||||
<!-- dona-ui-fix start -->
|
||||
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
|
||||
<!-- dona-ui-fix end -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"version": "8.3.1-navigation-default-origin-ha-notify",
|
||||
"app": "/opt/alfred/app",
|
||||
"files": [
|
||||
"services/dona_navigation.py",
|
||||
"mcp/dona_navigation_mcp.py"
|
||||
],
|
||||
"before": {
|
||||
"services/dona_navigation.py": "4140d7a6db31ddc6c4b37f351ea0d1683fff12bdc7c94a92ab5f726e7cc2b310",
|
||||
"mcp/dona_navigation_mcp.py": "047ba360347ef7200686819f72e561364d8493f080959b9145646badb8fda4c0"
|
||||
},
|
||||
"after": {
|
||||
"services/dona_navigation.py": "dd2884c7ee1c48a212cacb32a4fc8ee5e668d26edfe8bd5a2d2ffc48e9724b38",
|
||||
"mcp/dona_navigation_mcp.py": "e7d87c1d5961702917ab297aae8ea2f13a27648d361269432b54136f186aabaf"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Optional Hermes stdio MCP for Dona navigation. Run from a dedicated venv.
|
||||
|
||||
Dependencies: 'mcp<2' requests flask python-dotenv. Does not start Flask or alter its state.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, '/opt/alfred/app')
|
||||
# Load existing Dona configuration (including optional Home Assistant token) from the host.
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv('/opt/alfred/.env', override=False)
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
except ImportError as exc:
|
||||
raise SystemExit('Installe mcp<2 dans ce venv (voir README du patch).') from exc
|
||||
from services.dona_navigation import build, external_links, send_to_phone
|
||||
mcp = FastMCP('dona-navigation')
|
||||
|
||||
@mcp.tool()
|
||||
def rechercher_destination(destination: str) -> dict:
|
||||
"""Résoudre un lieu pour afficher sa carte dans Dona, sans départ implicite."""
|
||||
return build(destination)
|
||||
|
||||
@mcp.tool()
|
||||
def calculer_itineraire(destination: str, depart: str) -> dict:
|
||||
"""Calculer une route automobile et une durée indicative hors trafic depuis un départ explicite."""
|
||||
return build(destination,depart)
|
||||
|
||||
@mcp.tool()
|
||||
def liens_waze_maps(destination: str) -> dict:
|
||||
"""Créer des liens officiels vers Waze et Google Maps pour une destination."""
|
||||
return external_links(destination)
|
||||
|
||||
@mcp.tool()
|
||||
def envoyer_trajet_waze_telephone(destination: str) -> str:
|
||||
"""UNIQUEMENT sur ordre explicite, notifier le téléphone Home Assistant configuré."""
|
||||
send_to_phone(destination)
|
||||
return 'Notification Waze envoyée vers le mobile Home Assistant configuré.'
|
||||
|
||||
if __name__ == '__main__':
|
||||
mcp.run(transport='stdio')
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Dona V8.3: keyless, user-triggered navigation + optional HA phone hand-off.
|
||||
|
||||
Geocoding with Nominatim (low volume; cache + <=1 request/s); OSRM demo for
|
||||
personal experimentation, switch to a private OSRM for reliable routes.
|
||||
No geolocation is inferred. No credentials are stored in this module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
import requests
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
MAX_DEST=180
|
||||
_AGENT='DonaNavigation/8.3 (private household assistant; contact: '+os.getenv('DONA_NAV_CONTACT','local-admin')+')'
|
||||
_NOMINATIM='https://nominatim.openstreetmap.org'
|
||||
_OSRM_DEMO='https://router.project-osrm.org'
|
||||
_LOCK=threading.RLock()
|
||||
_NEXT_REQUEST={'geo':0.0,'route':0.0}
|
||||
_CACHE={}
|
||||
_CACHE_READY=False
|
||||
_CACHE_LIMIT=400
|
||||
_CACHE_TTL=30*86400
|
||||
|
||||
class NavigationError(RuntimeError):
|
||||
pass
|
||||
|
||||
def _config_url(name, default):
|
||||
val=os.getenv(name,default).strip().rstrip('/')
|
||||
u=urlsplit(val)
|
||||
if not (u.scheme in ('http','https') and u.hostname and not u.username and not u.password and not u.fragment and not u.query):
|
||||
raise NavigationError(f'Adresse {name} invalide.')
|
||||
return val
|
||||
|
||||
def _cache_file():
|
||||
return Path(os.getenv('DONA_NAV_CACHE_FILE','/opt/alfred/app/cache/dona_navigation.json'))
|
||||
|
||||
def _get_cache():
|
||||
global _CACHE_READY
|
||||
if not _CACHE_READY:
|
||||
try:
|
||||
raw=json.loads(_cache_file().read_text(encoding='utf-8'))
|
||||
if isinstance(raw,dict):_CACHE.update({str(k):v for k,v in raw.items() if isinstance(v,dict)})
|
||||
except (OSError,ValueError): pass
|
||||
_CACHE_READY=True
|
||||
return _CACHE
|
||||
|
||||
def _save_cache():
|
||||
try:
|
||||
path=_cache_file();path.parent.mkdir(parents=True,exist_ok=True)
|
||||
entries=sorted(_CACHE.items(),key=lambda x:x[1].get('at',0),reverse=True)[:_CACHE_LIMIT]
|
||||
tmp=path.with_name(path.name+'.tmp')
|
||||
tmp.write_text(json.dumps(dict(entries),ensure_ascii=False,separators=(',',':')),encoding='utf-8')
|
||||
tmp.replace(path)
|
||||
except OSError:
|
||||
pass # caching is optional; never block navigation
|
||||
|
||||
def _cached(key):
|
||||
with _LOCK:
|
||||
rec=_get_cache().get(key)
|
||||
if rec and time.time()-float(rec.get('at',0))<_CACHE_TTL:
|
||||
return rec.get('data')
|
||||
return None
|
||||
|
||||
def _remember(key,data):
|
||||
with _LOCK:
|
||||
_get_cache()[key]={'at':time.time(),'data':data}
|
||||
_save_cache()
|
||||
|
||||
def _request(kind, base, suffix, params):
|
||||
# All endpoint hosts are configured by the server administrator, never by callers.
|
||||
with _LOCK:
|
||||
wait=max(0.0,_NEXT_REQUEST[kind]-time.monotonic())
|
||||
if wait:time.sleep(wait)
|
||||
_NEXT_REQUEST[kind]=time.monotonic()+1.1
|
||||
try:
|
||||
resp=requests.get(base+suffix,params=params,timeout=(4,11),headers={
|
||||
'User-Agent':_AGENT, 'Accept':'application/json', 'Accept-Language':'fr'})
|
||||
resp.raise_for_status()
|
||||
if len(resp.content)>1_500_000:raise NavigationError('Réponse cartographique trop volumineuse.')
|
||||
return resp.json()
|
||||
except (requests.RequestException,ValueError) as exc:
|
||||
raise NavigationError('Service cartographique momentanément indisponible.') from exc
|
||||
|
||||
def _place(value):
|
||||
if not isinstance(value,str) or not 2<=len(value.strip())<=MAX_DEST:
|
||||
raise NavigationError('Indique une destination de 2 à 180 caractères.')
|
||||
text=' '.join(value.strip().split())
|
||||
if re.search(r'[\x00-\x1f]',text):raise NavigationError('Destination invalide.')
|
||||
return text
|
||||
|
||||
def _coord(lat,lon):
|
||||
try: a,b=float(lat),float(lon)
|
||||
except (ValueError,TypeError):raise NavigationError('Coordonnées invalides.') from None
|
||||
if not math.isfinite(a) or not math.isfinite(b) or not (-90<=a<=90 and -180<=b<=180):
|
||||
raise NavigationError('Coordonnées hors limites.')
|
||||
return round(a,6),round(b,6)
|
||||
|
||||
def search_location(query):
|
||||
query=_place(query)
|
||||
key='geo:'+query.casefold()
|
||||
hit=_cached(key)
|
||||
if hit is not None:return hit
|
||||
base=_config_url('DONA_NAV_GEOCODER_URL',_NOMINATIM)
|
||||
raw=_request('geo',base,'/search',{'q':query,'format':'jsonv2','limit':3,'accept-language':'fr'})
|
||||
results=[]
|
||||
for entry in raw if isinstance(raw,list) else []:
|
||||
if not isinstance(entry,dict):continue
|
||||
try:lat,lon=_coord(entry.get('lat'),entry.get('lon'))
|
||||
except NavigationError:continue
|
||||
results.append({'name':str(entry.get('display_name') or query)[:220], 'lat':lat,'lon':lon})
|
||||
_remember(key,results)
|
||||
return results
|
||||
|
||||
def _origin_text(origin=None):
|
||||
# No guessed location, ever. Caller or administrator must provide one.
|
||||
val=(origin or os.getenv('DONA_NAV_ORIGIN','')).strip()
|
||||
return _place(val) if val else None
|
||||
|
||||
def external_links(dest,dest_point=None):
|
||||
query=_place(dest)
|
||||
if dest_point:
|
||||
lat,lon=_coord(dest_point['lat'],dest_point['lon'])
|
||||
waze='https://www.waze.com/ul?'+urlencode({'ll':f'{lat},{lon}','navigate':'yes','utm_source':'DonaNavigation'})
|
||||
maps='https://www.google.com/maps/dir/?'+urlencode({'api':'1','destination':f'{lat},{lon}','travelmode':'driving'})
|
||||
else:
|
||||
waze='https://www.waze.com/ul?'+urlencode({'q':query,'navigate':'yes','utm_source':'DonaNavigation'})
|
||||
maps='https://www.google.com/maps/dir/?'+urlencode({'api':'1','destination':query,'travelmode':'driving'})
|
||||
return {'waze':waze,'google_maps':maps}
|
||||
|
||||
def _route_between(start,end):
|
||||
key=f"route:{start['lat']},{start['lon']}:{end['lat']},{end['lon']}"
|
||||
hit=_cached(key)
|
||||
if hit is not None:return hit
|
||||
base=_config_url('DONA_NAV_ROUTER_URL',_OSRM_DEMO)
|
||||
coords=f"{start['lon']},{start['lat']};{end['lon']},{end['lat']}"
|
||||
raw=_request('route',base,'/route/v1/driving/'+coords,{'overview':'simplified','geometries':'geojson','steps':'false'})
|
||||
routes=raw.get('routes',[]) if isinstance(raw,dict) and raw.get('code')=='Ok' else []
|
||||
if not routes:raise NavigationError('Aucun itinéraire routier calculable.')
|
||||
r=routes[0]
|
||||
coords=(r.get('geometry') or {}).get('coordinates') or []
|
||||
line=[]
|
||||
for p in coords[:700]:
|
||||
if not isinstance(p,list) or len(p)<2:continue
|
||||
lat,lon=_coord(p[1],p[0]);line.append([lat,lon])
|
||||
# Need a meaningful route before returning a claimed ETA.
|
||||
if len(line)<2:raise NavigationError('Géométrie d’itinéraire indisponible.')
|
||||
meters=float(r['distance']);seconds=float(r['duration'])
|
||||
if any(not math.isfinite(x) or x<0 for x in (meters,seconds)):
|
||||
raise NavigationError('Durée de trajet invalide.')
|
||||
out={'distance_km':round(meters/1000,1),'duration_min':round(seconds/60),'line':line,
|
||||
'traffic_included':False,'provider':'OSRM'}
|
||||
_remember(key,out)
|
||||
return out
|
||||
|
||||
def build(destination,origin=None):
|
||||
dest=_place(destination)
|
||||
result={'destination':dest,'origin':None,'place':None,'route':None,'note':'', 'links':external_links(dest),
|
||||
'attribution':'© OpenStreetMap contributors'}
|
||||
try:
|
||||
hits=search_location(dest)
|
||||
if hits:
|
||||
result['place']=hits[0];result['links']=external_links(dest,hits[0])
|
||||
else:result['note']='Adresse non trouvée ; ouvre Waze pour vérifier la destination.'
|
||||
except NavigationError as exc:result['note']=str(exc)+' Le lien Waze reste disponible.'
|
||||
try: start=_origin_text(origin)
|
||||
except NavigationError as exc:result['note']=str(exc);start=None
|
||||
if start and result['place']:
|
||||
result['origin']=start
|
||||
try:
|
||||
matches=search_location(start)
|
||||
if matches:result['route']=_route_between(matches[0],result['place']);result['origin_place']=matches[0]
|
||||
else:result['note']='Point de départ introuvable : trajet non calculé.'
|
||||
except NavigationError as exc:result['note']=str(exc)+' Tu peux ouvrir Waze pour calculer l’itinéraire.'
|
||||
elif not start and not result['note']:
|
||||
result['note']='Renseigne un point de départ pour calculer la durée. Sans départ, seule la destination est affichée.'
|
||||
return result
|
||||
|
||||
# Explicit speech routing. Does not intercept ordinary conversation or media controls.
|
||||
_NAV_RE=re.compile(r'\b(?:waze|gps|google maps|itin[eé]raire|trajet|navigation|destination|route vers|temps pour (?:aller|me rendre)|aller [aà] )\b',re.I)
|
||||
_SEND_RE=re.compile(r'\b(?:envoie|envoies|transf[eè]re|partage|sur mon t[eé]l[eé]phone|à mon t[eé]l[eé]phone)\b',re.I)
|
||||
|
||||
def is_request(text):
|
||||
return isinstance(text,str) and 3<=len(text)<500 and bool(_NAV_RE.search(text))
|
||||
|
||||
def is_send(text):return bool(_SEND_RE.search(text or ''))
|
||||
|
||||
def extract_destination(text):
|
||||
s=str(text or '').strip()
|
||||
s=re.sub(r'^(?:dona|alfred)[,\s]+','',s,flags=re.I)
|
||||
patterns=[r'^.*?\b(?:itin[eé]raire|trajet|destination|route)\s+(?:pour|vers|jusqu[\'’]?[aà]|[aà])\s+',
|
||||
r'^.*?\b(?:aller|rendre)\s+(?:[aà]|au|aux|en)\s+',
|
||||
r'^.*?\b(?:waze|google maps|gps)\s+(?:pour|vers|jusqu[\'’]?[aà]|[aà])\s+',
|
||||
r'^.*?\b(?:jusqu[\'’]?[aà])\s+']
|
||||
for pat in patterns:
|
||||
found=re.search(pat,s,re.I)
|
||||
if found:s=s[found.end():];break
|
||||
s=re.sub(r'\s+(?:sur|dans)\s+(?:waze|google maps)\s*$','',s,flags=re.I)
|
||||
s=s.rstrip(' .?!;,')
|
||||
return s[:MAX_DEST] if 2<=len(s)<=MAX_DEST else None
|
||||
|
||||
def _notify_url(destination,url):
|
||||
service=os.getenv('DONA_NAV_HA_NOTIFY_SERVICE','').strip()
|
||||
match=re.fullmatch(r'notify\.([a-z0-9_]+)',service)
|
||||
if not match:raise NavigationError('Configure DONA_NAV_HA_NOTIFY_SERVICE (notify.mobile_app_...) avant un envoi au téléphone.')
|
||||
base=(os.getenv('HA_URL') or os.getenv('HOME_ASSISTANT_URL') or '').strip().rstrip('/')
|
||||
token=(os.getenv('HA_TOKEN') or os.getenv('HOME_ASSISTANT_TOKEN') or '').strip()
|
||||
if not base or not token:raise NavigationError('Home Assistant : HA_URL ou HA_TOKEN manquant.')
|
||||
u=urlsplit(base)
|
||||
if u.scheme not in ('http','https') or not u.hostname or u.username or u.password:
|
||||
raise NavigationError('Adresse Home Assistant invalide.')
|
||||
payload={'title':'Itinéraire Dona','message':'Ouvrir Waze pour '+destination,
|
||||
'data':{'url':url,'clickAction':url}}
|
||||
try:
|
||||
res=requests.post(base+'/api/services/notify/'+match.group(1),json=payload,
|
||||
headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'},timeout=10)
|
||||
res.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise NavigationError('Notification Home Assistant refusée ou inaccessible.') from exc
|
||||
return True
|
||||
|
||||
def send_to_phone(destination):
|
||||
dest=_place(destination)
|
||||
# Only links generated on server; never a caller-provided URL.
|
||||
return _notify_url(dest,external_links(dest)['waze'])
|
||||
|
||||
def _allowed_request():
|
||||
if not request.is_json or request.headers.get('X-Alfred-Request')!='1':return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin:
|
||||
try:
|
||||
u=urlsplit(origin)
|
||||
return u.scheme in ('http','https') and u.netloc==request.host
|
||||
except ValueError:return False
|
||||
return request.headers.get('Sec-Fetch-Site','same-origin') in ('same-origin','none')
|
||||
|
||||
def register_navigation(app,socketio):
|
||||
bp=Blueprint('dona_navigation_v83',__name__)
|
||||
@bp.post('/api/dona/navigation/search')
|
||||
def query():
|
||||
if not _allowed_request():return jsonify(ok=False,error='Requête refusée.'),403
|
||||
data=request.get_json(silent=True) or {}
|
||||
try:return jsonify(ok=True,**build(data.get('destination'),data.get('origin')))
|
||||
except NavigationError as exc:return jsonify(ok=False,error=str(exc)),400
|
||||
@bp.post('/api/dona/navigation/send')
|
||||
def notify():
|
||||
if not _allowed_request():return jsonify(ok=False,error='Requête refusée.'),403
|
||||
try:
|
||||
dest=_place((request.get_json(silent=True) or {}).get('destination'))
|
||||
send_to_phone(dest)
|
||||
return jsonify(ok=True,message='Notification Waze envoyée au téléphone configuré.')
|
||||
except NavigationError as exc:return jsonify(ok=False,error=str(exc)),400
|
||||
@bp.get('/api/dona/navigation/config')
|
||||
def settings():
|
||||
return jsonify(ok=True,origin=os.getenv('DONA_NAV_ORIGIN',''),
|
||||
phone_configured=bool(re.fullmatch(r'notify\.mobile_app_[a-z0-9_]+',os.getenv('DONA_NAV_HA_NOTIFY_SERVICE',''))))
|
||||
app.register_blueprint(bp)
|
||||
|
||||
def handle_and_emit(text,socketio,state,state_lock):
|
||||
dest=extract_destination(text)
|
||||
if not dest:
|
||||
msg='Indique une destination, par exemple : montre-moi le trajet vers Metz.'
|
||||
result={'type':'dona_navigation','error':msg}
|
||||
else:
|
||||
result={'type':'dona_navigation', **build(dest)}
|
||||
msg='Voici la destination '+dest+'.'
|
||||
if result.get('route'):
|
||||
msg+=f" Distance : {result['route']['distance_km']} kilomètres ; durée indicative : {result['route']['duration_min']} minutes, hors trafic."
|
||||
if is_send(text):
|
||||
try:send_to_phone(dest);result['sent']=True;msg+=' Le lien Waze a été envoyé au téléphone configuré.'
|
||||
except NavigationError as exc:result['send_error']=str(exc);msg+=' Je n’ai pas pu envoyer la notification : '+str(exc)
|
||||
with state_lock:
|
||||
state['content']=result
|
||||
socketio.emit('alfred_render',result)
|
||||
return msg
|
||||
@@ -0,0 +1,742 @@
|
||||
|
||||
import os, threading, hmac
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Flask, render_template, request, jsonify, Response
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger la configuration AVANT les imports des services
|
||||
load_dotenv("/opt/alfred/.env")
|
||||
load_dotenv(
|
||||
"/opt/alfred/app/config/control_center.env",
|
||||
override=True
|
||||
)
|
||||
|
||||
from services.audio import listen_once, speak_async, tts_backend_detect
|
||||
from services.wakeword import WakeWordListener
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
from services.piper_tts import stop_playback as duplex_stop_piper
|
||||
from services import admin_console # ALFRED_ADMIN_PAGES_V1
|
||||
from services.hermes_client import ask_hermes
|
||||
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
|
||||
from services import alfred_features # ALFRED_FEATURES_V45
|
||||
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
||||
from services.camera import video_stream
|
||||
from services import ha_views, alfred_views_controller
|
||||
from services import camera_settings, ha_weather, weather_outfits
|
||||
|
||||
load_dotenv()
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
|
||||
##
|
||||
from services.audio import get_vosk_model
|
||||
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
|
||||
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
|
||||
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
|
||||
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||
##
|
||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
||||
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||
STATE_LOCK = threading.RLock()
|
||||
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
|
||||
conversation_lock=threading.Lock()
|
||||
mic_stop_event=threading.Event()
|
||||
# Service temps autonome (survit aux conversations Hermes)
|
||||
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
|
||||
audio_settings.initialize()
|
||||
audio_settings.update_settings({"mic_gain": 30})
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
|
||||
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
|
||||
on_interrupt=duplex_stop_piper,
|
||||
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
|
||||
|
||||
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
|
||||
camera_settings.restore_saved_settings()
|
||||
|
||||
def emit_state(mode=None, **extra):
|
||||
with STATE_LOCK:
|
||||
if mode: STATE["mode"] = mode
|
||||
STATE.update(extra)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return
|
||||
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
|
||||
try:
|
||||
emit_state("listening", partial="", transcript="", wake_score=score)
|
||||
|
||||
print("[ALFRED] Début écoute STT", flush=True)
|
||||
|
||||
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
from services.audio import STTResult
|
||||
r = STTResult(text=injected_text)
|
||||
else:
|
||||
r = listen_once(
|
||||
stop_event=mic_stop_event,
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
|
||||
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
|
||||
|
||||
if audio_settings.mic_muted():
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
if r.error:
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": r.error}
|
||||
)
|
||||
return
|
||||
|
||||
if not r.text:
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
emit_state(
|
||||
"thinking",
|
||||
partial="",
|
||||
transcript=r.text,
|
||||
content={"type": "text", "text": r.text}
|
||||
)
|
||||
|
||||
# DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix.
|
||||
if _dona_youtube.is_search(r.text):
|
||||
_speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK)
|
||||
if _speech and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_evt=threading.Event()
|
||||
_ok=speak_async(_speech,on_end=_evt.set)
|
||||
if _ok: _evt.wait(timeout=120)
|
||||
emit_state('idle',partial='')
|
||||
return
|
||||
if _dona_youtube.is_control(r.text,_dona_youtube.player_active()):
|
||||
_dona_youtube.control_and_emit(r.text,socketio)
|
||||
emit_state('idle',partial='')
|
||||
return
|
||||
|
||||
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
|
||||
if _dona_dynamic.is_dynamic_request(r.text):
|
||||
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
|
||||
_payload = {'type':'dona_dynamic', 'dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _payload
|
||||
socketio.emit('alfred_render', _payload)
|
||||
if not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_finished = threading.Event()
|
||||
_started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set)
|
||||
if _started: _finished.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
|
||||
# Vue dynamique : commandes déterministes exécutées sans faire
|
||||
# dépendre l'affichage des capacités ou sessions Hermes.
|
||||
visual = None
|
||||
try:
|
||||
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
|
||||
visual = ({"type":"text", "text":str(exc)}, str(exc))
|
||||
|
||||
if visual is not None:
|
||||
content, speech = visual
|
||||
with STATE_LOCK:
|
||||
STATE["content"] = content
|
||||
socketio.emit("alfred_render", content)
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
done.wait(timeout=120)
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_START
|
||||
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
|
||||
try:
|
||||
_lists_reply = _alfred_handle_list_voice(
|
||||
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
|
||||
except Exception as _lists_error:
|
||||
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
|
||||
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
|
||||
if _lists_reply is not None:
|
||||
_lists_display = {'type':'text', 'text':_lists_reply}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _lists_display
|
||||
socketio.emit('alfred_render', _lists_display)
|
||||
if _lists_reply and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_lists_done = threading.Event()
|
||||
speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
_lists_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_END
|
||||
|
||||
print("[ALFRED] Envoi à Hermes", flush=True)
|
||||
# ComfyUI publie l'image pendant cet appel Hermes.
|
||||
with STATE_LOCK:
|
||||
image_revision_before = IMAGE_REVISION
|
||||
# ALFRED_FEATURES_V45 voice opt-in background mode
|
||||
if alfred_features.should_run_in_background(r.text):
|
||||
try:
|
||||
alfred_features.submit(r.text, source='voice')
|
||||
emit_state('idle', partial='', content={
|
||||
'type': 'text',
|
||||
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
|
||||
})
|
||||
except ValueError as exc:
|
||||
emit_state('error', content={'type': 'text', 'text': str(exc)})
|
||||
return
|
||||
# DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal.
|
||||
answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(r.text)
|
||||
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
|
||||
if isinstance(answer, dict):
|
||||
speech = str(answer.get("speech") or answer.get("text") or "")
|
||||
display = answer.get("display") or {
|
||||
"type": "text",
|
||||
"text": speech
|
||||
}
|
||||
else:
|
||||
speech = str(answer or "")
|
||||
display = {"type": "text", "text": speech}
|
||||
|
||||
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
|
||||
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
|
||||
alfred_context_images.schedule_context_image(
|
||||
r.text, speech, socketio,
|
||||
lambda: STATE.get("transcript") == r.text,
|
||||
) # ALFRED_TIMERS_CONTEXT_V1
|
||||
|
||||
# Si le MCP ComfyUI a affiché une image pendant la requête,
|
||||
# ne pas l'écraser par la réponse textuelle d'Hermes.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == image_revision_before:
|
||||
STATE["content"] = display
|
||||
alfred_features.publish_voice_display(display)
|
||||
socketio.emit("alfred_render", display)
|
||||
else:
|
||||
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
|
||||
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
try:
|
||||
started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
if started:
|
||||
done.wait(timeout=120)
|
||||
finally:
|
||||
if not duplex_monitor.interrupted():
|
||||
duplex_monitor.stop()
|
||||
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
|
||||
if duplex_monitor.interrupted():
|
||||
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
|
||||
duplex_monitor.stop()
|
||||
else:
|
||||
# A few wake-free seconds after the assistant finishes speaking.
|
||||
pending_duplex_text = duplex_monitor.listen_conversation()
|
||||
if pending_duplex_text:
|
||||
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
|
||||
|
||||
emit_state("idle", partial="")
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": str(exc)}
|
||||
)
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
# Only the voice is cancelled; the original Hermes job is untouched.
|
||||
wake.pause()
|
||||
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
|
||||
daemon=True, name='dona-duplex-followup').start()
|
||||
elif not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
print("[ALFRED] Wake word réactivé", flush=True)
|
||||
|
||||
|
||||
|
||||
def on_wake(score):
|
||||
if audio_settings.mic_muted():
|
||||
return
|
||||
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
|
||||
threading.Thread(target=conversation,args=(score,),daemon=True).start()
|
||||
|
||||
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
|
||||
|
||||
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
|
||||
@app.get('/api/duplex/status')
|
||||
def duplex_status():
|
||||
return jsonify(duplex_monitor.status())
|
||||
|
||||
@app.get('/api/audio/settings')
|
||||
def get_audio_settings():
|
||||
return jsonify(audio_settings.get_settings())
|
||||
|
||||
@app.post('/api/audio/settings')
|
||||
def set_audio_settings():
|
||||
data = request.get_json(silent=True)
|
||||
try:
|
||||
old = audio_settings.get_settings()
|
||||
new = audio_settings.update_settings(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'ALSA : {exc}'}), 500
|
||||
if new['mic_muted'] != old['mic_muted']:
|
||||
if new['mic_muted']:
|
||||
mic_stop_event.set()
|
||||
wake.pause()
|
||||
else:
|
||||
mic_stop_event.clear()
|
||||
if not conversation_lock.locked():
|
||||
wake.resume()
|
||||
socketio.emit('audio_settings', new)
|
||||
return jsonify(new)
|
||||
|
||||
@app.get('/api/camera/settings')
|
||||
def get_camera_settings():
|
||||
try:
|
||||
return jsonify(camera_settings.get_settings())
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/settings')
|
||||
def set_camera_settings():
|
||||
# Même garde-fou que les commandes calendrier existantes.
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'JSON invalide.'}), 400
|
||||
try:
|
||||
result = camera_settings.update_setting(data.get('control'), data.get('value'))
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/reset')
|
||||
def reset_camera_settings():
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
try:
|
||||
result = camera_settings.reset_defaults()
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.get('/api/camera/stream')
|
||||
def camera_stream():
|
||||
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
|
||||
try:
|
||||
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
|
||||
except (ImportError, ValueError, RuntimeError, OSError) as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
return Response(
|
||||
frames,
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame',
|
||||
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
|
||||
)
|
||||
|
||||
# ALFRED_FEATURES_V45 init
|
||||
alfred_features.init_app(app, ask_hermes, socketio=socketio)
|
||||
|
||||
@app.get("/")
|
||||
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
|
||||
@app.get("/api/state")
|
||||
def state():
|
||||
with STATE_LOCK:
|
||||
snapshot = dict(STATE)
|
||||
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
|
||||
|
||||
@app.post("/api/state")
|
||||
def set_state():
|
||||
data = request.get_json(force=True)
|
||||
with STATE_LOCK:
|
||||
STATE.update(data)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
return jsonify({"ok": True})
|
||||
@app.post("/api/render")
|
||||
def render_payload():
|
||||
global IMAGE_REVISION
|
||||
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||
if token and not hmac.compare_digest(
|
||||
request.headers.get("X-Alfred-Render-Token", ""), token
|
||||
):
|
||||
return jsonify({"ok": False, "error": "Non autorisé"}), 401
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
|
||||
if data.get("type") == "image":
|
||||
image_url = data.get("url")
|
||||
if not isinstance(image_url, str) or not (
|
||||
image_url.startswith(("http://", "https://", "/"))
|
||||
and not image_url.startswith("//")
|
||||
):
|
||||
return jsonify({"ok": False, "error": "URL image invalide"}), 400
|
||||
|
||||
with STATE_LOCK:
|
||||
if data.get("type") == "image":
|
||||
IMAGE_REVISION += 1
|
||||
STATE["content"] = data
|
||||
socketio.emit("alfred_render", data)
|
||||
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
|
||||
# --- Agenda / capteurs / caméras Home Assistant ---
|
||||
def _calendar_range():
|
||||
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
|
||||
now = datetime.now(tz)
|
||||
def parse(name, default):
|
||||
raw = request.args.get(name)
|
||||
if not raw:return default
|
||||
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
|
||||
if value.tzinfo is None: value = value.replace(tzinfo=tz)
|
||||
return value.astimezone(tz)
|
||||
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
|
||||
end = parse('end',start+timedelta(days=7))
|
||||
return start,end
|
||||
|
||||
def _json_error(exc):
|
||||
code = 400 if isinstance(exc,ValueError) else 502
|
||||
return jsonify({'error':str(exc)}),code
|
||||
|
||||
def _writes_enabled():
|
||||
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
|
||||
|
||||
def _same_origin_json():
|
||||
# POSTs require a custom header and same-origin browser requests.
|
||||
if request.headers.get('X-Alfred-Request') != '1':return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
|
||||
return True
|
||||
|
||||
@app.get('/api/calendar/calendars')
|
||||
def calendars_list():
|
||||
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/calendar/events')
|
||||
def calendar_events():
|
||||
try:
|
||||
start,end=_calendar_range()
|
||||
selected=request.args.getlist('calendar_id')
|
||||
return jsonify(ha_views.get_events(start,end,selected or None))
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/create')
|
||||
def calendar_create():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
def read_dt(key):
|
||||
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
|
||||
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
||||
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
|
||||
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
|
||||
str(data.get('location') or ''),bool(data.get('all_day',False)))
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_create()
|
||||
socketio.emit('calendar_changed', {'type':'created'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/delete')
|
||||
def calendar_delete():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
|
||||
start=datetime.fromisoformat(str(data['start']))
|
||||
end=datetime.fromisoformat(str(data['end']))
|
||||
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
|
||||
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
|
||||
rid=str(data.get('recurrence_id') or '')
|
||||
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
|
||||
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
|
||||
result=ha_views.delete_event(eid,uid,rid or None)
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_delete()
|
||||
socketio.emit('calendar_changed',{'type':'deleted'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather')
|
||||
def ha_weather_data():
|
||||
# Read-only route, token always stays on the Alfred server.
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
return jsonify(ha_weather.get_weather(q))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather/outfits')
|
||||
def ha_weather_outfits_data():
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
|
||||
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
|
||||
except (ha_views.HAError, ValueError, RuntimeError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperatures')
|
||||
def ha_temperatures():
|
||||
try:return jsonify({'items':ha_views.get_temperatures()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperature/history')
|
||||
def ha_temperature_history():
|
||||
try:
|
||||
eid=request.args.get('entity_id','')
|
||||
hours=int(request.args.get('hours','24'))
|
||||
return jsonify({'items':ha_views.temperature_history(eid,hours)})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/cameras')
|
||||
def ha_cameras():
|
||||
try:return jsonify({'items':ha_views.list_cameras()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
|
||||
def ha_camera_snapshot(entity_id):
|
||||
try:
|
||||
binary,mime=ha_views.camera_snapshot(entity_id)
|
||||
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/ui/home')
|
||||
def ui_home():
|
||||
with STATE_LOCK:
|
||||
STATE['content']={'type':'home','text':'À ton écoute.'}
|
||||
socketio.emit('alfred_render',STATE['content'])
|
||||
return jsonify({'ok':True})
|
||||
|
||||
@app.post("/api/audio-level")
|
||||
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
|
||||
@app.get("/api/wake/status")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
|
||||
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
|
||||
def _alfred_dashboard_quick_command(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
|
||||
def _run():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_image_revision_before = IMAGE_REVISION
|
||||
emit_state('thinking', transcript=prompt, partial='',
|
||||
content={'type':'text', 'text':prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _image_revision_before:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
if _speech and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_done = threading.Event()
|
||||
speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
except Exception as _exc:
|
||||
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':str(_exc)})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
|
||||
return True
|
||||
|
||||
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
|
||||
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_START
|
||||
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
|
||||
_alfred_register_home_glass(app)
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_END
|
||||
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_START
|
||||
from services.alfred_lists import register_lists as _alfred_register_lists
|
||||
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
|
||||
_alfred_register_lists(app, socketio)
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_GLASS_V2_BACKEND_START
|
||||
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
|
||||
_alfred_register_logs_v2(app)
|
||||
# ALFRED_GLASS_V2_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
|
||||
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
|
||||
_alfred_identity_register(app)
|
||||
|
||||
|
||||
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
|
||||
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
|
||||
_alfred_developer_register(app)
|
||||
|
||||
|
||||
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
|
||||
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
|
||||
_register_hermes_new_session(app)
|
||||
|
||||
|
||||
# DONA_FAMILY_UI_V1_START
|
||||
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
|
||||
def _dona_submit_typed(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _dona_run_typed():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_before_image = IMAGE_REVISION
|
||||
emit_state('thinking', partial='', transcript=prompt,
|
||||
content={'type': 'text', 'text': prompt})
|
||||
# DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes.
|
||||
if _dona_youtube.is_search(prompt):
|
||||
_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK)
|
||||
emit_state('idle',partial='')
|
||||
return
|
||||
if _dona_youtube.is_control(prompt,_dona_youtube.player_active()):
|
||||
_dona_youtube.control_and_emit(prompt,socketio)
|
||||
emit_state('idle',partial='')
|
||||
return
|
||||
|
||||
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
|
||||
if _dona_dynamic.is_dynamic_request(prompt):
|
||||
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
|
||||
_payload = {'type':'dona_dynamic','dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _payload
|
||||
socketio.emit('alfred_render',_payload)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _before_image:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
# Deliberately no speak_async(): typed interaction is silent.
|
||||
emit_state('idle', partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
|
||||
return True
|
||||
|
||||
from services.dona_family_ui import register_dona_family as _dona_register_family
|
||||
_dona_register_family(app, adult_command=_dona_submit_typed)
|
||||
# DONA_FAMILY_UI_V1_END
|
||||
|
||||
# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés.
|
||||
from services import dona_dynamic as _dona_dynamic
|
||||
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
|
||||
from services import dona_dashboard_agent as _dona_dashboard_agent
|
||||
|
||||
def _dona_dynamic_submit(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _work():
|
||||
try:
|
||||
wake.pause()
|
||||
emit_state('thinking',transcript=prompt,partial='')
|
||||
_board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard)
|
||||
_payload={'type':'dona_dynamic','dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content']=_payload
|
||||
socketio.emit('alfred_render',_payload)
|
||||
emit_state('idle',partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True)
|
||||
emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start()
|
||||
return True
|
||||
|
||||
_dona_dynamic.register_dynamic(app,_dona_dynamic_submit)
|
||||
|
||||
# DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé.
|
||||
from services import dona_youtube as _dona_youtube
|
||||
_dona_youtube.register_youtube(app,socketio)
|
||||
|
||||
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
|
||||
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
|
||||
_register_dona_spotify(app)
|
||||
|
||||
# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée.
|
||||
from services.dona_face import (register_face as _dona_register_face,
|
||||
recognize_once as _dona_face_recognize, is_face_request as _dona_face_request)
|
||||
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": "8.3-navigation",
|
||||
"app": "/opt/alfred/app",
|
||||
"modified": [
|
||||
"app.py",
|
||||
"templates/index.html"
|
||||
],
|
||||
"created": [
|
||||
"services/dona_navigation.py",
|
||||
"static/js/dona-navigation-v8-3.js",
|
||||
"static/css/dona-navigation-v8-3.css",
|
||||
"mcp/dona_navigation_mcp.py"
|
||||
],
|
||||
"before": {
|
||||
"app.py": "33702677173cad2c0920dd5a276293b072d23bb39a339adfad2891c2aa36e874",
|
||||
"templates/index.html": "9b50d8e21c4464c307e6f2a09f52642830a8f67455aded9b2a594d78755e4a0c"
|
||||
},
|
||||
"after": {
|
||||
"services/dona_navigation.py": "4140d7a6db31ddc6c4b37f351ea0d1683fff12bdc7c94a92ab5f726e7cc2b310",
|
||||
"static/js/dona-navigation-v8-3.js": "0370bf2bce6faedb720766a02289679ce0fb5612e7dec74d4f2b3f7fb08329bf",
|
||||
"static/css/dona-navigation-v8-3.css": "ed4cdd2b5b1cc5b893c904ffb179fdcead4c4f487ca571e67e25a847d6d8c579",
|
||||
"mcp/dona_navigation_mcp.py": "047ba360347ef7200686819f72e561364d8493f080959b9145646badb8fda4c0",
|
||||
"app.py": "cb5eda8f1b56200041ed67817f3cea9fcfd5de22e5358b99c34291d9f7921fb6",
|
||||
"templates/index.html": "4b99c6e6a616f0d1e2f7ace62736a31f598ed49e48157f8a9b0ffdbd1de322cc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
|
||||
<!-- ALFRED_MAIN_VISUAL_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
|
||||
<!-- ALFRED_LISTES_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
|
||||
<!-- ALFRED_GLASS_V2 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
|
||||
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
|
||||
<!-- ALFRED_REPONSE_CLOSE_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
|
||||
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
|
||||
<!-- DONA_FAMILY_UI_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
|
||||
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
|
||||
<!-- DONA_V7_RELEASE -->
|
||||
<link rel="stylesheet" href="/static/css/dona-v7.css?v=7">
|
||||
<!-- DONA_V8_DYNAMIC -->
|
||||
<link rel="stylesheet" href="/static/css/dona-dynamic-v8.css?v=8">
|
||||
<!-- DONA_V8_1_ISOLATION -->
|
||||
<link rel="stylesheet" href="/static/css/dona-header-v8-1.css?v=81">
|
||||
<!-- DONA_YOUTUBE_V82 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-youtube-v8-2.css?v=82">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
|
||||
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
|
||||
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
|
||||
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
|
||||
<div id="alfredTimersList"></div>
|
||||
</aside>
|
||||
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
|
||||
|
||||
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
|
||||
aria-modal="true" hidden>
|
||||
<article class="alfred-context-card">
|
||||
<button id="alfredContextClose" class="alfred-context-close" type="button"
|
||||
aria-label="Fermer l'illustration" title="Fermer">×</button>
|
||||
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
|
||||
<div class="alfred-context-details">
|
||||
<h2 id="alfredContextTitle"></h2>
|
||||
<p id="alfredContextText"></p>
|
||||
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<script src="/static/js/alfred-timers-context.js"></script>
|
||||
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
|
||||
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
|
||||
<script src="/static/js/alfred-main-v1.js?v=1"></script>
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<script src="/static/js/alfred-maison-card.js?v=1"></script>
|
||||
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
|
||||
<script src="/static/js/alfred-main-v2.js?v=2"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
|
||||
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
|
||||
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
|
||||
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
|
||||
<script defer src="/static/js/dona-reply-auto-close-v7.js?v=7"></script>
|
||||
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
|
||||
<script defer src="/static/js/dona-spotify.js?v=6"></script>
|
||||
<!-- dona-ui-fix start -->
|
||||
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
|
||||
<!-- dona-ui-fix end -->
|
||||
<script defer src="/static/js/dona-face-v7.js?v=7"></script>
|
||||
<script defer src="/static/js/dona-dynamic-v8.js?v=8"></script>
|
||||
<script defer src="/static/js/dona-header-v8-1.js?v=81"></script>
|
||||
<script defer src="/static/js/dona-youtube-v8-2.js?v=82"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,646 @@
|
||||
|
||||
import os, threading, hmac
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Flask, render_template, request, jsonify, Response
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger la configuration AVANT les imports des services
|
||||
load_dotenv("/opt/alfred/.env")
|
||||
load_dotenv(
|
||||
"/opt/alfred/app/config/control_center.env",
|
||||
override=True
|
||||
)
|
||||
|
||||
from services.audio import listen_once, speak_async, tts_backend_detect
|
||||
from services.wakeword import WakeWordListener
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
from services.piper_tts import stop_playback as duplex_stop_piper
|
||||
from services import admin_console # ALFRED_ADMIN_PAGES_V1
|
||||
from services.hermes_client import ask_hermes
|
||||
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
|
||||
from services import alfred_features # ALFRED_FEATURES_V45
|
||||
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
||||
from services.camera import video_stream
|
||||
from services import ha_views, alfred_views_controller
|
||||
from services import camera_settings, ha_weather, weather_outfits
|
||||
|
||||
load_dotenv()
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
|
||||
##
|
||||
from services.audio import get_vosk_model
|
||||
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
|
||||
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
|
||||
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
|
||||
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||
##
|
||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
||||
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||
STATE_LOCK = threading.RLock()
|
||||
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
|
||||
conversation_lock=threading.Lock()
|
||||
mic_stop_event=threading.Event()
|
||||
# Service temps autonome (survit aux conversations Hermes)
|
||||
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
|
||||
audio_settings.initialize()
|
||||
audio_settings.update_settings({"mic_gain": 30})
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
|
||||
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
|
||||
on_interrupt=duplex_stop_piper,
|
||||
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
|
||||
|
||||
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
|
||||
camera_settings.restore_saved_settings()
|
||||
|
||||
def emit_state(mode=None, **extra):
|
||||
with STATE_LOCK:
|
||||
if mode: STATE["mode"] = mode
|
||||
STATE.update(extra)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return
|
||||
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
|
||||
try:
|
||||
emit_state("listening", partial="", transcript="", wake_score=score)
|
||||
|
||||
print("[ALFRED] Début écoute STT", flush=True)
|
||||
|
||||
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
from services.audio import STTResult
|
||||
r = STTResult(text=injected_text)
|
||||
else:
|
||||
r = listen_once(
|
||||
stop_event=mic_stop_event,
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
|
||||
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
|
||||
|
||||
if audio_settings.mic_muted():
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
if r.error:
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": r.error}
|
||||
)
|
||||
return
|
||||
|
||||
if not r.text:
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
emit_state(
|
||||
"thinking",
|
||||
partial="",
|
||||
transcript=r.text,
|
||||
content={"type": "text", "text": r.text}
|
||||
)
|
||||
|
||||
# Vue dynamique : commandes déterministes exécutées sans faire
|
||||
# dépendre l'affichage des capacités ou sessions Hermes.
|
||||
visual = None
|
||||
try:
|
||||
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
|
||||
visual = ({"type":"text", "text":str(exc)}, str(exc))
|
||||
|
||||
if visual is not None:
|
||||
content, speech = visual
|
||||
with STATE_LOCK:
|
||||
STATE["content"] = content
|
||||
socketio.emit("alfred_render", content)
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=done.set)
|
||||
done.wait(timeout=120)
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_START
|
||||
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
|
||||
try:
|
||||
_lists_reply = _alfred_handle_list_voice(
|
||||
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
|
||||
except Exception as _lists_error:
|
||||
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
|
||||
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
|
||||
if _lists_reply is not None:
|
||||
_lists_display = {'type':'text', 'text':_lists_reply}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _lists_display
|
||||
socketio.emit('alfred_render', _lists_display)
|
||||
if _lists_reply and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_lists_done = threading.Event()
|
||||
speak_async(_lists_reply, on_end=_lists_done.set)
|
||||
_lists_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_END
|
||||
|
||||
print("[ALFRED] Envoi à Hermes", flush=True)
|
||||
# ComfyUI publie l'image pendant cet appel Hermes.
|
||||
with STATE_LOCK:
|
||||
image_revision_before = IMAGE_REVISION
|
||||
# ALFRED_FEATURES_V45 voice opt-in background mode
|
||||
if alfred_features.should_run_in_background(r.text):
|
||||
try:
|
||||
alfred_features.submit(r.text, source='voice')
|
||||
emit_state('idle', partial='', content={
|
||||
'type': 'text',
|
||||
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
|
||||
})
|
||||
except ValueError as exc:
|
||||
emit_state('error', content={'type': 'text', 'text': str(exc)})
|
||||
return
|
||||
answer = ask_hermes(r.text)
|
||||
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
|
||||
if isinstance(answer, dict):
|
||||
speech = str(answer.get("speech") or answer.get("text") or "")
|
||||
display = answer.get("display") or {
|
||||
"type": "text",
|
||||
"text": speech
|
||||
}
|
||||
else:
|
||||
speech = str(answer or "")
|
||||
display = {"type": "text", "text": speech}
|
||||
|
||||
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
|
||||
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
|
||||
alfred_context_images.schedule_context_image(
|
||||
r.text, speech, socketio,
|
||||
lambda: STATE.get("transcript") == r.text,
|
||||
) # ALFRED_TIMERS_CONTEXT_V1
|
||||
|
||||
# Si le MCP ComfyUI a affiché une image pendant la requête,
|
||||
# ne pas l'écraser par la réponse textuelle d'Hermes.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == image_revision_before:
|
||||
STATE["content"] = display
|
||||
alfred_features.publish_voice_display(display)
|
||||
socketio.emit("alfred_render", display)
|
||||
else:
|
||||
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
|
||||
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
try:
|
||||
started = speak_async(speech, on_end=done.set)
|
||||
if started:
|
||||
done.wait(timeout=120)
|
||||
finally:
|
||||
if not duplex_monitor.interrupted():
|
||||
duplex_monitor.stop()
|
||||
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
|
||||
if duplex_monitor.interrupted():
|
||||
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
|
||||
duplex_monitor.stop()
|
||||
else:
|
||||
# A few wake-free seconds after the assistant finishes speaking.
|
||||
pending_duplex_text = duplex_monitor.listen_conversation()
|
||||
if pending_duplex_text:
|
||||
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
|
||||
|
||||
emit_state("idle", partial="")
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": str(exc)}
|
||||
)
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
# Only the voice is cancelled; the original Hermes job is untouched.
|
||||
wake.pause()
|
||||
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
|
||||
daemon=True, name='dona-duplex-followup').start()
|
||||
elif not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
print("[ALFRED] Wake word réactivé", flush=True)
|
||||
|
||||
|
||||
|
||||
def on_wake(score):
|
||||
if audio_settings.mic_muted():
|
||||
return
|
||||
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
|
||||
threading.Thread(target=conversation,args=(score,),daemon=True).start()
|
||||
|
||||
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
|
||||
|
||||
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
|
||||
@app.get('/api/duplex/status')
|
||||
def duplex_status():
|
||||
return jsonify(duplex_monitor.status())
|
||||
|
||||
@app.get('/api/audio/settings')
|
||||
def get_audio_settings():
|
||||
return jsonify(audio_settings.get_settings())
|
||||
|
||||
@app.post('/api/audio/settings')
|
||||
def set_audio_settings():
|
||||
data = request.get_json(silent=True)
|
||||
try:
|
||||
old = audio_settings.get_settings()
|
||||
new = audio_settings.update_settings(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'ALSA : {exc}'}), 500
|
||||
if new['mic_muted'] != old['mic_muted']:
|
||||
if new['mic_muted']:
|
||||
mic_stop_event.set()
|
||||
wake.pause()
|
||||
else:
|
||||
mic_stop_event.clear()
|
||||
if not conversation_lock.locked():
|
||||
wake.resume()
|
||||
socketio.emit('audio_settings', new)
|
||||
return jsonify(new)
|
||||
|
||||
@app.get('/api/camera/settings')
|
||||
def get_camera_settings():
|
||||
try:
|
||||
return jsonify(camera_settings.get_settings())
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/settings')
|
||||
def set_camera_settings():
|
||||
# Même garde-fou que les commandes calendrier existantes.
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'JSON invalide.'}), 400
|
||||
try:
|
||||
result = camera_settings.update_setting(data.get('control'), data.get('value'))
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/reset')
|
||||
def reset_camera_settings():
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
try:
|
||||
result = camera_settings.reset_defaults()
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.get('/api/camera/stream')
|
||||
def camera_stream():
|
||||
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
|
||||
try:
|
||||
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
|
||||
except (ImportError, ValueError, RuntimeError, OSError) as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
return Response(
|
||||
frames,
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame',
|
||||
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
|
||||
)
|
||||
|
||||
# ALFRED_FEATURES_V45 init
|
||||
alfred_features.init_app(app, ask_hermes, socketio=socketio)
|
||||
|
||||
@app.get("/")
|
||||
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
|
||||
@app.get("/api/state")
|
||||
def state():
|
||||
with STATE_LOCK:
|
||||
snapshot = dict(STATE)
|
||||
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
|
||||
|
||||
@app.post("/api/state")
|
||||
def set_state():
|
||||
data = request.get_json(force=True)
|
||||
with STATE_LOCK:
|
||||
STATE.update(data)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
return jsonify({"ok": True})
|
||||
@app.post("/api/render")
|
||||
def render_payload():
|
||||
global IMAGE_REVISION
|
||||
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||
if token and not hmac.compare_digest(
|
||||
request.headers.get("X-Alfred-Render-Token", ""), token
|
||||
):
|
||||
return jsonify({"ok": False, "error": "Non autorisé"}), 401
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
|
||||
if data.get("type") == "image":
|
||||
image_url = data.get("url")
|
||||
if not isinstance(image_url, str) or not (
|
||||
image_url.startswith(("http://", "https://", "/"))
|
||||
and not image_url.startswith("//")
|
||||
):
|
||||
return jsonify({"ok": False, "error": "URL image invalide"}), 400
|
||||
|
||||
with STATE_LOCK:
|
||||
if data.get("type") == "image":
|
||||
IMAGE_REVISION += 1
|
||||
STATE["content"] = data
|
||||
socketio.emit("alfred_render", data)
|
||||
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
|
||||
# --- Agenda / capteurs / caméras Home Assistant ---
|
||||
def _calendar_range():
|
||||
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
|
||||
now = datetime.now(tz)
|
||||
def parse(name, default):
|
||||
raw = request.args.get(name)
|
||||
if not raw:return default
|
||||
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
|
||||
if value.tzinfo is None: value = value.replace(tzinfo=tz)
|
||||
return value.astimezone(tz)
|
||||
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
|
||||
end = parse('end',start+timedelta(days=7))
|
||||
return start,end
|
||||
|
||||
def _json_error(exc):
|
||||
code = 400 if isinstance(exc,ValueError) else 502
|
||||
return jsonify({'error':str(exc)}),code
|
||||
|
||||
def _writes_enabled():
|
||||
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
|
||||
|
||||
def _same_origin_json():
|
||||
# POSTs require a custom header and same-origin browser requests.
|
||||
if request.headers.get('X-Alfred-Request') != '1':return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
|
||||
return True
|
||||
|
||||
@app.get('/api/calendar/calendars')
|
||||
def calendars_list():
|
||||
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/calendar/events')
|
||||
def calendar_events():
|
||||
try:
|
||||
start,end=_calendar_range()
|
||||
selected=request.args.getlist('calendar_id')
|
||||
return jsonify(ha_views.get_events(start,end,selected or None))
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/create')
|
||||
def calendar_create():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
def read_dt(key):
|
||||
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
|
||||
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
||||
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
|
||||
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
|
||||
str(data.get('location') or ''),bool(data.get('all_day',False)))
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_create()
|
||||
socketio.emit('calendar_changed', {'type':'created'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/delete')
|
||||
def calendar_delete():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
|
||||
start=datetime.fromisoformat(str(data['start']))
|
||||
end=datetime.fromisoformat(str(data['end']))
|
||||
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
|
||||
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
|
||||
rid=str(data.get('recurrence_id') or '')
|
||||
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
|
||||
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
|
||||
result=ha_views.delete_event(eid,uid,rid or None)
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_delete()
|
||||
socketio.emit('calendar_changed',{'type':'deleted'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather')
|
||||
def ha_weather_data():
|
||||
# Read-only route, token always stays on the Alfred server.
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
return jsonify(ha_weather.get_weather(q))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather/outfits')
|
||||
def ha_weather_outfits_data():
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
|
||||
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
|
||||
except (ha_views.HAError, ValueError, RuntimeError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperatures')
|
||||
def ha_temperatures():
|
||||
try:return jsonify({'items':ha_views.get_temperatures()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperature/history')
|
||||
def ha_temperature_history():
|
||||
try:
|
||||
eid=request.args.get('entity_id','')
|
||||
hours=int(request.args.get('hours','24'))
|
||||
return jsonify({'items':ha_views.temperature_history(eid,hours)})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/cameras')
|
||||
def ha_cameras():
|
||||
try:return jsonify({'items':ha_views.list_cameras()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
|
||||
def ha_camera_snapshot(entity_id):
|
||||
try:
|
||||
binary,mime=ha_views.camera_snapshot(entity_id)
|
||||
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/ui/home')
|
||||
def ui_home():
|
||||
with STATE_LOCK:
|
||||
STATE['content']={'type':'home','text':'À ton écoute.'}
|
||||
socketio.emit('alfred_render',STATE['content'])
|
||||
return jsonify({'ok':True})
|
||||
|
||||
@app.post("/api/audio-level")
|
||||
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
|
||||
@app.get("/api/wake/status")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
|
||||
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
|
||||
def _alfred_dashboard_quick_command(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
|
||||
def _run():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_image_revision_before = IMAGE_REVISION
|
||||
emit_state('thinking', transcript=prompt, partial='',
|
||||
content={'type':'text', 'text':prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _image_revision_before:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
if _speech and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_done = threading.Event()
|
||||
speak_async(_speech, on_end=_done.set)
|
||||
_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
except Exception as _exc:
|
||||
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':str(_exc)})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
|
||||
return True
|
||||
|
||||
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
|
||||
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_START
|
||||
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
|
||||
_alfred_register_home_glass(app)
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_END
|
||||
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_START
|
||||
from services.alfred_lists import register_lists as _alfred_register_lists
|
||||
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
|
||||
_alfred_register_lists(app, socketio)
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_GLASS_V2_BACKEND_START
|
||||
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
|
||||
_alfred_register_logs_v2(app)
|
||||
# ALFRED_GLASS_V2_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
|
||||
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
|
||||
_alfred_identity_register(app)
|
||||
|
||||
|
||||
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
|
||||
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
|
||||
_alfred_developer_register(app)
|
||||
|
||||
|
||||
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
|
||||
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
|
||||
_register_hermes_new_session(app)
|
||||
|
||||
|
||||
# DONA_FAMILY_UI_V1_START
|
||||
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
|
||||
def _dona_submit_typed(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _dona_run_typed():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_before_image = IMAGE_REVISION
|
||||
emit_state('thinking', partial='', transcript=prompt,
|
||||
content={'type': 'text', 'text': prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _before_image:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
# Deliberately no speak_async(): typed interaction is silent.
|
||||
emit_state('idle', partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
|
||||
return True
|
||||
|
||||
from services.dona_family_ui import register_dona_family as _dona_register_family
|
||||
_dona_register_family(app, adult_command=_dona_submit_typed)
|
||||
# DONA_FAMILY_UI_V1_END
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"app": "/opt/alfred/app",
|
||||
"existing": [
|
||||
"app.py",
|
||||
"services/hermes_client.py",
|
||||
"services/alfred_identity_page.py",
|
||||
"static/js/dona-accueil-ecrit-v1.js",
|
||||
"static/js/dona-ui-fix.js",
|
||||
"templates/index.html",
|
||||
"templates/alfred_identity_glass.html"
|
||||
],
|
||||
"created": [
|
||||
"services/dona_spotify.py",
|
||||
"static/js/dona-spotify.js",
|
||||
"static/css/dona-v6.css"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Identity editor and current assistant name, authenticated by Alfred's admin UI.
|
||||
|
||||
Identity text stays in config/identity.md. All writes are atomic; dated revisions
|
||||
are kept separately. Technical names/paths/API identifiers are never renamed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
from services import admin_console
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONFIG = ROOT / 'config'
|
||||
IDENTITY = CONFIG / 'identity.md'
|
||||
TRACKER = CONFIG / 'identity_name_state.json'
|
||||
HISTORY = CONFIG / 'identity_history'
|
||||
LOCK = threading.RLock()
|
||||
MAX_SIZE = 40_000
|
||||
ASSISTANT_RE = re.compile(r"(?<![A-Za-z0-9_.-])Alfred(?![A-Za-z0-9_.-])")
|
||||
|
||||
|
||||
def active_name():
|
||||
name = (os.getenv('ALFRED_NAME') or 'Alfred').strip()
|
||||
return name if 2 <= len(name) <= 30 else 'Alfred'
|
||||
|
||||
|
||||
def identity_path():
|
||||
# Avoid an admin Web editor writing outside the identity config directory.
|
||||
raw = Path(os.getenv('ALFRED_IDENTITY_FILE', str(IDENTITY))).expanduser()
|
||||
resolved = raw.resolve()
|
||||
if resolved.parent != CONFIG.resolve() or raw.is_symlink():
|
||||
raise ValueError('Le fichier ALFRED_IDENTITY_FILE est externe à config/ : édition Web refusée.')
|
||||
return resolved
|
||||
|
||||
|
||||
def sha(text):
|
||||
return hashlib.sha256(text.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _read_text():
|
||||
path = identity_path()
|
||||
return path.read_text(encoding='utf-8') if path.exists() else ''
|
||||
|
||||
|
||||
def _save_atomic(path, content, mode=0o600):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp = tempfile.mkstemp(dir=str(path.parent), prefix='.identity-', suffix='.tmp')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
|
||||
fh.write(content)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.chmod(temp, mode)
|
||||
os.replace(temp, path)
|
||||
finally:
|
||||
if os.path.exists(temp):
|
||||
os.unlink(temp)
|
||||
|
||||
|
||||
def _revision(text, label):
|
||||
HISTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
filename = datetime.now().strftime('%Y%m%d_%H%M%S_%f') + '_' + label + '.md'
|
||||
dest = HISTORY / filename
|
||||
fd = os.open(dest, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def _old_name(text):
|
||||
if TRACKER.exists():
|
||||
try:
|
||||
data = json.loads(TRACKER.read_text(encoding='utf-8'))
|
||||
value = data.get('name', '')
|
||||
if isinstance(value, str) and 2 <= len(value) <= 30:
|
||||
return value
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
# Existing identity files tend to contain "Tu t'appelles Alfred".
|
||||
match = re.search(r"\bTu\s+t['’]appelles\s+([A-Za-zÀ-ÖØ-öø-ÿ-]{2,30})\b", text, flags=re.I)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return 'Alfred'
|
||||
|
||||
|
||||
def _replace_persona_name(text, old, new):
|
||||
if old == new:
|
||||
return text
|
||||
# Replace proper standalone name only; not ALFRED_NAME, alfred.service,
|
||||
# MCP_alfred_timers, /opt/alfred etc.
|
||||
pattern = re.compile(r'(?<![A-Za-z0-9_.-])' + re.escape(old) + r'(?![A-Za-z0-9_-]|\.[A-Za-z0-9_-])')
|
||||
changed = pattern.sub(lambda _m: new, text)
|
||||
if old != new and old and old[0].lower() in 'aeiouy' and new and new[0].lower() not in 'aeiouyh':
|
||||
changed = changed.replace("d'" + new, 'de ' + new).replace('d’' + new, 'de ' + new)
|
||||
if changed == text:
|
||||
# No existing self-name found: insert an explicit, visible identity rule.
|
||||
changed = f'# Identité actuelle\nTu t’appelles {new}.\n\n' + text
|
||||
return changed
|
||||
|
||||
|
||||
def sync_identity_name(name=None):
|
||||
"""Called on startup and after a /config rename. Never erases custom prose."""
|
||||
new = name or active_name()
|
||||
if not isinstance(new, str) or not 2 <= len(new) <= 30 or any(c in new for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
|
||||
raise ValueError('Nom d’assistant invalide.')
|
||||
with LOCK:
|
||||
path = identity_path()
|
||||
original = _read_text()
|
||||
if not original.strip():
|
||||
# No identity installed: don't silently create a new persona.
|
||||
raise ValueError('Le fichier identity.md est vide ou absent.')
|
||||
old = _old_name(original)
|
||||
updated = _replace_persona_name(original, old, new)
|
||||
if updated != original:
|
||||
_revision(original, 'avant_nom')
|
||||
mode = (path.stat().st_mode & 0o777) if path.exists() else 0o600
|
||||
_save_atomic(path, updated, mode)
|
||||
# Keep original metadata if the file text was unchanged; preserve editability.
|
||||
if not TRACKER.exists() or old != new:
|
||||
_save_atomic(TRACKER, json.dumps({'name':new}, ensure_ascii=False) + '\n')
|
||||
return {'changed': updated != original, 'name':new}
|
||||
|
||||
|
||||
def identity_for_hermes(text):
|
||||
"""Runtime guard: current name wins even if a legacy identity wasn't synced."""
|
||||
new = active_name()
|
||||
old = _old_name(text)
|
||||
content = _replace_persona_name(text, old, new) if text else text
|
||||
return ('Règle d’identité prioritaire : ton nom actuel est ' + new +
|
||||
'. Si l’on demande ton nom, réponds « Je m’appelle ' + new + ' ».\n\n' + content).strip()
|
||||
|
||||
|
||||
def register_identity_page(app):
|
||||
bp = Blueprint('alfred_identity_glass', __name__)
|
||||
|
||||
@bp.get('/identity')
|
||||
@bp.get('/identity/')
|
||||
def page():
|
||||
# Never embed identity text or secrets in unauthenticated HTML.
|
||||
return render_template('alfred_identity_glass.html')
|
||||
|
||||
@bp.get('/api/alfred/identity/name')
|
||||
def name_api():
|
||||
res = jsonify(name=active_name(), wake_word=os.getenv('WAKE_WORD', 'alfred'))
|
||||
res.headers['Cache-Control'] = 'no-store'
|
||||
return res
|
||||
|
||||
@bp.get('/api/control/identity')
|
||||
@admin_console._require_admin
|
||||
def get_identity():
|
||||
try:
|
||||
with LOCK:
|
||||
content = _read_text()
|
||||
except (OSError, ValueError) as e:
|
||||
return jsonify(error=str(e)), 503
|
||||
res = jsonify(name=active_name(), text=content, revision=sha(content),
|
||||
file='config/identity.md')
|
||||
res.headers['Cache-Control'] = 'no-store, private'
|
||||
return res
|
||||
|
||||
@bp.post('/api/control/identity')
|
||||
@admin_console._require_admin
|
||||
def save_identity():
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify(error='Données JSON invalides.'), 400
|
||||
text = data.get('text')
|
||||
revision = data.get('revision')
|
||||
if not isinstance(text, str) or not isinstance(revision, str):
|
||||
return jsonify(error='Texte ou révision manquante.'), 400
|
||||
if not text.strip() or len(text.encode('utf-8')) > MAX_SIZE or '\x00' in text:
|
||||
return jsonify(error='L’identité doit être non vide et ne pas dépasser 40 Ko.'), 400
|
||||
if not re.fullmatch(r'[0-9a-f]{64}', revision):
|
||||
return jsonify(error='Révision invalide.'), 400
|
||||
try:
|
||||
with LOCK:
|
||||
path = identity_path()
|
||||
old = _read_text()
|
||||
if sha(old) != revision:
|
||||
return jsonify(error='Le fichier a changé depuis ton chargement. Recharge avant d’enregistrer.'), 409
|
||||
if text != old:
|
||||
_revision(old, 'avant_edition')
|
||||
mode = (path.stat().st_mode & 0o777) if path.exists() else 0o600
|
||||
_save_atomic(path, text, mode)
|
||||
res = jsonify(ok=True, revision=sha(text), name=active_name(),
|
||||
updated=text != old, file='config/identity.md')
|
||||
res.headers['Cache-Control'] = 'no-store, private'
|
||||
return res
|
||||
except (OSError, ValueError) as e:
|
||||
return jsonify(error='Enregistrement impossible : '+str(e)), 500
|
||||
|
||||
app.register_blueprint(bp)
|
||||
# If a renamed assistant was configured before installation, reconcile on startup.
|
||||
try:
|
||||
sync_identity_name()
|
||||
except (OSError, ValueError) as e:
|
||||
app.logger.warning('Synchronisation identité différée : %s', e)
|
||||
@@ -0,0 +1,478 @@
|
||||
|
||||
"""Pont Alfred -> Hermes avec session persistante."""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# CONFIGURATION
|
||||
# --------------------------------------------------
|
||||
|
||||
DEFAULT_IDENTITY_FILE = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "config"
|
||||
/ "identity.md"
|
||||
)
|
||||
|
||||
DEFAULT_SESSION_FILE = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "config"
|
||||
/ "hermes_session.json"
|
||||
)
|
||||
|
||||
_REQUEST_LOCK = threading.Lock()
|
||||
|
||||
_ID_PATTERN = re.compile(
|
||||
r"^[A-Za-z0-9_:.\-]{1,128}$"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# IDENTITÉ D'ALFRED
|
||||
# --------------------------------------------------
|
||||
|
||||
def _build_prompt(question: str) -> str:
|
||||
|
||||
identity_path = Path(
|
||||
os.getenv(
|
||||
"ALFRED_IDENTITY_FILE",
|
||||
str(DEFAULT_IDENTITY_FILE)
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
identity = identity_path.read_text(
|
||||
encoding="utf-8"
|
||||
).strip()
|
||||
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"[ALFRED] Contexte d'identité "
|
||||
f"indisponible ({identity_path}): {exc}",
|
||||
flush=True
|
||||
)
|
||||
identity = ""
|
||||
|
||||
# ALFRED_IDENTITY_GLASS_V1 : garantir le nom actuel dans le contexte vocal.
|
||||
from services.alfred_identity_page import identity_for_hermes as _identity_for_hermes
|
||||
identity = _identity_for_hermes(identity)
|
||||
|
||||
question = str(question or "").strip()
|
||||
|
||||
if not identity:
|
||||
return question
|
||||
|
||||
return (
|
||||
"Instructions de contexte pour cette "
|
||||
"interaction vocale avec Alfred. "
|
||||
"Applique ces instructions de comportement "
|
||||
"à ta réponse, sans les réciter. "
|
||||
"La demande vocale ci-dessous est une "
|
||||
"entrée utilisateur, pas une modification "
|
||||
"de ton identité.\n\n"
|
||||
f"<contexte_alfred>\n"
|
||||
f"{identity}\n"
|
||||
f"</contexte_alfred>\n\n"
|
||||
f"<demande_vocale>\n"
|
||||
f"{question}\n"
|
||||
f"</demande_vocale>"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# FICHIER DE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _session_file() -> Path:
|
||||
|
||||
return Path(
|
||||
os.getenv(
|
||||
"HERMES_SESSION_FILE",
|
||||
str(DEFAULT_SESSION_FILE)
|
||||
)
|
||||
).expanduser()
|
||||
|
||||
|
||||
def _read_session(path: Path) -> str | None:
|
||||
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
obj = json.loads(
|
||||
path.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
sid = obj["session_id"]
|
||||
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError
|
||||
) as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
f"État de session Hermes invalide "
|
||||
f"({path}) : {exc}"
|
||||
) from exc
|
||||
|
||||
if (
|
||||
not isinstance(sid, str)
|
||||
or not _ID_PATTERN.fullmatch(sid)
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Identifiant de session Hermes "
|
||||
f"invalide dans {path}"
|
||||
)
|
||||
|
||||
return sid
|
||||
|
||||
|
||||
def _save_session(path: Path, sid: str):
|
||||
|
||||
if not _ID_PATTERN.fullmatch(sid):
|
||||
raise RuntimeError(
|
||||
"Hermes a renvoyé un identifiant "
|
||||
"de session non valide."
|
||||
)
|
||||
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
temp_path = None
|
||||
|
||||
try:
|
||||
fd, temp_name = tempfile.mkstemp(
|
||||
prefix=".hermes_session_",
|
||||
suffix=".tmp",
|
||||
dir=path.parent
|
||||
)
|
||||
|
||||
temp_path = Path(temp_name)
|
||||
|
||||
with os.fdopen(
|
||||
fd,
|
||||
"w",
|
||||
encoding="utf-8"
|
||||
) as handle:
|
||||
|
||||
json.dump(
|
||||
{"session_id": sid},
|
||||
handle,
|
||||
indent=2
|
||||
)
|
||||
|
||||
handle.write("\n")
|
||||
|
||||
os.replace(temp_path, path)
|
||||
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# VERROUILLAGE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def _session_lock(path: Path):
|
||||
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
lock_path = path.with_name(
|
||||
path.name + ".lock"
|
||||
)
|
||||
|
||||
fd = os.open(
|
||||
lock_path,
|
||||
os.O_CREAT | os.O_RDWR,
|
||||
0o600
|
||||
)
|
||||
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
|
||||
fcntl.flock(
|
||||
handle.fileno(),
|
||||
fcntl.LOCK_EX
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
|
||||
finally:
|
||||
fcntl.flock(
|
||||
handle.fileno(),
|
||||
fcntl.LOCK_UN
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# CONFIGURATION HERMES
|
||||
# --------------------------------------------------
|
||||
|
||||
def _hermes_binary() -> str:
|
||||
|
||||
return os.getenv(
|
||||
"HERMES_CLI",
|
||||
"/root/.local/bin/hermes"
|
||||
)
|
||||
|
||||
|
||||
def _hermes_timeout() -> float:
|
||||
|
||||
return max(
|
||||
10.0,
|
||||
float(
|
||||
os.getenv("HERMES_TIMEOUT", "180")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# ANCIEN MODE SANS SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _oneshot_legacy(prompt: str) -> str:
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
_hermes_binary(),
|
||||
"--oneshot",
|
||||
prompt
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_hermes_timeout(),
|
||||
check=True
|
||||
)
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas répondu "
|
||||
"dans le délai imparti."
|
||||
) from exc
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
|
||||
details = (
|
||||
exc.stderr
|
||||
or exc.stdout
|
||||
or "aucun détail"
|
||||
).strip()
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes a échoué "
|
||||
f"(code {exc.returncode}): {details}"
|
||||
) from exc
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# INTERPRÉTATION RÉPONSE JSONL
|
||||
# --------------------------------------------------
|
||||
|
||||
def _parse_jsonl(stdout: str) -> tuple[str, str]:
|
||||
|
||||
final = None
|
||||
initial_session_id = None
|
||||
|
||||
for line in stdout.splitlines():
|
||||
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
event = json.loads(line)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
if (
|
||||
event.get("type") == "system"
|
||||
and event.get("subtype") == "init"
|
||||
):
|
||||
initial_session_id = event.get(
|
||||
"session_id"
|
||||
)
|
||||
|
||||
if event.get("type") == "result":
|
||||
final = event
|
||||
|
||||
if final is None:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas renvoyé de résultat "
|
||||
"JSONL. Vérifie la prise en charge "
|
||||
"de --format stream-json."
|
||||
)
|
||||
|
||||
if final.get("exit_code", 0) != 0:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes : "
|
||||
f"{final.get('error') or 'échec'}"
|
||||
)
|
||||
|
||||
sid = (
|
||||
final.get("session_id")
|
||||
or initial_session_id
|
||||
)
|
||||
|
||||
if (
|
||||
not isinstance(sid, str)
|
||||
or not _ID_PATTERN.fullmatch(sid)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas fourni "
|
||||
"d'identifiant de session utilisable."
|
||||
)
|
||||
|
||||
answer = final.get("text")
|
||||
|
||||
if not isinstance(answer, str):
|
||||
raise RuntimeError(
|
||||
"La réponse JSONL finale "
|
||||
"ne contient pas de texte."
|
||||
)
|
||||
|
||||
return answer.strip(), sid
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# APPEL HERMES AVEC REPRISE DE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _call_hermes(
|
||||
prompt: str,
|
||||
session_id: str | None
|
||||
) -> tuple[str, str]:
|
||||
|
||||
cmd = [
|
||||
_hermes_binary(),
|
||||
"chat",
|
||||
"--oneshot",
|
||||
"--query-file",
|
||||
"-",
|
||||
"--format",
|
||||
"stream-json",
|
||||
"--source",
|
||||
"cli"
|
||||
]
|
||||
|
||||
if session_id:
|
||||
cmd.extend([
|
||||
"--resume",
|
||||
session_id
|
||||
])
|
||||
|
||||
try:
|
||||
process = subprocess.run(
|
||||
cmd,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_hermes_timeout(),
|
||||
check=False
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas répondu "
|
||||
"dans le délai imparti."
|
||||
) from exc
|
||||
|
||||
except FileNotFoundError as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes introuvable : "
|
||||
f"{_hermes_binary()}"
|
||||
) from exc
|
||||
|
||||
if process.returncode != 0:
|
||||
|
||||
msg = (
|
||||
process.stderr
|
||||
or process.stdout
|
||||
or "aucun détail"
|
||||
).strip()
|
||||
|
||||
if len(msg) > 1500:
|
||||
msg = msg[-1500:]
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes a échoué "
|
||||
f"(code {process.returncode}) : "
|
||||
f"{msg}"
|
||||
)
|
||||
|
||||
return _parse_jsonl(process.stdout)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# FONCTION PRINCIPALE APPELÉE PAR FLASK
|
||||
# --------------------------------------------------
|
||||
|
||||
def ask_hermes(text: str) -> str:
|
||||
|
||||
prompt = _build_prompt(text)
|
||||
|
||||
# Ancien fonctionnement sans session
|
||||
if (
|
||||
os.getenv(
|
||||
"HERMES_SESSION_MODE",
|
||||
"persistent"
|
||||
).lower() == "legacy"
|
||||
):
|
||||
return _oneshot_legacy(prompt)
|
||||
|
||||
path = _session_file()
|
||||
|
||||
# Empêche les appels concurrents
|
||||
with _REQUEST_LOCK, _session_lock(path):
|
||||
|
||||
# Recherche la session précédente
|
||||
current_sid = _read_session(path)
|
||||
|
||||
# Envoie la question à Hermes
|
||||
answer, new_sid = _call_hermes(
|
||||
prompt,
|
||||
current_sid
|
||||
)
|
||||
|
||||
# Sauvegarde l'identifiant
|
||||
_save_session(
|
||||
path,
|
||||
new_sid
|
||||
)
|
||||
|
||||
print(
|
||||
f"[ALFRED] Session Hermes : "
|
||||
f"{new_sid} "
|
||||
f"({'reprise' if current_sid else 'création'})",
|
||||
flush=True
|
||||
)
|
||||
|
||||
return answer
|
||||
@@ -0,0 +1,108 @@
|
||||
/* DONA_FAMILY_UI_V1: non-destructive augmentation of the installed Glass V1 dashboard. */
|
||||
(() => {
|
||||
'use strict';
|
||||
let attempts=0;
|
||||
function init(){
|
||||
const root=document.getElementById('alfredMainV1');
|
||||
if(!root){if(++attempts<40)setTimeout(init,150);return;}
|
||||
if(root.dataset.donaFamily==='ready')return;
|
||||
const $=id=>document.getElementById(id);
|
||||
const head=root.querySelector('.av-header-right');
|
||||
const weather=root.querySelector('.av-weather');
|
||||
const hero=root.querySelector('.av-voice-hero');
|
||||
const shortcuts=root.querySelector('.av-shortcuts');
|
||||
if(!head||!weather||!hero||!shortcuts)return;
|
||||
root.dataset.donaFamily='ready';document.body.classList.add('dona-family-v1');
|
||||
|
||||
// Move the EXISTING weather widget: old JS keeps updating the SAME IDs.
|
||||
weather.classList.add('dona-inline-weather');
|
||||
weather.setAttribute('title','Ouvrir les détails météo');
|
||||
head.prepend(weather);
|
||||
|
||||
// Child link is supplementary; all old admin/navigation functions remain.
|
||||
const kid=document.createElement('a');kid.href='/enfant';kid.className='dona-child-link';kid.textContent='⭐ Espace enfant';
|
||||
kid.setAttribute('aria-label','Ouvrir le chat enfant, écrit uniquement');
|
||||
head.insertBefore(kid,head.querySelector('.av-head-actions'));
|
||||
const toolsGrid=root.querySelector('#avMoreDialog .av-more-grid');
|
||||
if(toolsGrid&&!toolsGrid.querySelector('a[href="/enfant"]')){
|
||||
const a=document.createElement('a');a.href='/enfant';a.textContent='⭐ Espace enfant';toolsGrid.append(a);
|
||||
}
|
||||
|
||||
// New expressive centre; old controls stay in DOM so audio event listeners work.
|
||||
const display=document.createElement('div');display.className='dona-art-center';
|
||||
display.innerHTML='<div class="dona-organic-wrap"><canvas id="donaOrganicCanvas" aria-label="Orbe animé de Dona" role="img"></canvas><span class="dona-orb-reflection" aria-hidden="true"></span></div><strong class="dona-art-name" id="donaArtName"></strong><span class="dona-art-subtitle" id="donaArtSubtitle">Prêt à vous aider</span>';
|
||||
hero.prepend(display);
|
||||
const oldVoice=root.querySelector('.av-voice-center');
|
||||
if(oldVoice){display.append(oldVoice);}
|
||||
|
||||
// Move shortcuts into a collapsed tray, instead of removing their controls.
|
||||
const tools=document.createElement('details');tools.className='dona-tools-tray';
|
||||
const summary=document.createElement('summary');summary.textContent='☷ Raccourcis';
|
||||
shortcuts.parentNode.insertBefore(tools,shortcuts);tools.append(summary,shortcuts);
|
||||
|
||||
// Collapsible multi-line composition field. No TTS on typed requests.
|
||||
const compose=document.createElement('div');compose.className='dona-compose';
|
||||
compose.innerHTML=`<button class="dona-compose-open" type="button" id="donaComposeToggle" aria-expanded="false" aria-controls="donaComposeBody"><span>✎</span> Écrire une demande à Dona… <span aria-hidden="true">⌃</span></button>
|
||||
<form class="dona-compose-body" id="donaComposeBody" hidden>
|
||||
<label for="donaComposeInput">Ta demande écrite à Hermes</label>
|
||||
<div class="dona-compose-row"><textarea id="donaComposeInput" maxlength="1600" rows="2" placeholder="Ex. Résume mes cinq derniers mails, sans en envoyer." required></textarea><button id="donaComposeSend" type="submit">Envoyer ➤</button></div>
|
||||
<div class="dona-compose-foot"><small>Entrée pour envoyer · Maj + Entrée pour une nouvelle ligne · réponse sans synthèse vocale</small><span id="donaComposeStatus" aria-live="polite"></span></div>
|
||||
</form>`;
|
||||
root.append(compose);
|
||||
const toggle=$('donaComposeToggle'),body=$('donaComposeBody'),input=$('donaComposeInput'),send=$('donaComposeSend'),status=$('donaComposeStatus');
|
||||
function openCompose(value){body.hidden=!value;toggle.setAttribute('aria-expanded',String(value));compose.classList.toggle('is-open',value);if(value)input.focus();}
|
||||
toggle.addEventListener('click',()=>openCompose(body.hidden));
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&!body.hidden&&!root.classList.contains('is-focused'))openCompose(false);});
|
||||
input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();body.requestSubmit();}});
|
||||
body.addEventListener('submit',async e=>{
|
||||
e.preventDefault();let message=input.value.trim();if(!message)return;
|
||||
send.disabled=true;status.textContent='Envoi à Hermes…';
|
||||
try{
|
||||
const r=await fetch('/api/dona/texte',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify({message})});
|
||||
let d={};try{d=await r.json();}catch(_){}
|
||||
if(!r.ok)throw Error(d.error||'Erreur '+r.status);
|
||||
input.value='';status.textContent='Hermes prépare sa réponse…';
|
||||
}catch(err){status.textContent=err.message;}
|
||||
finally{send.disabled=false;}
|
||||
});
|
||||
const modeText={idle:'Prêt à vous aider',waiting:'Prêt à vous aider',listening:'Je vous écoute…',thinking:'Je réfléchis…',speaking:'Je vous réponds…',error:'Un problème est survenu'};
|
||||
function setMode(mode){root.dataset.familyMode=mode;const t=$('donaArtSubtitle');if(t)t.textContent=modeText[mode]||'Prêt à vous aider';if(mode==='idle'&&status.textContent==='Hermes prépare sa réponse…')status.textContent='Réponse reçue';}
|
||||
function setName(value){const name=String(value||$('avName')?.textContent||'Dona').trim();$('donaArtName').textContent=name;}
|
||||
setName();setMode(root.dataset.mode||'idle');
|
||||
const socket=window.alfredSocket;
|
||||
socket?.on('alfred_state',s=>{setMode(String(s.mode||'idle').toLowerCase());setName(s.assistant_name||s.title||$('avName')?.textContent);});
|
||||
socket?.on('alfred_render',()=>{if(status.textContent==='Hermes prépare sa réponse…')status.textContent='Réponse reçue';});
|
||||
fetch('/api/state',{cache:'no-store'}).then(r=>r.ok?r.json():null).then(s=>{if(s){setMode(s.mode||'idle');setName(s.assistant_name||s.title);}}).catch(()=>{});
|
||||
|
||||
// Animated multi-layer liquid orb using canvas, independent of server state.
|
||||
const canvas=$('donaOrganicCanvas'),ctx=canvas.getContext('2d');
|
||||
if(!ctx)return;
|
||||
let w=320,h=320,phase=0,raf=0,last=0;
|
||||
const reduce=matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
function resize(){const rect=canvas.getBoundingClientRect();const dpr=Math.min(devicePixelRatio||1,2);w=Math.max(150,rect.width);h=Math.max(150,rect.height);canvas.width=Math.floor(w*dpr);canvas.height=Math.floor(h*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);}
|
||||
window.addEventListener('resize',resize,{passive:true});resize();
|
||||
function path(radius,layer){const n=130;ctx.beginPath();for(let k=0;k<=n;k++){
|
||||
const a=k/n*Math.PI*2;const modulation=1+0.067*Math.sin(a*3+phase*.72+layer*.64)+0.044*Math.cos(a*5-phase*.39+layer)+0.021*Math.sin(a*8+phase*.55);
|
||||
const r=radius*modulation;const x=Math.cos(a)*r,y=Math.sin(a)*r;
|
||||
if(k===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
|
||||
}ctx.closePath();}
|
||||
function frame(now){raf=requestAnimationFrame(frame);if(document.hidden||now-last<30)return;last=now;
|
||||
const mode=root.dataset.familyMode||'idle';const speed=mode==='listening'?1.7:mode==='thinking'?1.38:mode==='speaking'?1.5:.75;
|
||||
if(!reduce)phase+=.024*speed;
|
||||
ctx.clearRect(0,0,w,h);ctx.save();ctx.translate(w/2,h/2);const r=Math.min(w,h)*.33;
|
||||
const bloom=ctx.createRadialGradient(-r*.2,-r*.15,r*.1,0,0,r*1.45);
|
||||
bloom.addColorStop(0,'rgba(238,117,160,.11)');bloom.addColorStop(.7,'rgba(139,112,246,.14)');bloom.addColorStop(1,'rgba(37,180,255,0)');ctx.fillStyle=bloom;ctx.beginPath();ctx.arc(0,0,r*1.48,0,Math.PI*2);ctx.fill();
|
||||
for(let layer=3;layer>=0;layer--){const waveR=r*(1+layer*.025);path(waveR,layer);
|
||||
const g=ctx.createLinearGradient(-r*.9,r*.8,r*.9,-r*.85);
|
||||
if(layer%2===0){g.addColorStop(0,'rgba(47,196,244,.76)');g.addColorStop(.36,'rgba(68,94,198,.88)');g.addColorStop(.62,'rgba(244,138,173,.74)');g.addColorStop(1,'rgba(255,176,71,.9)');}
|
||||
else {g.addColorStop(0,'rgba(59,164,255,.65)');g.addColorStop(.4,'rgba(196,119,245,.48)');g.addColorStop(.7,'rgba(241,125,146,.52)');g.addColorStop(1,'rgba(255,185,95,.76)');}
|
||||
ctx.globalAlpha=layer===0?.76:.25;ctx.fillStyle=g;ctx.fill();
|
||||
ctx.globalAlpha=layer===0?.92:.31;ctx.strokeStyle=g;ctx.lineWidth=layer===0?2.5:1.8;ctx.shadowBlur=layer===0?23:13;ctx.shadowColor=layer%2?'#bd80ec':'#ffc685';ctx.stroke();ctx.shadowBlur=0;
|
||||
}
|
||||
const ribbon=ctx.createLinearGradient(-r,r*.4,r,-r*.3);ribbon.addColorStop(0,'rgba(67,219,255,.0)');ribbon.addColorStop(.48,'rgba(255,231,197,.56)');ribbon.addColorStop(1,'rgba(255,181,103,.01)');
|
||||
ctx.beginPath();for(let i=0;i<=100;i++){const x=-r+i/100*r*2;const y=Math.sin(i/100*Math.PI*2+phase*.35)*r*.25+Math.cos(i/100*Math.PI*3-phase*.45)*r*.11;if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);}ctx.lineWidth=r*.26;ctx.strokeStyle=ribbon;ctx.globalAlpha=.38;ctx.stroke();ctx.restore();
|
||||
}
|
||||
raf=requestAnimationFrame(frame);window.addEventListener('pagehide',()=>cancelAnimationFrame(raf),{once:true});
|
||||
}
|
||||
init();
|
||||
})();
|
||||
@@ -0,0 +1,134 @@
|
||||
/* Correctif visuel Dona : titre d'accueil et dégagement de la reconnaissance vocale.
|
||||
Ne touche ni à Hermes, ni à Vosk, ni aux réglages du mot d'appel. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const LABEL = 'RECONNAISSANCE VOCALE';
|
||||
const COMPOSER = 'Écrire une demande à';
|
||||
let pending = false;
|
||||
let lastStatus = null;
|
||||
let lastDock = null;
|
||||
let resizeObserver = null;
|
||||
const offsets = new WeakMap();
|
||||
|
||||
function visible(el) {
|
||||
if (!el || el.nodeType !== 1) return false;
|
||||
const s = getComputedStyle(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
return s.display !== 'none' && s.visibility !== 'hidden' &&
|
||||
r.width > 0 && r.height > 0;
|
||||
}
|
||||
|
||||
function textNodes() {
|
||||
const root = document.body;
|
||||
if (!root) return [];
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(n) {
|
||||
const p = n.parentElement;
|
||||
if (!p || /^(SCRIPT|STYLE|NOSCRIPT|TEXTAREA)$/i.test(p.tagName))
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
return n.textContent.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
||||
}
|
||||
});
|
||||
const nodes = [];
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function correctTitle(nodes) {
|
||||
for (const n of nodes) {
|
||||
if (n.textContent.trim() !== 'Alfred' || !visible(n.parentElement)) continue;
|
||||
const el = n.parentElement;
|
||||
const size = parseFloat(getComputedStyle(el).fontSize) || 0;
|
||||
const heading = el.closest('h1,h2,[role="heading"]');
|
||||
// Uniquement le grand titre d'accueil ; ne pas renommer les autres mentions.
|
||||
if (size >= 32 || (heading && parseFloat(getComputedStyle(heading).fontSize) >= 32)) {
|
||||
n.textContent = n.textContent.replace(/Alfred/, 'Dona');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findDock(nodes) {
|
||||
const found = nodes.find(n => n.textContent.includes(COMPOSER) && visible(n.parentElement));
|
||||
if (!found) return null;
|
||||
let best = null;
|
||||
let bestScore = -Infinity;
|
||||
let el = found.parentElement;
|
||||
for (let i = 0; i < 8 && el && el !== document.body; i++, el = el.parentElement) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!visible(el) || r.height < 45 || r.height > 265 || r.width < 250 ||
|
||||
r.top < window.innerHeight * 0.45) continue;
|
||||
const css = getComputedStyle(el);
|
||||
const fixed = /^(fixed|sticky)$/.test(css.position);
|
||||
const radius = parseFloat(css.borderTopLeftRadius) || 0;
|
||||
const score = (fixed ? 30 : 0) + (radius > 12 ? 14 : 0) +
|
||||
(r.width > window.innerWidth * 0.55 ? 10 : 0) -
|
||||
Math.abs(r.bottom - (window.innerHeight - 16)) / 12 -
|
||||
Math.abs(r.height - 85) / 10;
|
||||
if (score > bestScore) { bestScore = score; best = el; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function findVoiceRegion(nodes, dock) {
|
||||
const found = nodes.find(n => n.textContent.includes(LABEL) && visible(n.parentElement));
|
||||
if (!found) return null;
|
||||
let el = found.parentElement;
|
||||
let candidate = el;
|
||||
for (let i = 0; i < 9 && el && el !== document.body; i++, el = el.parentElement) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (dock && el.contains(dock)) break;
|
||||
const text = (el.innerText || '').trim();
|
||||
if (r.height > 300 || text.includes('Prêt à vous aider') || text.length > 1100) break;
|
||||
// Prendre le groupe entier (statut Vosk + transcription), pas la seule étiquette.
|
||||
if (r.height >= 25 && visible(el)) candidate = el;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function update() {
|
||||
pending = false;
|
||||
const nodes = textNodes();
|
||||
correctTitle(nodes);
|
||||
const dock = findDock(nodes);
|
||||
const status = findVoiceRegion(nodes, dock);
|
||||
|
||||
if (resizeObserver) {
|
||||
if (lastStatus && lastStatus !== status) resizeObserver.unobserve(lastStatus);
|
||||
if (lastDock && lastDock !== dock) resizeObserver.unobserve(lastDock);
|
||||
if (status && lastStatus !== status) resizeObserver.observe(status);
|
||||
if (dock && lastDock !== dock) resizeObserver.observe(dock);
|
||||
}
|
||||
lastStatus = status;
|
||||
lastDock = dock;
|
||||
if (!status) return;
|
||||
|
||||
const prev = offsets.get(status) || 0;
|
||||
const rect = status.getBoundingClientRect();
|
||||
const originalBottom = rect.bottom + prev;
|
||||
const topLimit = dock ? dock.getBoundingClientRect().top - 24 : window.innerHeight - 22;
|
||||
const offset = dock ? Math.max(0, Math.ceil(originalBottom - topLimit)) : 0;
|
||||
const safeOffset = Math.min(offset, Math.max(0, rect.top + prev - 70));
|
||||
if (Math.abs(safeOffset - prev) >= 1) {
|
||||
status.style.translate = safeOffset ? `0 -${safeOffset}px` : '';
|
||||
offsets.set(status, safeOffset);
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
function init() {
|
||||
if ('ResizeObserver' in window) resizeObserver = new ResizeObserver(schedule);
|
||||
const observer = new MutationObserver(schedule);
|
||||
observer.observe(document.body, { subtree: true, childList: true, characterData: true });
|
||||
window.addEventListener('resize', schedule, {passive: true});
|
||||
window.addEventListener('orientationchange', schedule, {passive: true});
|
||||
if (window.visualViewport) window.visualViewport.addEventListener('resize', schedule, {passive:true});
|
||||
schedule();
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, {once:true});
|
||||
else init();
|
||||
})();
|
||||
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>Identité · Assistant</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-identity-glass.css?v=1">
|
||||
<script src="/static/js/alfred-identity-glass.js?v=1" defer></script>
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
|
||||
</head>
|
||||
<body>
|
||||
<header class="id-head">
|
||||
<a class="id-brand" href="/"><span class="id-letter" id="idInitial">A</span><span><strong id="idName">Assistant</strong><small>Identité et personnalité</small></span></a>
|
||||
<nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs">Journaux</a><a href="/identity" class="active" aria-current="page">Identité</a></nav>
|
||||
</header>
|
||||
<main class="id-main">
|
||||
<div class="id-title"><div><p class="id-eyebrow">PERSONNALISATION · HERMES</p><h1>Identité de l'assistant</h1><p>Personnalise la personnalité, le ton et les instructions permanentes transmises à Hermes.</p></div><span class="id-pill" id="idBadge">Vérification…</span></div>
|
||||
<section class="id-panel" id="idLoginPanel" hidden>
|
||||
<h2>Accès administrateur</h2><p>Utilise le même mot de passe que la page Configuration.</p>
|
||||
<form id="idLoginForm"><label for="idPassword">Mot de passe</label><input type="password" id="idPassword" autocomplete="current-password" required><button type="submit" class="id-button primary">Se connecter</button></form>
|
||||
<p id="idLoginMessage" role="alert"></p>
|
||||
</section>
|
||||
<section id="idEditor" hidden>
|
||||
<div class="id-layout">
|
||||
<div class="id-panel id-editor-card">
|
||||
<div class="id-editor-title"><div><h2>Document d'identité</h2><p>Édition directe de <code>config/identity.md</code> · format Markdown</p></div><span id="idCount">0 caractère</span></div>
|
||||
<label class="id-sr-only" for="idText">Instructions Hermes</label>
|
||||
<textarea id="idText" spellcheck="false" autocomplete="off" aria-describedby="idEditorHelp"></textarea>
|
||||
<p class="id-tip" id="idEditorHelp">Les changements du texte s'appliquent aux prochaines demandes vocales. Les sessions Hermes anciennes peuvent conserver leur propre historique.</p>
|
||||
<div class="id-buttons"><button type="button" class="id-button" id="idReload">Recharger</button><button type="button" class="id-button primary" id="idSave">Enregistrer l'identité</button></div>
|
||||
<p id="idMessage" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<aside class="id-side">
|
||||
<section class="id-panel"><p class="id-kicker">IDENTITÉ ACTIVE</p><div class="id-current"><span id="idAvatar">A</span><div><strong id="idActiveName">—</strong><small>Nom configuré dans /config</small></div></div><p>Modifier le nom dans <a href="/config">Configuration</a> synchronise automatiquement les mentions de l'assistant dans ce document.</p></section>
|
||||
<section class="id-panel"><h2>Conseils de rédaction</h2><p>Décris les règles de conversation et les préférences durables. Pour les outils spécialisés, utilise les Skills Hermes.</p><div class="id-example">## Style<br>Tu réponds en français, avec des phrases naturelles.<br><br>## Outils<br>Tu confirmes une action seulement après le succès de son outil.</div></section>
|
||||
<section class="id-panel"><h2>Sauvegardes</h2><p>Une copie est créée avant chaque modification dans <code>config/identity_history/</code>. Les fichiers existants et les noms techniques ne sont pas renommés.</p></section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,222 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
|
||||
<!-- ALFRED_MAIN_VISUAL_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
|
||||
<!-- ALFRED_LISTES_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
|
||||
<!-- ALFRED_GLASS_V2 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
|
||||
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
|
||||
<!-- ALFRED_REPONSE_CLOSE_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
|
||||
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
|
||||
<!-- DONA_FAMILY_UI_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
|
||||
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
|
||||
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
|
||||
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
|
||||
<div id="alfredTimersList"></div>
|
||||
</aside>
|
||||
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
|
||||
|
||||
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
|
||||
aria-modal="true" hidden>
|
||||
<article class="alfred-context-card">
|
||||
<button id="alfredContextClose" class="alfred-context-close" type="button"
|
||||
aria-label="Fermer l'illustration" title="Fermer">×</button>
|
||||
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
|
||||
<div class="alfred-context-details">
|
||||
<h2 id="alfredContextTitle"></h2>
|
||||
<p id="alfredContextText"></p>
|
||||
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<script src="/static/js/alfred-timers-context.js"></script>
|
||||
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
|
||||
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
|
||||
<script src="/static/js/alfred-main-v1.js?v=1"></script>
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<script src="/static/js/alfred-maison-card.js?v=1"></script>
|
||||
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
|
||||
<script src="/static/js/alfred-main-v2.js?v=2"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
|
||||
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
|
||||
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
|
||||
<script defer src="/static/js/alfred-reply-auto-close.js?v=1"></script>
|
||||
<script src="/static/js/dona-accueil-ecrit-v1.js?v=1"></script>
|
||||
<!-- dona-ui-fix start -->
|
||||
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=1"></script>
|
||||
<!-- dona-ui-fix end -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,651 @@
|
||||
|
||||
import os, threading, hmac
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Flask, render_template, request, jsonify, Response
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger la configuration AVANT les imports des services
|
||||
load_dotenv("/opt/alfred/.env")
|
||||
load_dotenv(
|
||||
"/opt/alfred/app/config/control_center.env",
|
||||
override=True
|
||||
)
|
||||
|
||||
from services.audio import listen_once, speak_async, tts_backend_detect
|
||||
from services.wakeword import WakeWordListener
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
from services.piper_tts import stop_playback as duplex_stop_piper
|
||||
from services import admin_console # ALFRED_ADMIN_PAGES_V1
|
||||
from services.hermes_client import ask_hermes
|
||||
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
|
||||
from services import alfred_features # ALFRED_FEATURES_V45
|
||||
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
||||
from services.camera import video_stream
|
||||
from services import ha_views, alfred_views_controller
|
||||
from services import camera_settings, ha_weather, weather_outfits
|
||||
|
||||
load_dotenv()
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
|
||||
##
|
||||
from services.audio import get_vosk_model
|
||||
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
|
||||
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
|
||||
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
|
||||
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||
##
|
||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
||||
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||
STATE_LOCK = threading.RLock()
|
||||
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
|
||||
conversation_lock=threading.Lock()
|
||||
mic_stop_event=threading.Event()
|
||||
# Service temps autonome (survit aux conversations Hermes)
|
||||
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
|
||||
audio_settings.initialize()
|
||||
audio_settings.update_settings({"mic_gain": 30})
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
|
||||
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
|
||||
on_interrupt=duplex_stop_piper,
|
||||
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
|
||||
|
||||
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
|
||||
camera_settings.restore_saved_settings()
|
||||
|
||||
def emit_state(mode=None, **extra):
|
||||
with STATE_LOCK:
|
||||
if mode: STATE["mode"] = mode
|
||||
STATE.update(extra)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return
|
||||
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
|
||||
try:
|
||||
emit_state("listening", partial="", transcript="", wake_score=score)
|
||||
|
||||
print("[ALFRED] Début écoute STT", flush=True)
|
||||
|
||||
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
from services.audio import STTResult
|
||||
r = STTResult(text=injected_text)
|
||||
else:
|
||||
r = listen_once(
|
||||
stop_event=mic_stop_event,
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
|
||||
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
|
||||
|
||||
if audio_settings.mic_muted():
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
if r.error:
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": r.error}
|
||||
)
|
||||
return
|
||||
|
||||
if not r.text:
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
emit_state(
|
||||
"thinking",
|
||||
partial="",
|
||||
transcript=r.text,
|
||||
content={"type": "text", "text": r.text}
|
||||
)
|
||||
|
||||
# Vue dynamique : commandes déterministes exécutées sans faire
|
||||
# dépendre l'affichage des capacités ou sessions Hermes.
|
||||
visual = None
|
||||
try:
|
||||
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
|
||||
visual = ({"type":"text", "text":str(exc)}, str(exc))
|
||||
|
||||
if visual is not None:
|
||||
content, speech = visual
|
||||
with STATE_LOCK:
|
||||
STATE["content"] = content
|
||||
socketio.emit("alfred_render", content)
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=done.set)
|
||||
done.wait(timeout=120)
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_START
|
||||
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
|
||||
try:
|
||||
_lists_reply = _alfred_handle_list_voice(
|
||||
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
|
||||
except Exception as _lists_error:
|
||||
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
|
||||
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
|
||||
if _lists_reply is not None:
|
||||
_lists_display = {'type':'text', 'text':_lists_reply}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _lists_display
|
||||
socketio.emit('alfred_render', _lists_display)
|
||||
if _lists_reply and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_lists_done = threading.Event()
|
||||
speak_async(_lists_reply, on_end=_lists_done.set)
|
||||
_lists_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_END
|
||||
|
||||
print("[ALFRED] Envoi à Hermes", flush=True)
|
||||
# ComfyUI publie l'image pendant cet appel Hermes.
|
||||
with STATE_LOCK:
|
||||
image_revision_before = IMAGE_REVISION
|
||||
# ALFRED_FEATURES_V45 voice opt-in background mode
|
||||
if alfred_features.should_run_in_background(r.text):
|
||||
try:
|
||||
alfred_features.submit(r.text, source='voice')
|
||||
emit_state('idle', partial='', content={
|
||||
'type': 'text',
|
||||
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
|
||||
})
|
||||
except ValueError as exc:
|
||||
emit_state('error', content={'type': 'text', 'text': str(exc)})
|
||||
return
|
||||
answer = ask_hermes(r.text)
|
||||
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
|
||||
if isinstance(answer, dict):
|
||||
speech = str(answer.get("speech") or answer.get("text") or "")
|
||||
display = answer.get("display") or {
|
||||
"type": "text",
|
||||
"text": speech
|
||||
}
|
||||
else:
|
||||
speech = str(answer or "")
|
||||
display = {"type": "text", "text": speech}
|
||||
|
||||
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
|
||||
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
|
||||
alfred_context_images.schedule_context_image(
|
||||
r.text, speech, socketio,
|
||||
lambda: STATE.get("transcript") == r.text,
|
||||
) # ALFRED_TIMERS_CONTEXT_V1
|
||||
|
||||
# Si le MCP ComfyUI a affiché une image pendant la requête,
|
||||
# ne pas l'écraser par la réponse textuelle d'Hermes.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == image_revision_before:
|
||||
STATE["content"] = display
|
||||
alfred_features.publish_voice_display(display)
|
||||
socketio.emit("alfred_render", display)
|
||||
else:
|
||||
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
|
||||
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
try:
|
||||
started = speak_async(speech, on_end=done.set)
|
||||
if started:
|
||||
done.wait(timeout=120)
|
||||
finally:
|
||||
if not duplex_monitor.interrupted():
|
||||
duplex_monitor.stop()
|
||||
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
|
||||
if duplex_monitor.interrupted():
|
||||
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
|
||||
duplex_monitor.stop()
|
||||
else:
|
||||
# A few wake-free seconds after the assistant finishes speaking.
|
||||
pending_duplex_text = duplex_monitor.listen_conversation()
|
||||
if pending_duplex_text:
|
||||
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
|
||||
|
||||
emit_state("idle", partial="")
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": str(exc)}
|
||||
)
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
# Only the voice is cancelled; the original Hermes job is untouched.
|
||||
wake.pause()
|
||||
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
|
||||
daemon=True, name='dona-duplex-followup').start()
|
||||
elif not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
print("[ALFRED] Wake word réactivé", flush=True)
|
||||
|
||||
|
||||
|
||||
def on_wake(score):
|
||||
if audio_settings.mic_muted():
|
||||
return
|
||||
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
|
||||
threading.Thread(target=conversation,args=(score,),daemon=True).start()
|
||||
|
||||
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
|
||||
|
||||
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
|
||||
@app.get('/api/duplex/status')
|
||||
def duplex_status():
|
||||
return jsonify(duplex_monitor.status())
|
||||
|
||||
@app.get('/api/audio/settings')
|
||||
def get_audio_settings():
|
||||
return jsonify(audio_settings.get_settings())
|
||||
|
||||
@app.post('/api/audio/settings')
|
||||
def set_audio_settings():
|
||||
data = request.get_json(silent=True)
|
||||
try:
|
||||
old = audio_settings.get_settings()
|
||||
new = audio_settings.update_settings(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'ALSA : {exc}'}), 500
|
||||
if new['mic_muted'] != old['mic_muted']:
|
||||
if new['mic_muted']:
|
||||
mic_stop_event.set()
|
||||
wake.pause()
|
||||
else:
|
||||
mic_stop_event.clear()
|
||||
if not conversation_lock.locked():
|
||||
wake.resume()
|
||||
socketio.emit('audio_settings', new)
|
||||
return jsonify(new)
|
||||
|
||||
@app.get('/api/camera/settings')
|
||||
def get_camera_settings():
|
||||
try:
|
||||
return jsonify(camera_settings.get_settings())
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/settings')
|
||||
def set_camera_settings():
|
||||
# Même garde-fou que les commandes calendrier existantes.
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'JSON invalide.'}), 400
|
||||
try:
|
||||
result = camera_settings.update_setting(data.get('control'), data.get('value'))
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/reset')
|
||||
def reset_camera_settings():
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
try:
|
||||
result = camera_settings.reset_defaults()
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.get('/api/camera/stream')
|
||||
def camera_stream():
|
||||
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
|
||||
try:
|
||||
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
|
||||
except (ImportError, ValueError, RuntimeError, OSError) as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
return Response(
|
||||
frames,
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame',
|
||||
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
|
||||
)
|
||||
|
||||
# ALFRED_FEATURES_V45 init
|
||||
alfred_features.init_app(app, ask_hermes, socketio=socketio)
|
||||
|
||||
@app.get("/")
|
||||
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
|
||||
@app.get("/api/state")
|
||||
def state():
|
||||
with STATE_LOCK:
|
||||
snapshot = dict(STATE)
|
||||
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
|
||||
|
||||
@app.post("/api/state")
|
||||
def set_state():
|
||||
data = request.get_json(force=True)
|
||||
with STATE_LOCK:
|
||||
STATE.update(data)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
return jsonify({"ok": True})
|
||||
@app.post("/api/render")
|
||||
def render_payload():
|
||||
global IMAGE_REVISION
|
||||
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||
if token and not hmac.compare_digest(
|
||||
request.headers.get("X-Alfred-Render-Token", ""), token
|
||||
):
|
||||
return jsonify({"ok": False, "error": "Non autorisé"}), 401
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
|
||||
if data.get("type") == "image":
|
||||
image_url = data.get("url")
|
||||
if not isinstance(image_url, str) or not (
|
||||
image_url.startswith(("http://", "https://", "/"))
|
||||
and not image_url.startswith("//")
|
||||
):
|
||||
return jsonify({"ok": False, "error": "URL image invalide"}), 400
|
||||
|
||||
with STATE_LOCK:
|
||||
if data.get("type") == "image":
|
||||
IMAGE_REVISION += 1
|
||||
STATE["content"] = data
|
||||
socketio.emit("alfred_render", data)
|
||||
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
|
||||
# --- Agenda / capteurs / caméras Home Assistant ---
|
||||
def _calendar_range():
|
||||
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
|
||||
now = datetime.now(tz)
|
||||
def parse(name, default):
|
||||
raw = request.args.get(name)
|
||||
if not raw:return default
|
||||
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
|
||||
if value.tzinfo is None: value = value.replace(tzinfo=tz)
|
||||
return value.astimezone(tz)
|
||||
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
|
||||
end = parse('end',start+timedelta(days=7))
|
||||
return start,end
|
||||
|
||||
def _json_error(exc):
|
||||
code = 400 if isinstance(exc,ValueError) else 502
|
||||
return jsonify({'error':str(exc)}),code
|
||||
|
||||
def _writes_enabled():
|
||||
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
|
||||
|
||||
def _same_origin_json():
|
||||
# POSTs require a custom header and same-origin browser requests.
|
||||
if request.headers.get('X-Alfred-Request') != '1':return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
|
||||
return True
|
||||
|
||||
@app.get('/api/calendar/calendars')
|
||||
def calendars_list():
|
||||
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/calendar/events')
|
||||
def calendar_events():
|
||||
try:
|
||||
start,end=_calendar_range()
|
||||
selected=request.args.getlist('calendar_id')
|
||||
return jsonify(ha_views.get_events(start,end,selected or None))
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/create')
|
||||
def calendar_create():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
def read_dt(key):
|
||||
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
|
||||
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
||||
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
|
||||
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
|
||||
str(data.get('location') or ''),bool(data.get('all_day',False)))
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_create()
|
||||
socketio.emit('calendar_changed', {'type':'created'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/delete')
|
||||
def calendar_delete():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
|
||||
start=datetime.fromisoformat(str(data['start']))
|
||||
end=datetime.fromisoformat(str(data['end']))
|
||||
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
|
||||
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
|
||||
rid=str(data.get('recurrence_id') or '')
|
||||
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
|
||||
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
|
||||
result=ha_views.delete_event(eid,uid,rid or None)
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_delete()
|
||||
socketio.emit('calendar_changed',{'type':'deleted'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather')
|
||||
def ha_weather_data():
|
||||
# Read-only route, token always stays on the Alfred server.
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
return jsonify(ha_weather.get_weather(q))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather/outfits')
|
||||
def ha_weather_outfits_data():
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
|
||||
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
|
||||
except (ha_views.HAError, ValueError, RuntimeError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperatures')
|
||||
def ha_temperatures():
|
||||
try:return jsonify({'items':ha_views.get_temperatures()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperature/history')
|
||||
def ha_temperature_history():
|
||||
try:
|
||||
eid=request.args.get('entity_id','')
|
||||
hours=int(request.args.get('hours','24'))
|
||||
return jsonify({'items':ha_views.temperature_history(eid,hours)})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/cameras')
|
||||
def ha_cameras():
|
||||
try:return jsonify({'items':ha_views.list_cameras()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
|
||||
def ha_camera_snapshot(entity_id):
|
||||
try:
|
||||
binary,mime=ha_views.camera_snapshot(entity_id)
|
||||
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/ui/home')
|
||||
def ui_home():
|
||||
with STATE_LOCK:
|
||||
STATE['content']={'type':'home','text':'À ton écoute.'}
|
||||
socketio.emit('alfred_render',STATE['content'])
|
||||
return jsonify({'ok':True})
|
||||
|
||||
@app.post("/api/audio-level")
|
||||
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
|
||||
@app.get("/api/wake/status")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
|
||||
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
|
||||
def _alfred_dashboard_quick_command(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
|
||||
def _run():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_image_revision_before = IMAGE_REVISION
|
||||
emit_state('thinking', transcript=prompt, partial='',
|
||||
content={'type':'text', 'text':prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _image_revision_before:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
if _speech and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_done = threading.Event()
|
||||
speak_async(_speech, on_end=_done.set)
|
||||
_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
except Exception as _exc:
|
||||
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':str(_exc)})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
|
||||
return True
|
||||
|
||||
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
|
||||
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_START
|
||||
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
|
||||
_alfred_register_home_glass(app)
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_END
|
||||
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_START
|
||||
from services.alfred_lists import register_lists as _alfred_register_lists
|
||||
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
|
||||
_alfred_register_lists(app, socketio)
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_GLASS_V2_BACKEND_START
|
||||
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
|
||||
_alfred_register_logs_v2(app)
|
||||
# ALFRED_GLASS_V2_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
|
||||
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
|
||||
_alfred_identity_register(app)
|
||||
|
||||
|
||||
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
|
||||
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
|
||||
_alfred_developer_register(app)
|
||||
|
||||
|
||||
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
|
||||
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
|
||||
_register_hermes_new_session(app)
|
||||
|
||||
|
||||
# DONA_FAMILY_UI_V1_START
|
||||
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
|
||||
def _dona_submit_typed(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _dona_run_typed():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_before_image = IMAGE_REVISION
|
||||
emit_state('thinking', partial='', transcript=prompt,
|
||||
content={'type': 'text', 'text': prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _before_image:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
# Deliberately no speak_async(): typed interaction is silent.
|
||||
emit_state('idle', partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
|
||||
return True
|
||||
|
||||
from services.dona_family_ui import register_dona_family as _dona_register_family
|
||||
_dona_register_family(app, adult_command=_dona_submit_typed)
|
||||
# DONA_FAMILY_UI_V1_END
|
||||
|
||||
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
|
||||
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
|
||||
_register_dona_spotify(app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"app": "/opt/alfred/app",
|
||||
"existing": [
|
||||
"app.py",
|
||||
"services/hermes_client.py",
|
||||
"templates/index.html",
|
||||
"templates/config.html"
|
||||
],
|
||||
"created": [
|
||||
"services/dona_face.py",
|
||||
"static/js/dona-face-v7.js",
|
||||
"static/js/dona-config-accordion-v7.js",
|
||||
"static/js/dona-reply-auto-close-v7.js",
|
||||
"static/css/dona-v7.css"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
|
||||
"""Pont Alfred -> Hermes avec session persistante."""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# CONFIGURATION
|
||||
# --------------------------------------------------
|
||||
|
||||
DEFAULT_IDENTITY_FILE = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "config"
|
||||
/ "identity.md"
|
||||
)
|
||||
|
||||
DEFAULT_SESSION_FILE = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "config"
|
||||
/ "hermes_session.json"
|
||||
)
|
||||
|
||||
_REQUEST_LOCK = threading.Lock()
|
||||
|
||||
_ID_PATTERN = re.compile(
|
||||
r"^[A-Za-z0-9_:.\-]{1,128}$"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# IDENTITÉ D'ALFRED
|
||||
# --------------------------------------------------
|
||||
|
||||
def _build_prompt(question: str) -> str:
|
||||
"""Send only the real user utterance. Personality is configured in Hermes SOUL.md.
|
||||
|
||||
The previous identity.md wrapper caused absent/unknown-tool decisions in
|
||||
both Spotify and Home Assistant. Never inject it into each user turn.
|
||||
"""
|
||||
return str(question or '').strip()
|
||||
|
||||
|
||||
def _toolsets_for(question: str) -> str | None:
|
||||
"""Conservative domain routing to avoid huge MCP schemas on simple turns.
|
||||
|
||||
Cross-domain, ambiguous, or generic requests retain Hermes' full toolset.
|
||||
Set HERMES_TOOLSET_ROUTING=off for instant rollback without code changes.
|
||||
"""
|
||||
if os.getenv('HERMES_TOOLSET_ROUTING', 'on').lower() not in ('on','1','true','yes'):
|
||||
return None
|
||||
q = str(question or '').casefold()
|
||||
domains = {
|
||||
'spotify': (r'\bspotify\b|\bplaylist\b|\bmusique\b|\bmorceau\b|\bchanson\b|\balbum\b|\bspotify connect\b'),
|
||||
'homeassistant': (r'\blumi[eè]res?\b|\blampes?\b|\bvolets?\b|\bdomotique\b|\bthermostat\b|\bchauffage\b|\bprises?\b|\bhome assistant\b|\ballume\b|\b[eé]teins?\b'),
|
||||
}
|
||||
import re as _re
|
||||
matching = [domain for domain, pattern in domains.items() if _re.search(pattern, q)]
|
||||
# Combined or potentially compound queries keep full access to every MCP.
|
||||
if len(matching) != 1:
|
||||
return None
|
||||
if _re.search(r'\b(et aussi|en m[eê]me temps|puis v[eé]rifie|compare avec|cherche sur|envoie|par mail|gmail|calendrier|agenda|wazuh|proxmox|comfyui|document|paperless)\b', q):
|
||||
return None
|
||||
return {'spotify':'spotify', 'homeassistant':'homeassistant,home_assistant'}[matching[0]]
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# FICHIER DE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _session_file() -> Path:
|
||||
|
||||
return Path(
|
||||
os.getenv(
|
||||
"HERMES_SESSION_FILE",
|
||||
str(DEFAULT_SESSION_FILE)
|
||||
)
|
||||
).expanduser()
|
||||
|
||||
|
||||
def _read_session(path: Path) -> str | None:
|
||||
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
obj = json.loads(
|
||||
path.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
sid = obj["session_id"]
|
||||
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError
|
||||
) as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
f"État de session Hermes invalide "
|
||||
f"({path}) : {exc}"
|
||||
) from exc
|
||||
|
||||
if (
|
||||
not isinstance(sid, str)
|
||||
or not _ID_PATTERN.fullmatch(sid)
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Identifiant de session Hermes "
|
||||
f"invalide dans {path}"
|
||||
)
|
||||
|
||||
return sid
|
||||
|
||||
|
||||
def _save_session(path: Path, sid: str):
|
||||
|
||||
if not _ID_PATTERN.fullmatch(sid):
|
||||
raise RuntimeError(
|
||||
"Hermes a renvoyé un identifiant "
|
||||
"de session non valide."
|
||||
)
|
||||
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
temp_path = None
|
||||
|
||||
try:
|
||||
fd, temp_name = tempfile.mkstemp(
|
||||
prefix=".hermes_session_",
|
||||
suffix=".tmp",
|
||||
dir=path.parent
|
||||
)
|
||||
|
||||
temp_path = Path(temp_name)
|
||||
|
||||
with os.fdopen(
|
||||
fd,
|
||||
"w",
|
||||
encoding="utf-8"
|
||||
) as handle:
|
||||
|
||||
json.dump(
|
||||
{"session_id": sid},
|
||||
handle,
|
||||
indent=2
|
||||
)
|
||||
|
||||
handle.write("\n")
|
||||
|
||||
os.replace(temp_path, path)
|
||||
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# VERROUILLAGE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def _session_lock(path: Path):
|
||||
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
lock_path = path.with_name(
|
||||
path.name + ".lock"
|
||||
)
|
||||
|
||||
fd = os.open(
|
||||
lock_path,
|
||||
os.O_CREAT | os.O_RDWR,
|
||||
0o600
|
||||
)
|
||||
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
|
||||
fcntl.flock(
|
||||
handle.fileno(),
|
||||
fcntl.LOCK_EX
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
|
||||
finally:
|
||||
fcntl.flock(
|
||||
handle.fileno(),
|
||||
fcntl.LOCK_UN
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# CONFIGURATION HERMES
|
||||
# --------------------------------------------------
|
||||
|
||||
def _hermes_binary() -> str:
|
||||
|
||||
return os.getenv(
|
||||
"HERMES_CLI",
|
||||
"/root/.local/bin/hermes"
|
||||
)
|
||||
|
||||
|
||||
def _hermes_timeout() -> float:
|
||||
|
||||
return max(
|
||||
10.0,
|
||||
float(
|
||||
os.getenv("HERMES_TIMEOUT", "180")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# ANCIEN MODE SANS SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _oneshot_legacy(prompt: str) -> str:
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
_hermes_binary(),
|
||||
"--oneshot",
|
||||
prompt
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_hermes_timeout(),
|
||||
check=True
|
||||
)
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas répondu "
|
||||
"dans le délai imparti."
|
||||
) from exc
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
|
||||
details = (
|
||||
exc.stderr
|
||||
or exc.stdout
|
||||
or "aucun détail"
|
||||
).strip()
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes a échoué "
|
||||
f"(code {exc.returncode}): {details}"
|
||||
) from exc
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# INTERPRÉTATION RÉPONSE JSONL
|
||||
# --------------------------------------------------
|
||||
|
||||
def _parse_jsonl(stdout: str) -> tuple[str, str]:
|
||||
|
||||
final = None
|
||||
initial_session_id = None
|
||||
|
||||
for line in stdout.splitlines():
|
||||
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
event = json.loads(line)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
if (
|
||||
event.get("type") == "system"
|
||||
and event.get("subtype") == "init"
|
||||
):
|
||||
initial_session_id = event.get(
|
||||
"session_id"
|
||||
)
|
||||
|
||||
if event.get("type") == "result":
|
||||
final = event
|
||||
|
||||
if final is None:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas renvoyé de résultat "
|
||||
"JSONL. Vérifie la prise en charge "
|
||||
"de --format stream-json."
|
||||
)
|
||||
|
||||
if final.get("exit_code", 0) != 0:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes : "
|
||||
f"{final.get('error') or 'échec'}"
|
||||
)
|
||||
|
||||
sid = (
|
||||
final.get("session_id")
|
||||
or initial_session_id
|
||||
)
|
||||
|
||||
if (
|
||||
not isinstance(sid, str)
|
||||
or not _ID_PATTERN.fullmatch(sid)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas fourni "
|
||||
"d'identifiant de session utilisable."
|
||||
)
|
||||
|
||||
answer = final.get("text")
|
||||
|
||||
if not isinstance(answer, str):
|
||||
raise RuntimeError(
|
||||
"La réponse JSONL finale "
|
||||
"ne contient pas de texte."
|
||||
)
|
||||
|
||||
return answer.strip(), sid
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# APPEL HERMES AVEC REPRISE DE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _call_hermes(
|
||||
prompt: str,
|
||||
session_id: str | None
|
||||
) -> tuple[str, str]:
|
||||
|
||||
cmd = [
|
||||
_hermes_binary(),
|
||||
"chat",
|
||||
"--oneshot",
|
||||
"--query-file",
|
||||
"-",
|
||||
"--format",
|
||||
"stream-json",
|
||||
"--source",
|
||||
"cli"
|
||||
]
|
||||
|
||||
route = _toolsets_for(prompt)
|
||||
if route:
|
||||
cmd.extend(["--toolsets", route])
|
||||
|
||||
if session_id:
|
||||
cmd.extend([
|
||||
"--resume",
|
||||
session_id
|
||||
])
|
||||
|
||||
try:
|
||||
process = subprocess.run(
|
||||
cmd,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_hermes_timeout(),
|
||||
check=False
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas répondu "
|
||||
"dans le délai imparti."
|
||||
) from exc
|
||||
|
||||
except FileNotFoundError as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes introuvable : "
|
||||
f"{_hermes_binary()}"
|
||||
) from exc
|
||||
|
||||
if process.returncode != 0:
|
||||
|
||||
msg = (
|
||||
process.stderr
|
||||
or process.stdout
|
||||
or "aucun détail"
|
||||
).strip()
|
||||
|
||||
if len(msg) > 1500:
|
||||
msg = msg[-1500:]
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes a échoué "
|
||||
f"(code {process.returncode}) : "
|
||||
f"{msg}"
|
||||
)
|
||||
|
||||
return _parse_jsonl(process.stdout)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# FONCTION PRINCIPALE APPELÉE PAR FLASK
|
||||
# --------------------------------------------------
|
||||
|
||||
def ask_hermes(text: str) -> str:
|
||||
|
||||
prompt = _build_prompt(text)
|
||||
|
||||
# Ancien fonctionnement sans session
|
||||
if (
|
||||
os.getenv(
|
||||
"HERMES_SESSION_MODE",
|
||||
"persistent"
|
||||
).lower() == "legacy"
|
||||
):
|
||||
return _oneshot_legacy(prompt)
|
||||
|
||||
path = _session_file()
|
||||
|
||||
# Empêche les appels concurrents
|
||||
with _REQUEST_LOCK, _session_lock(path):
|
||||
|
||||
# Recherche la session précédente
|
||||
current_sid = _read_session(path)
|
||||
|
||||
# Envoie la question à Hermes
|
||||
answer, new_sid = _call_hermes(
|
||||
prompt,
|
||||
current_sid
|
||||
)
|
||||
|
||||
# Sauvegarde l'identifiant
|
||||
_save_session(
|
||||
path,
|
||||
new_sid
|
||||
)
|
||||
|
||||
print(
|
||||
f"[ALFRED] Session Hermes : "
|
||||
f"{new_sid} "
|
||||
f"({'reprise' if current_sid else 'création'})",
|
||||
flush=True
|
||||
)
|
||||
|
||||
return answer
|
||||
@@ -0,0 +1,120 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#121b25"><title>Alfred · Configuration</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-admin.css">
|
||||
<script defer src="/static/js/alfred-admin.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
|
||||
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
|
||||
</head>
|
||||
<body data-page="config">
|
||||
<div class="admin-layout">
|
||||
<header class="admin-header">
|
||||
<div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div>
|
||||
<nav aria-label="Navigation"><a href="/">Accueil</a><a class="selected" href="/config">Configuration</a><a href="/surveillance">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav>
|
||||
</header>
|
||||
<main class="admin-main" id="mainArea" hidden>
|
||||
<div class="page-top"><div><p class="eyebrow">PERSONNALISATION</p><h1>Configuration</h1><p class="muted">Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.</p></div><div class="page-actions"><span id="saveState" class="tag">Aucun changement</span><button id="saveConfig" class="button primary" type="button">Enregistrer</button></div></div>
|
||||
<section class="notice" id="pendingNotice" hidden>Réglages enregistrés. <strong>Redémarre Alfred</strong> pour les appliquer.</section>
|
||||
<div class="two-col">
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">✦</span><div><h2>Identité et voix</h2><p>Nom affiché, mot d'activation et voix de synthèse</p></div></div>
|
||||
<div class="field"><label for="ALFRED_NAME">Nom de l’assistant</label><input id="ALFRED_NAME" data-setting placeholder="Alfred" maxlength="30"><small>Visible dans l’interface après redémarrage.</small></div>
|
||||
<div class="field"><label for="WAKE_WORD">Mot de réveil (Vosk)</label><input id="WAKE_WORD" data-setting placeholder="alfred" maxlength="22"><small>Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.</small></div>
|
||||
<div class="field"><label for="PIPER_VOICE">Voix Wyoming Piper</label><input id="PIPER_VOICE" data-setting placeholder="fr_FR-tom-medium"><small>Exemple : fr_FR-tom-medium. La voix doit exister sur ton serveur Wyoming ; pour Piper local, c’est le fichier modèle qui détermine la voix.</small></div>
|
||||
<div class="inline"><button class="button ghost" type="button" id="testVoice">Tester la voix actuelle</button><span class="muted microtext">Utilise les réglages déjà actifs, sans redémarrage</span></div>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">☁</span><div><h2>Illustrations météo</h2><p>Qualité, visages de référence et accessoires ComfyUI</p></div></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS">Essais par personnage</label><select id="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS" data-setting><option value="1">1 — Rapide</option><option value="2">2 — Équilibré</option><option value="3">3 — Qualité</option></select></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE">Score qualité cible</label><div class="range-field"><input type="range" min="5" max="10" step="0.1" id="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" data-setting><output for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" id="scoreOut">8.6 / 10</output></div></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_DISPLAY_SIZE">Taille d’affichage</label><select id="ALFRED_WEATHER_DISPLAY_SIZE" data-setting><option value="250">250 px</option><option value="300">300 px</option><option value="350">350 px</option><option value="400">400 px</option><option value="450">450 px</option><option value="500">500 px</option></select></div>
|
||||
<div class="field switch-field"><label for="ALFRED_WEATHER_REVIEW">Analyse automatique des candidats <small>Nécessite un workflow vision compatible</small></label><input id="ALFRED_WEATHER_REVIEW" type="checkbox" data-setting></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_MODE">Références des visages</label><select id="ALFRED_WEATHER_REFERENCE_MODE" data-setting><option value="auto">Auto — avec repli</option><option value="required">Obligatoires</option><option value="off">Désactivées</option></select></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_DENOISE">Liberté de modification des vêtements</label><div class="range-field"><input id="ALFRED_WEATHER_REFERENCE_DENOISE" data-setting type="range" min="0.25" max="0.85" step="0.01"><output id="denoiseOut">0.56</output></div><small>Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.</small></div>
|
||||
<div class="field switch-field"><label for="ALFRED_WEATHER_PRESERVE_FACE">Préserver les visages <small>Lorsque l’image de référence est utilisable</small></label><input id="ALFRED_WEATHER_PRESERVE_FACE" type="checkbox" data-setting></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_ACCESSORY_LIMIT">Accessoires visibles par personnage</label><select id="ALFRED_WEATHER_ACCESSORY_LIMIT" data-setting><option value="0">Aucun</option><option value="1">1 — Recommandé</option><option value="2">2</option></select></div>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">♫</span><div><h2>Audio en direct</h2><p>Volume, sensibilité et coupures immédiates</p></div></div>
|
||||
<div class="field"><label for="liveVolume">Volume de sortie</label><div class="range-field"><input id="liveVolume" type="range" min="0" max="100" step="1"><output id="liveVolumeOut">—</output></div></div>
|
||||
<div class="field"><label for="liveMic">Sensibilité micro</label><div class="range-field"><input id="liveMic" type="range" min="0" max="100" step="1"><output id="liveMicOut">—</output></div></div>
|
||||
<div class="field switch-field"><label for="muteMicro">Microphone coupé</label><input id="muteMicro" type="checkbox"></div>
|
||||
<div class="field switch-field"><label for="muteSpeaker">Haut-parleur coupé</label><input id="muteSpeaker" type="checkbox"></div>
|
||||
<p class="muted microtext" id="audioResult">Les réglages audio sont appliqués sans redémarrage.</p>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">⌘</span><div><h2>Connexions et météo</h2><p>Adresses des serveurs et source météo</p></div></div>
|
||||
<div class="field"><label for="ALFRED_COMFYUI_URL">Serveur ComfyUI</label><input data-setting id="ALFRED_COMFYUI_URL" placeholder="http://192.168.1.13:8188"><small>Laisse vide pour conserver la configuration Hermes existante.</small></div>
|
||||
<div class="field"><label for="ALFRED_OLLAMA_URL">Serveur Ollama (surveillance)</label><input data-setting id="ALFRED_OLLAMA_URL" placeholder="http://192.168.1.13:11434"><small>Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.</small></div>
|
||||
<div class="field"><label for="HA_WEATHER_ENTITY">Entité météo Home Assistant</label><input data-setting id="HA_WEATHER_ENTITY" placeholder="weather.forecast_home"><small>Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.</small></div>
|
||||
<a href="/surveillance" class="button ghost inline-link">Voir la surveillance des services</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : reconnaissance vocale distante -->
|
||||
<section class="panel admin-v2-stt-panel">
|
||||
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2>Reconnaissance vocale (STT)</h2><p>Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage</p></div></div>
|
||||
<div class="field"><label for="STT_ENGINE">Moteur de transcription final</label>
|
||||
<select id="STT_ENGINE" data-setting><option value="vosk">Vosk local (mode historique)</option><option value="wyoming">Whisper Wyoming distant (conseillé)</option></select>
|
||||
<small>Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.</small></div>
|
||||
<div class="admin-v2-form-grid">
|
||||
<div class="field"><label for="WYOMING_WHISPER_HOST">Serveur Wyoming Whisper</label><input id="WYOMING_WHISPER_HOST" data-setting placeholder="192.168.1.13" autocomplete="off"><small>Adresse IP ou nom DNS, sans http://</small></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_PORT">Port TCP Wyoming</label><input type="number" min="1" max="65535" id="WYOMING_WHISPER_PORT" data-setting placeholder="10300"></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_LANGUAGE">Langue</label><input id="WYOMING_WHISPER_LANGUAGE" data-setting maxlength="2" placeholder="fr"></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_TIMEOUT">Délai maximum transcription (s)</label><input id="WYOMING_WHISPER_TIMEOUT" type="number" min="5" max="90" step="0.5" data-setting></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_CONNECT_TIMEOUT">Délai de connexion (s)</label><input id="WYOMING_WHISPER_CONNECT_TIMEOUT" type="number" min="1" max="15" step="0.5" data-setting></div>
|
||||
<div class="field"><label for="STT_MAX_AUDIO_SECONDS">Durée audio maximale (s)</label><input id="STT_MAX_AUDIO_SECONDS" type="number" min="5" max="60" step="1" data-setting></div>
|
||||
</div>
|
||||
<p class="admin-v2-help">Le modèle Whisper (<strong>small</strong> actuellement) et son vocabulaire technique (<code>--initial-prompt</code> pour Proxmox, Wazuh…) se configurent <strong>dans le conteneur Docker distant</strong>, pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.</p>
|
||||
</section>
|
||||
|
||||
<!-- ALFRED_DUPLEX_BARGE_IN_V1 -->
|
||||
<section class="panel" id="alfredDuplexPanel">
|
||||
<div class="panel-title"><span class="panel-symbol">≋</span><div>
|
||||
<h2>Interruption vocale (duplex)</h2>
|
||||
<p>Couper la parole à l'assistant pendant la lecture Piper</p>
|
||||
</div></div>
|
||||
<div class="field"><label for="DUPLEX_MODE">Mode d'interruption</label>
|
||||
<select id="DUPLEX_MODE" data-setting>
|
||||
<option value="off">Désactivé (comportement actuel)</option>
|
||||
<option value="wake">Avec le nom d'activation — recommandé</option>
|
||||
<option value="natural">Naturelle sans le nom — expérimental</option>
|
||||
</select>
|
||||
<small>En mode « avec le nom », prononce le mot de réveil configuré (Dona, si c'est ton WAKE_WORD). En mode naturel, un début de parole distinct du TTS peut interrompre la réponse.</small>
|
||||
</div>
|
||||
<div class="field"><label for="DUPLEX_MIC_THRESHOLD">Seuil micro (sensibilité)</label>
|
||||
<input type="number" id="DUPLEX_MIC_THRESHOLD" data-setting min="0.008" max="0.30" step="0.005" placeholder="0.025">
|
||||
<small>Augmenter pour limiter les déclenchements intempestifs ; valeur initiale 0.025.</small>
|
||||
</div>
|
||||
<!-- ALFRED_DUPLEX_CONVERSATION_V2 -->
|
||||
<div class="field switch-field"><label for="DUPLEX_STOP_COMMANDS">Commandes « Stop », « Silence », « Arrête »
|
||||
<small>Coupe uniquement Piper, sans nom d’activation ni annulation des tâches Hermes.</small></label>
|
||||
<input id="DUPLEX_STOP_COMMANDS" type="checkbox" data-setting></div>
|
||||
<div class="field switch-field"><label for="DUPLEX_HANDOFF">Nouvelle question pendant la réponse
|
||||
<small>Interromps Dona et continue avec une question sans devoir répéter le nom.</small></label>
|
||||
<input id="DUPLEX_HANDOFF" type="checkbox" data-setting></div>
|
||||
<div class="field switch-field"><label for="DUPLEX_CONVERSATION">Mode Conversation
|
||||
<small>Après une réponse Piper, une courte fenêtre permet de poser une autre question.</small></label>
|
||||
<input id="DUPLEX_CONVERSATION" type="checkbox" data-setting></div>
|
||||
<div class="field"><label for="DUPLEX_CONVERSATION_SECONDS">Fenêtre de conversation (secondes)</label>
|
||||
<input id="DUPLEX_CONVERSATION_SECONDS" type="number" min="2" max="15" step="1" data-setting>
|
||||
<small>6 s recommandées. Pour fermer cette fenêtre, attends simplement quelques secondes.</small></div>
|
||||
<p class="muted microtext">Anti-écho : confirmation acoustique, comparaison avec les paroles Piper et délai de garde. Ceci ne remplace pas un vrai AEC ; le mode naturel reste expérimental.</p>
|
||||
<p class="muted" style="margin-top:12px">⚠ Le mode naturel exige idéalement un microphone avec annulation d'écho acoustique. Le simple filtrage logiciel ne remplace pas un AEC. Le retour à l'écoute prend effet après l'interruption ; attends que l'assistant soit prêt avant la nouvelle question. Les réglages nécessitent un redémarrage.</p>
|
||||
</section>
|
||||
<section class="panel restart-panel"><div><h2>Maintenance</h2><p class="muted">Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.</p><span class="microtext muted">Le bouton requiert une autorisation systemd explicitement activée.</span></div><button class="button danger" type="button" id="restartButton">Redémarrer Alfred</button></section>
|
||||
<p role="status" aria-live="polite" class="message" id="message"></p>
|
||||
</main>
|
||||
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour modifier la configuration d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
|
||||
</div>
|
||||
<script src="/static/js/alfred-admin-glass.js?v=1"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,225 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
|
||||
<!-- ALFRED_MAIN_VISUAL_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
|
||||
<!-- ALFRED_LISTES_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
|
||||
<!-- ALFRED_GLASS_V2 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
|
||||
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
|
||||
<!-- ALFRED_REPONSE_CLOSE_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
|
||||
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
|
||||
<!-- DONA_FAMILY_UI_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
|
||||
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
|
||||
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
|
||||
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
|
||||
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
|
||||
<div id="alfredTimersList"></div>
|
||||
</aside>
|
||||
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
|
||||
|
||||
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
|
||||
aria-modal="true" hidden>
|
||||
<article class="alfred-context-card">
|
||||
<button id="alfredContextClose" class="alfred-context-close" type="button"
|
||||
aria-label="Fermer l'illustration" title="Fermer">×</button>
|
||||
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
|
||||
<div class="alfred-context-details">
|
||||
<h2 id="alfredContextTitle"></h2>
|
||||
<p id="alfredContextText"></p>
|
||||
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<script src="/static/js/alfred-timers-context.js"></script>
|
||||
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
|
||||
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
|
||||
<script src="/static/js/alfred-main-v1.js?v=1"></script>
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<script src="/static/js/alfred-maison-card.js?v=1"></script>
|
||||
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
|
||||
<script src="/static/js/alfred-main-v2.js?v=2"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
|
||||
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
|
||||
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
|
||||
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
|
||||
<script defer src="/static/js/alfred-reply-auto-close.js?v=1"></script>
|
||||
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
|
||||
<script defer src="/static/js/dona-spotify.js?v=6"></script>
|
||||
<!-- dona-ui-fix start -->
|
||||
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
|
||||
<!-- dona-ui-fix end -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,711 @@
|
||||
|
||||
import os, threading, hmac
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Flask, render_template, request, jsonify, Response
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger la configuration AVANT les imports des services
|
||||
load_dotenv("/opt/alfred/.env")
|
||||
load_dotenv(
|
||||
"/opt/alfred/app/config/control_center.env",
|
||||
override=True
|
||||
)
|
||||
|
||||
from services.audio import listen_once, speak_async, tts_backend_detect
|
||||
from services.wakeword import WakeWordListener
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
from services.piper_tts import stop_playback as duplex_stop_piper
|
||||
from services import admin_console # ALFRED_ADMIN_PAGES_V1
|
||||
from services.hermes_client import ask_hermes
|
||||
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
|
||||
from services import alfred_features # ALFRED_FEATURES_V45
|
||||
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
||||
from services.camera import video_stream
|
||||
from services import ha_views, alfred_views_controller
|
||||
from services import camera_settings, ha_weather, weather_outfits
|
||||
|
||||
load_dotenv()
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
|
||||
##
|
||||
from services.audio import get_vosk_model
|
||||
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
|
||||
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
|
||||
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
|
||||
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||
##
|
||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
||||
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||
STATE_LOCK = threading.RLock()
|
||||
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
|
||||
conversation_lock=threading.Lock()
|
||||
mic_stop_event=threading.Event()
|
||||
# Service temps autonome (survit aux conversations Hermes)
|
||||
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
|
||||
audio_settings.initialize()
|
||||
audio_settings.update_settings({"mic_gain": 30})
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
|
||||
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
|
||||
on_interrupt=duplex_stop_piper,
|
||||
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
|
||||
|
||||
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
|
||||
camera_settings.restore_saved_settings()
|
||||
|
||||
def emit_state(mode=None, **extra):
|
||||
with STATE_LOCK:
|
||||
if mode: STATE["mode"] = mode
|
||||
STATE.update(extra)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return
|
||||
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
|
||||
try:
|
||||
emit_state("listening", partial="", transcript="", wake_score=score)
|
||||
|
||||
print("[ALFRED] Début écoute STT", flush=True)
|
||||
|
||||
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
from services.audio import STTResult
|
||||
r = STTResult(text=injected_text)
|
||||
else:
|
||||
r = listen_once(
|
||||
stop_event=mic_stop_event,
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
|
||||
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
|
||||
|
||||
if audio_settings.mic_muted():
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
if r.error:
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": r.error}
|
||||
)
|
||||
return
|
||||
|
||||
if not r.text:
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
emit_state(
|
||||
"thinking",
|
||||
partial="",
|
||||
transcript=r.text,
|
||||
content={"type": "text", "text": r.text}
|
||||
)
|
||||
|
||||
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
|
||||
if _dona_dynamic.is_dynamic_request(r.text):
|
||||
_board = _dona_dynamic.build_dashboard(r.text, ask_hermes)
|
||||
_payload = {'type':'dona_dynamic', 'dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _payload
|
||||
socketio.emit('alfred_render', _payload)
|
||||
if not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_finished = threading.Event()
|
||||
_started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set)
|
||||
if _started: _finished.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
|
||||
# Vue dynamique : commandes déterministes exécutées sans faire
|
||||
# dépendre l'affichage des capacités ou sessions Hermes.
|
||||
visual = None
|
||||
try:
|
||||
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
|
||||
visual = ({"type":"text", "text":str(exc)}, str(exc))
|
||||
|
||||
if visual is not None:
|
||||
content, speech = visual
|
||||
with STATE_LOCK:
|
||||
STATE["content"] = content
|
||||
socketio.emit("alfred_render", content)
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
done.wait(timeout=120)
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_START
|
||||
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
|
||||
try:
|
||||
_lists_reply = _alfred_handle_list_voice(
|
||||
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
|
||||
except Exception as _lists_error:
|
||||
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
|
||||
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
|
||||
if _lists_reply is not None:
|
||||
_lists_display = {'type':'text', 'text':_lists_reply}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _lists_display
|
||||
socketio.emit('alfred_render', _lists_display)
|
||||
if _lists_reply and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_lists_done = threading.Event()
|
||||
speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
_lists_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_END
|
||||
|
||||
print("[ALFRED] Envoi à Hermes", flush=True)
|
||||
# ComfyUI publie l'image pendant cet appel Hermes.
|
||||
with STATE_LOCK:
|
||||
image_revision_before = IMAGE_REVISION
|
||||
# ALFRED_FEATURES_V45 voice opt-in background mode
|
||||
if alfred_features.should_run_in_background(r.text):
|
||||
try:
|
||||
alfred_features.submit(r.text, source='voice')
|
||||
emit_state('idle', partial='', content={
|
||||
'type': 'text',
|
||||
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
|
||||
})
|
||||
except ValueError as exc:
|
||||
emit_state('error', content={'type': 'text', 'text': str(exc)})
|
||||
return
|
||||
# DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal.
|
||||
answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(r.text)
|
||||
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
|
||||
if isinstance(answer, dict):
|
||||
speech = str(answer.get("speech") or answer.get("text") or "")
|
||||
display = answer.get("display") or {
|
||||
"type": "text",
|
||||
"text": speech
|
||||
}
|
||||
else:
|
||||
speech = str(answer or "")
|
||||
display = {"type": "text", "text": speech}
|
||||
|
||||
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
|
||||
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
|
||||
alfred_context_images.schedule_context_image(
|
||||
r.text, speech, socketio,
|
||||
lambda: STATE.get("transcript") == r.text,
|
||||
) # ALFRED_TIMERS_CONTEXT_V1
|
||||
|
||||
# Si le MCP ComfyUI a affiché une image pendant la requête,
|
||||
# ne pas l'écraser par la réponse textuelle d'Hermes.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == image_revision_before:
|
||||
STATE["content"] = display
|
||||
alfred_features.publish_voice_display(display)
|
||||
socketio.emit("alfred_render", display)
|
||||
else:
|
||||
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
|
||||
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
try:
|
||||
started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
if started:
|
||||
done.wait(timeout=120)
|
||||
finally:
|
||||
if not duplex_monitor.interrupted():
|
||||
duplex_monitor.stop()
|
||||
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
|
||||
if duplex_monitor.interrupted():
|
||||
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
|
||||
duplex_monitor.stop()
|
||||
else:
|
||||
# A few wake-free seconds after the assistant finishes speaking.
|
||||
pending_duplex_text = duplex_monitor.listen_conversation()
|
||||
if pending_duplex_text:
|
||||
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
|
||||
|
||||
emit_state("idle", partial="")
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": str(exc)}
|
||||
)
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
# Only the voice is cancelled; the original Hermes job is untouched.
|
||||
wake.pause()
|
||||
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
|
||||
daemon=True, name='dona-duplex-followup').start()
|
||||
elif not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
print("[ALFRED] Wake word réactivé", flush=True)
|
||||
|
||||
|
||||
|
||||
def on_wake(score):
|
||||
if audio_settings.mic_muted():
|
||||
return
|
||||
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
|
||||
threading.Thread(target=conversation,args=(score,),daemon=True).start()
|
||||
|
||||
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
|
||||
|
||||
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
|
||||
@app.get('/api/duplex/status')
|
||||
def duplex_status():
|
||||
return jsonify(duplex_monitor.status())
|
||||
|
||||
@app.get('/api/audio/settings')
|
||||
def get_audio_settings():
|
||||
return jsonify(audio_settings.get_settings())
|
||||
|
||||
@app.post('/api/audio/settings')
|
||||
def set_audio_settings():
|
||||
data = request.get_json(silent=True)
|
||||
try:
|
||||
old = audio_settings.get_settings()
|
||||
new = audio_settings.update_settings(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'ALSA : {exc}'}), 500
|
||||
if new['mic_muted'] != old['mic_muted']:
|
||||
if new['mic_muted']:
|
||||
mic_stop_event.set()
|
||||
wake.pause()
|
||||
else:
|
||||
mic_stop_event.clear()
|
||||
if not conversation_lock.locked():
|
||||
wake.resume()
|
||||
socketio.emit('audio_settings', new)
|
||||
return jsonify(new)
|
||||
|
||||
@app.get('/api/camera/settings')
|
||||
def get_camera_settings():
|
||||
try:
|
||||
return jsonify(camera_settings.get_settings())
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/settings')
|
||||
def set_camera_settings():
|
||||
# Même garde-fou que les commandes calendrier existantes.
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'JSON invalide.'}), 400
|
||||
try:
|
||||
result = camera_settings.update_setting(data.get('control'), data.get('value'))
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/reset')
|
||||
def reset_camera_settings():
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
try:
|
||||
result = camera_settings.reset_defaults()
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.get('/api/camera/stream')
|
||||
def camera_stream():
|
||||
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
|
||||
try:
|
||||
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
|
||||
except (ImportError, ValueError, RuntimeError, OSError) as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
return Response(
|
||||
frames,
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame',
|
||||
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
|
||||
)
|
||||
|
||||
# ALFRED_FEATURES_V45 init
|
||||
alfred_features.init_app(app, ask_hermes, socketio=socketio)
|
||||
|
||||
@app.get("/")
|
||||
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
|
||||
@app.get("/api/state")
|
||||
def state():
|
||||
with STATE_LOCK:
|
||||
snapshot = dict(STATE)
|
||||
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
|
||||
|
||||
@app.post("/api/state")
|
||||
def set_state():
|
||||
data = request.get_json(force=True)
|
||||
with STATE_LOCK:
|
||||
STATE.update(data)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
return jsonify({"ok": True})
|
||||
@app.post("/api/render")
|
||||
def render_payload():
|
||||
global IMAGE_REVISION
|
||||
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||
if token and not hmac.compare_digest(
|
||||
request.headers.get("X-Alfred-Render-Token", ""), token
|
||||
):
|
||||
return jsonify({"ok": False, "error": "Non autorisé"}), 401
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
|
||||
if data.get("type") == "image":
|
||||
image_url = data.get("url")
|
||||
if not isinstance(image_url, str) or not (
|
||||
image_url.startswith(("http://", "https://", "/"))
|
||||
and not image_url.startswith("//")
|
||||
):
|
||||
return jsonify({"ok": False, "error": "URL image invalide"}), 400
|
||||
|
||||
with STATE_LOCK:
|
||||
if data.get("type") == "image":
|
||||
IMAGE_REVISION += 1
|
||||
STATE["content"] = data
|
||||
socketio.emit("alfred_render", data)
|
||||
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
|
||||
# --- Agenda / capteurs / caméras Home Assistant ---
|
||||
def _calendar_range():
|
||||
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
|
||||
now = datetime.now(tz)
|
||||
def parse(name, default):
|
||||
raw = request.args.get(name)
|
||||
if not raw:return default
|
||||
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
|
||||
if value.tzinfo is None: value = value.replace(tzinfo=tz)
|
||||
return value.astimezone(tz)
|
||||
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
|
||||
end = parse('end',start+timedelta(days=7))
|
||||
return start,end
|
||||
|
||||
def _json_error(exc):
|
||||
code = 400 if isinstance(exc,ValueError) else 502
|
||||
return jsonify({'error':str(exc)}),code
|
||||
|
||||
def _writes_enabled():
|
||||
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
|
||||
|
||||
def _same_origin_json():
|
||||
# POSTs require a custom header and same-origin browser requests.
|
||||
if request.headers.get('X-Alfred-Request') != '1':return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
|
||||
return True
|
||||
|
||||
@app.get('/api/calendar/calendars')
|
||||
def calendars_list():
|
||||
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/calendar/events')
|
||||
def calendar_events():
|
||||
try:
|
||||
start,end=_calendar_range()
|
||||
selected=request.args.getlist('calendar_id')
|
||||
return jsonify(ha_views.get_events(start,end,selected or None))
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/create')
|
||||
def calendar_create():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
def read_dt(key):
|
||||
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
|
||||
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
||||
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
|
||||
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
|
||||
str(data.get('location') or ''),bool(data.get('all_day',False)))
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_create()
|
||||
socketio.emit('calendar_changed', {'type':'created'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/delete')
|
||||
def calendar_delete():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
|
||||
start=datetime.fromisoformat(str(data['start']))
|
||||
end=datetime.fromisoformat(str(data['end']))
|
||||
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
|
||||
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
|
||||
rid=str(data.get('recurrence_id') or '')
|
||||
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
|
||||
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
|
||||
result=ha_views.delete_event(eid,uid,rid or None)
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_delete()
|
||||
socketio.emit('calendar_changed',{'type':'deleted'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather')
|
||||
def ha_weather_data():
|
||||
# Read-only route, token always stays on the Alfred server.
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
return jsonify(ha_weather.get_weather(q))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather/outfits')
|
||||
def ha_weather_outfits_data():
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
|
||||
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
|
||||
except (ha_views.HAError, ValueError, RuntimeError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperatures')
|
||||
def ha_temperatures():
|
||||
try:return jsonify({'items':ha_views.get_temperatures()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperature/history')
|
||||
def ha_temperature_history():
|
||||
try:
|
||||
eid=request.args.get('entity_id','')
|
||||
hours=int(request.args.get('hours','24'))
|
||||
return jsonify({'items':ha_views.temperature_history(eid,hours)})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/cameras')
|
||||
def ha_cameras():
|
||||
try:return jsonify({'items':ha_views.list_cameras()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
|
||||
def ha_camera_snapshot(entity_id):
|
||||
try:
|
||||
binary,mime=ha_views.camera_snapshot(entity_id)
|
||||
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/ui/home')
|
||||
def ui_home():
|
||||
with STATE_LOCK:
|
||||
STATE['content']={'type':'home','text':'À ton écoute.'}
|
||||
socketio.emit('alfred_render',STATE['content'])
|
||||
return jsonify({'ok':True})
|
||||
|
||||
@app.post("/api/audio-level")
|
||||
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
|
||||
@app.get("/api/wake/status")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
|
||||
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
|
||||
def _alfred_dashboard_quick_command(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
|
||||
def _run():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_image_revision_before = IMAGE_REVISION
|
||||
emit_state('thinking', transcript=prompt, partial='',
|
||||
content={'type':'text', 'text':prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _image_revision_before:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
if _speech and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_done = threading.Event()
|
||||
speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
except Exception as _exc:
|
||||
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':str(_exc)})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
|
||||
return True
|
||||
|
||||
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
|
||||
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_START
|
||||
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
|
||||
_alfred_register_home_glass(app)
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_END
|
||||
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_START
|
||||
from services.alfred_lists import register_lists as _alfred_register_lists
|
||||
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
|
||||
_alfred_register_lists(app, socketio)
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_GLASS_V2_BACKEND_START
|
||||
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
|
||||
_alfred_register_logs_v2(app)
|
||||
# ALFRED_GLASS_V2_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
|
||||
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
|
||||
_alfred_identity_register(app)
|
||||
|
||||
|
||||
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
|
||||
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
|
||||
_alfred_developer_register(app)
|
||||
|
||||
|
||||
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
|
||||
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
|
||||
_register_hermes_new_session(app)
|
||||
|
||||
|
||||
# DONA_FAMILY_UI_V1_START
|
||||
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
|
||||
def _dona_submit_typed(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _dona_run_typed():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_before_image = IMAGE_REVISION
|
||||
emit_state('thinking', partial='', transcript=prompt,
|
||||
content={'type': 'text', 'text': prompt})
|
||||
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
|
||||
if _dona_dynamic.is_dynamic_request(prompt):
|
||||
_board = _dona_dynamic.build_dashboard(prompt, ask_hermes)
|
||||
_payload = {'type':'dona_dynamic','dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _payload
|
||||
socketio.emit('alfred_render',_payload)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _before_image:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
# Deliberately no speak_async(): typed interaction is silent.
|
||||
emit_state('idle', partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
|
||||
return True
|
||||
|
||||
from services.dona_family_ui import register_dona_family as _dona_register_family
|
||||
_dona_register_family(app, adult_command=_dona_submit_typed)
|
||||
# DONA_FAMILY_UI_V1_END
|
||||
|
||||
# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés.
|
||||
from services import dona_dynamic as _dona_dynamic
|
||||
|
||||
def _dona_dynamic_submit(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _work():
|
||||
try:
|
||||
wake.pause()
|
||||
emit_state('thinking',transcript=prompt,partial='')
|
||||
_board=_dona_dynamic.build_dashboard(prompt,ask_hermes)
|
||||
_payload={'type':'dona_dynamic','dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content']=_payload
|
||||
socketio.emit('alfred_render',_payload)
|
||||
emit_state('idle',partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True)
|
||||
emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start()
|
||||
return True
|
||||
|
||||
_dona_dynamic.register_dynamic(app,_dona_dynamic_submit)
|
||||
|
||||
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
|
||||
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
|
||||
_register_dona_spotify(app)
|
||||
|
||||
# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée.
|
||||
from services.dona_face import (register_face as _dona_register_face,
|
||||
recognize_once as _dona_face_recognize, is_face_request as _dona_face_request)
|
||||
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "8.1",
|
||||
"app": "/opt/alfred/app",
|
||||
"existing": [
|
||||
"app.py",
|
||||
"templates/index.html"
|
||||
],
|
||||
"created": [
|
||||
"services/dona_dashboard_agent.py",
|
||||
"static/js/dona-header-v8-1.js",
|
||||
"static/css/dona-header-v8-1.css"
|
||||
],
|
||||
"before": {
|
||||
"app.py": "6f4cabfbc875fef4c63982d0c411e7ee165dbfffacb805d5c4af40dabe382fdb",
|
||||
"templates/index.html": "e925e4efbdc8e9c162580a4d5a5a8cd8ea0fa6f305ecdefb2fd698e0eeff8251"
|
||||
},
|
||||
"after": {
|
||||
"services/dona_dashboard_agent.py": "d17bb3dcba5af711c207d2fdfc0c66a82d5edbdc75d099bfcb00acace54cc65f",
|
||||
"static/js/dona-header-v8-1.js": "cbe4c836cd784d7254f8959b430d30da82882935b75c65e50ad9f1e2be890795",
|
||||
"static/css/dona-header-v8-1.css": "070b3f9ac3bcd6dc8cee342202b14577960ce0126b3598f05d32d02dce6a7e93",
|
||||
"app.py": "34464a73b500dd95353b83fc5896f60503193a1dfb1321b2069f5c0ec90e8ff2",
|
||||
"templates/index.html": "2506dc710ef641bc7b960124191b3c2a6821bdbd8e0e97dacd52248b57b6b2c1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
|
||||
<!-- ALFRED_MAIN_VISUAL_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
|
||||
<!-- ALFRED_LISTES_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
|
||||
<!-- ALFRED_GLASS_V2 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
|
||||
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
|
||||
<!-- ALFRED_REPONSE_CLOSE_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
|
||||
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
|
||||
<!-- DONA_FAMILY_UI_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
|
||||
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
|
||||
<!-- DONA_V7_RELEASE -->
|
||||
<link rel="stylesheet" href="/static/css/dona-v7.css?v=7">
|
||||
<!-- DONA_V8_DYNAMIC -->
|
||||
<link rel="stylesheet" href="/static/css/dona-dynamic-v8.css?v=8">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
|
||||
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
|
||||
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
|
||||
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
|
||||
<div id="alfredTimersList"></div>
|
||||
</aside>
|
||||
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
|
||||
|
||||
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
|
||||
aria-modal="true" hidden>
|
||||
<article class="alfred-context-card">
|
||||
<button id="alfredContextClose" class="alfred-context-close" type="button"
|
||||
aria-label="Fermer l'illustration" title="Fermer">×</button>
|
||||
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
|
||||
<div class="alfred-context-details">
|
||||
<h2 id="alfredContextTitle"></h2>
|
||||
<p id="alfredContextText"></p>
|
||||
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<script src="/static/js/alfred-timers-context.js"></script>
|
||||
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
|
||||
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
|
||||
<script src="/static/js/alfred-main-v1.js?v=1"></script>
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<script src="/static/js/alfred-maison-card.js?v=1"></script>
|
||||
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
|
||||
<script src="/static/js/alfred-main-v2.js?v=2"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
|
||||
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
|
||||
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
|
||||
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
|
||||
<script defer src="/static/js/dona-reply-auto-close-v7.js?v=7"></script>
|
||||
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
|
||||
<script defer src="/static/js/dona-spotify.js?v=6"></script>
|
||||
<!-- dona-ui-fix start -->
|
||||
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
|
||||
<!-- dona-ui-fix end -->
|
||||
<script defer src="/static/js/dona-face-v7.js?v=7"></script>
|
||||
<script defer src="/static/js/dona-dynamic-v8.js?v=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,657 @@
|
||||
|
||||
import os, threading, hmac
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Flask, render_template, request, jsonify, Response
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger la configuration AVANT les imports des services
|
||||
load_dotenv("/opt/alfred/.env")
|
||||
load_dotenv(
|
||||
"/opt/alfred/app/config/control_center.env",
|
||||
override=True
|
||||
)
|
||||
|
||||
from services.audio import listen_once, speak_async, tts_backend_detect
|
||||
from services.wakeword import WakeWordListener
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
from services.piper_tts import stop_playback as duplex_stop_piper
|
||||
from services import admin_console # ALFRED_ADMIN_PAGES_V1
|
||||
from services.hermes_client import ask_hermes
|
||||
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
|
||||
from services import alfred_features # ALFRED_FEATURES_V45
|
||||
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
||||
from services.camera import video_stream
|
||||
from services import ha_views, alfred_views_controller
|
||||
from services import camera_settings, ha_weather, weather_outfits
|
||||
|
||||
load_dotenv()
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
|
||||
##
|
||||
from services.audio import get_vosk_model
|
||||
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
|
||||
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
|
||||
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
|
||||
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||
##
|
||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
||||
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||
STATE_LOCK = threading.RLock()
|
||||
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
|
||||
conversation_lock=threading.Lock()
|
||||
mic_stop_event=threading.Event()
|
||||
# Service temps autonome (survit aux conversations Hermes)
|
||||
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
|
||||
audio_settings.initialize()
|
||||
audio_settings.update_settings({"mic_gain": 30})
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
|
||||
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
|
||||
on_interrupt=duplex_stop_piper,
|
||||
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
|
||||
|
||||
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
|
||||
camera_settings.restore_saved_settings()
|
||||
|
||||
def emit_state(mode=None, **extra):
|
||||
with STATE_LOCK:
|
||||
if mode: STATE["mode"] = mode
|
||||
STATE.update(extra)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return
|
||||
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
|
||||
try:
|
||||
emit_state("listening", partial="", transcript="", wake_score=score)
|
||||
|
||||
print("[ALFRED] Début écoute STT", flush=True)
|
||||
|
||||
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
from services.audio import STTResult
|
||||
r = STTResult(text=injected_text)
|
||||
else:
|
||||
r = listen_once(
|
||||
stop_event=mic_stop_event,
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
|
||||
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
|
||||
|
||||
if audio_settings.mic_muted():
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
if r.error:
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": r.error}
|
||||
)
|
||||
return
|
||||
|
||||
if not r.text:
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
emit_state(
|
||||
"thinking",
|
||||
partial="",
|
||||
transcript=r.text,
|
||||
content={"type": "text", "text": r.text}
|
||||
)
|
||||
|
||||
# Vue dynamique : commandes déterministes exécutées sans faire
|
||||
# dépendre l'affichage des capacités ou sessions Hermes.
|
||||
visual = None
|
||||
try:
|
||||
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
|
||||
visual = ({"type":"text", "text":str(exc)}, str(exc))
|
||||
|
||||
if visual is not None:
|
||||
content, speech = visual
|
||||
with STATE_LOCK:
|
||||
STATE["content"] = content
|
||||
socketio.emit("alfred_render", content)
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
done.wait(timeout=120)
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_START
|
||||
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
|
||||
try:
|
||||
_lists_reply = _alfred_handle_list_voice(
|
||||
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
|
||||
except Exception as _lists_error:
|
||||
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
|
||||
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
|
||||
if _lists_reply is not None:
|
||||
_lists_display = {'type':'text', 'text':_lists_reply}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _lists_display
|
||||
socketio.emit('alfred_render', _lists_display)
|
||||
if _lists_reply and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_lists_done = threading.Event()
|
||||
speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
_lists_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_END
|
||||
|
||||
print("[ALFRED] Envoi à Hermes", flush=True)
|
||||
# ComfyUI publie l'image pendant cet appel Hermes.
|
||||
with STATE_LOCK:
|
||||
image_revision_before = IMAGE_REVISION
|
||||
# ALFRED_FEATURES_V45 voice opt-in background mode
|
||||
if alfred_features.should_run_in_background(r.text):
|
||||
try:
|
||||
alfred_features.submit(r.text, source='voice')
|
||||
emit_state('idle', partial='', content={
|
||||
'type': 'text',
|
||||
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
|
||||
})
|
||||
except ValueError as exc:
|
||||
emit_state('error', content={'type': 'text', 'text': str(exc)})
|
||||
return
|
||||
# DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal.
|
||||
answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(r.text)
|
||||
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
|
||||
if isinstance(answer, dict):
|
||||
speech = str(answer.get("speech") or answer.get("text") or "")
|
||||
display = answer.get("display") or {
|
||||
"type": "text",
|
||||
"text": speech
|
||||
}
|
||||
else:
|
||||
speech = str(answer or "")
|
||||
display = {"type": "text", "text": speech}
|
||||
|
||||
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
|
||||
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
|
||||
alfred_context_images.schedule_context_image(
|
||||
r.text, speech, socketio,
|
||||
lambda: STATE.get("transcript") == r.text,
|
||||
) # ALFRED_TIMERS_CONTEXT_V1
|
||||
|
||||
# Si le MCP ComfyUI a affiché une image pendant la requête,
|
||||
# ne pas l'écraser par la réponse textuelle d'Hermes.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == image_revision_before:
|
||||
STATE["content"] = display
|
||||
alfred_features.publish_voice_display(display)
|
||||
socketio.emit("alfred_render", display)
|
||||
else:
|
||||
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
|
||||
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
try:
|
||||
started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
if started:
|
||||
done.wait(timeout=120)
|
||||
finally:
|
||||
if not duplex_monitor.interrupted():
|
||||
duplex_monitor.stop()
|
||||
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
|
||||
if duplex_monitor.interrupted():
|
||||
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
|
||||
duplex_monitor.stop()
|
||||
else:
|
||||
# A few wake-free seconds after the assistant finishes speaking.
|
||||
pending_duplex_text = duplex_monitor.listen_conversation()
|
||||
if pending_duplex_text:
|
||||
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
|
||||
|
||||
emit_state("idle", partial="")
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": str(exc)}
|
||||
)
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
# Only the voice is cancelled; the original Hermes job is untouched.
|
||||
wake.pause()
|
||||
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
|
||||
daemon=True, name='dona-duplex-followup').start()
|
||||
elif not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
print("[ALFRED] Wake word réactivé", flush=True)
|
||||
|
||||
|
||||
|
||||
def on_wake(score):
|
||||
if audio_settings.mic_muted():
|
||||
return
|
||||
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
|
||||
threading.Thread(target=conversation,args=(score,),daemon=True).start()
|
||||
|
||||
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
|
||||
|
||||
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
|
||||
@app.get('/api/duplex/status')
|
||||
def duplex_status():
|
||||
return jsonify(duplex_monitor.status())
|
||||
|
||||
@app.get('/api/audio/settings')
|
||||
def get_audio_settings():
|
||||
return jsonify(audio_settings.get_settings())
|
||||
|
||||
@app.post('/api/audio/settings')
|
||||
def set_audio_settings():
|
||||
data = request.get_json(silent=True)
|
||||
try:
|
||||
old = audio_settings.get_settings()
|
||||
new = audio_settings.update_settings(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'ALSA : {exc}'}), 500
|
||||
if new['mic_muted'] != old['mic_muted']:
|
||||
if new['mic_muted']:
|
||||
mic_stop_event.set()
|
||||
wake.pause()
|
||||
else:
|
||||
mic_stop_event.clear()
|
||||
if not conversation_lock.locked():
|
||||
wake.resume()
|
||||
socketio.emit('audio_settings', new)
|
||||
return jsonify(new)
|
||||
|
||||
@app.get('/api/camera/settings')
|
||||
def get_camera_settings():
|
||||
try:
|
||||
return jsonify(camera_settings.get_settings())
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/settings')
|
||||
def set_camera_settings():
|
||||
# Même garde-fou que les commandes calendrier existantes.
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'JSON invalide.'}), 400
|
||||
try:
|
||||
result = camera_settings.update_setting(data.get('control'), data.get('value'))
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/reset')
|
||||
def reset_camera_settings():
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
try:
|
||||
result = camera_settings.reset_defaults()
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.get('/api/camera/stream')
|
||||
def camera_stream():
|
||||
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
|
||||
try:
|
||||
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
|
||||
except (ImportError, ValueError, RuntimeError, OSError) as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
return Response(
|
||||
frames,
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame',
|
||||
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
|
||||
)
|
||||
|
||||
# ALFRED_FEATURES_V45 init
|
||||
alfred_features.init_app(app, ask_hermes, socketio=socketio)
|
||||
|
||||
@app.get("/")
|
||||
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
|
||||
@app.get("/api/state")
|
||||
def state():
|
||||
with STATE_LOCK:
|
||||
snapshot = dict(STATE)
|
||||
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
|
||||
|
||||
@app.post("/api/state")
|
||||
def set_state():
|
||||
data = request.get_json(force=True)
|
||||
with STATE_LOCK:
|
||||
STATE.update(data)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
return jsonify({"ok": True})
|
||||
@app.post("/api/render")
|
||||
def render_payload():
|
||||
global IMAGE_REVISION
|
||||
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||
if token and not hmac.compare_digest(
|
||||
request.headers.get("X-Alfred-Render-Token", ""), token
|
||||
):
|
||||
return jsonify({"ok": False, "error": "Non autorisé"}), 401
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
|
||||
if data.get("type") == "image":
|
||||
image_url = data.get("url")
|
||||
if not isinstance(image_url, str) or not (
|
||||
image_url.startswith(("http://", "https://", "/"))
|
||||
and not image_url.startswith("//")
|
||||
):
|
||||
return jsonify({"ok": False, "error": "URL image invalide"}), 400
|
||||
|
||||
with STATE_LOCK:
|
||||
if data.get("type") == "image":
|
||||
IMAGE_REVISION += 1
|
||||
STATE["content"] = data
|
||||
socketio.emit("alfred_render", data)
|
||||
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
|
||||
# --- Agenda / capteurs / caméras Home Assistant ---
|
||||
def _calendar_range():
|
||||
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
|
||||
now = datetime.now(tz)
|
||||
def parse(name, default):
|
||||
raw = request.args.get(name)
|
||||
if not raw:return default
|
||||
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
|
||||
if value.tzinfo is None: value = value.replace(tzinfo=tz)
|
||||
return value.astimezone(tz)
|
||||
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
|
||||
end = parse('end',start+timedelta(days=7))
|
||||
return start,end
|
||||
|
||||
def _json_error(exc):
|
||||
code = 400 if isinstance(exc,ValueError) else 502
|
||||
return jsonify({'error':str(exc)}),code
|
||||
|
||||
def _writes_enabled():
|
||||
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
|
||||
|
||||
def _same_origin_json():
|
||||
# POSTs require a custom header and same-origin browser requests.
|
||||
if request.headers.get('X-Alfred-Request') != '1':return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
|
||||
return True
|
||||
|
||||
@app.get('/api/calendar/calendars')
|
||||
def calendars_list():
|
||||
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/calendar/events')
|
||||
def calendar_events():
|
||||
try:
|
||||
start,end=_calendar_range()
|
||||
selected=request.args.getlist('calendar_id')
|
||||
return jsonify(ha_views.get_events(start,end,selected or None))
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/create')
|
||||
def calendar_create():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
def read_dt(key):
|
||||
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
|
||||
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
||||
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
|
||||
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
|
||||
str(data.get('location') or ''),bool(data.get('all_day',False)))
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_create()
|
||||
socketio.emit('calendar_changed', {'type':'created'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/delete')
|
||||
def calendar_delete():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
|
||||
start=datetime.fromisoformat(str(data['start']))
|
||||
end=datetime.fromisoformat(str(data['end']))
|
||||
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
|
||||
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
|
||||
rid=str(data.get('recurrence_id') or '')
|
||||
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
|
||||
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
|
||||
result=ha_views.delete_event(eid,uid,rid or None)
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_delete()
|
||||
socketio.emit('calendar_changed',{'type':'deleted'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather')
|
||||
def ha_weather_data():
|
||||
# Read-only route, token always stays on the Alfred server.
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
return jsonify(ha_weather.get_weather(q))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather/outfits')
|
||||
def ha_weather_outfits_data():
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
|
||||
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
|
||||
except (ha_views.HAError, ValueError, RuntimeError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperatures')
|
||||
def ha_temperatures():
|
||||
try:return jsonify({'items':ha_views.get_temperatures()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperature/history')
|
||||
def ha_temperature_history():
|
||||
try:
|
||||
eid=request.args.get('entity_id','')
|
||||
hours=int(request.args.get('hours','24'))
|
||||
return jsonify({'items':ha_views.temperature_history(eid,hours)})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/cameras')
|
||||
def ha_cameras():
|
||||
try:return jsonify({'items':ha_views.list_cameras()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
|
||||
def ha_camera_snapshot(entity_id):
|
||||
try:
|
||||
binary,mime=ha_views.camera_snapshot(entity_id)
|
||||
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/ui/home')
|
||||
def ui_home():
|
||||
with STATE_LOCK:
|
||||
STATE['content']={'type':'home','text':'À ton écoute.'}
|
||||
socketio.emit('alfred_render',STATE['content'])
|
||||
return jsonify({'ok':True})
|
||||
|
||||
@app.post("/api/audio-level")
|
||||
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
|
||||
@app.get("/api/wake/status")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
|
||||
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
|
||||
def _alfred_dashboard_quick_command(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
|
||||
def _run():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_image_revision_before = IMAGE_REVISION
|
||||
emit_state('thinking', transcript=prompt, partial='',
|
||||
content={'type':'text', 'text':prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _image_revision_before:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
if _speech and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_done = threading.Event()
|
||||
speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
except Exception as _exc:
|
||||
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':str(_exc)})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
|
||||
return True
|
||||
|
||||
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
|
||||
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_START
|
||||
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
|
||||
_alfred_register_home_glass(app)
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_END
|
||||
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_START
|
||||
from services.alfred_lists import register_lists as _alfred_register_lists
|
||||
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
|
||||
_alfred_register_lists(app, socketio)
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_GLASS_V2_BACKEND_START
|
||||
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
|
||||
_alfred_register_logs_v2(app)
|
||||
# ALFRED_GLASS_V2_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
|
||||
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
|
||||
_alfred_identity_register(app)
|
||||
|
||||
|
||||
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
|
||||
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
|
||||
_alfred_developer_register(app)
|
||||
|
||||
|
||||
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
|
||||
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
|
||||
_register_hermes_new_session(app)
|
||||
|
||||
|
||||
# DONA_FAMILY_UI_V1_START
|
||||
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
|
||||
def _dona_submit_typed(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _dona_run_typed():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_before_image = IMAGE_REVISION
|
||||
emit_state('thinking', partial='', transcript=prompt,
|
||||
content={'type': 'text', 'text': prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _before_image:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
# Deliberately no speak_async(): typed interaction is silent.
|
||||
emit_state('idle', partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
|
||||
return True
|
||||
|
||||
from services.dona_family_ui import register_dona_family as _dona_register_family
|
||||
_dona_register_family(app, adult_command=_dona_submit_typed)
|
||||
# DONA_FAMILY_UI_V1_END
|
||||
|
||||
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
|
||||
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
|
||||
_register_dona_spotify(app)
|
||||
|
||||
# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée.
|
||||
from services.dona_face import (register_face as _dona_register_face,
|
||||
recognize_once as _dona_face_recognize, is_face_request as _dona_face_request)
|
||||
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"app": "/opt/alfred/app",
|
||||
"created": [
|
||||
"services/dona_dynamic.py",
|
||||
"static/js/dona-dynamic-v8.js",
|
||||
"static/css/dona-dynamic-v8.css"
|
||||
],
|
||||
"modified": [
|
||||
"app.py",
|
||||
"templates/index.html",
|
||||
"services/hermes_client.py"
|
||||
],
|
||||
"before": {
|
||||
"app.py": "8eaebc07260935a1905f597028ab1c6425cafe596b09fe2d96d93a0571ba5cf8",
|
||||
"templates/index.html": "c85faa772ac0a82007028be3aa054aaa372c9b14f2d209a333cd2b9f75543584",
|
||||
"services/hermes_client.py": "1c9daf8020eb1438a034a2ed9ec08a8a4499794dcd4cab91987a9820f849a85d"
|
||||
},
|
||||
"after": {
|
||||
"services/dona_dynamic.py": "336c3e0b728d88fb7f7ffa7045f913c62983810f38f8df4f016c77f004103cda",
|
||||
"static/js/dona-dynamic-v8.js": "cf365dc516f0fa2c9fb6bbd87bed51eb08fe2b478b321b8ccae1d6c90d5d53d9",
|
||||
"static/css/dona-dynamic-v8.css": "518e3d886e81056a64480d05a0dda6a5c11b257c0b9a2c748d3458b8b114fec9",
|
||||
"app.py": "26aefa356f6212c1cd4c2f9fe1a9f3202420c9b92e445d5d3143c868bf77e7b2",
|
||||
"templates/index.html": "e925e4efbdc8e9c162580a4d5a5a8cd8ea0fa6f305ecdefb2fd698e0eeff8251",
|
||||
"services/hermes_client.py": "41baaea942f4ea4166d82781416cb7542a6cb31182f1a60a968aa10a934c91c6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
|
||||
"""Pont Alfred -> Hermes avec session persistante."""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# CONFIGURATION
|
||||
# --------------------------------------------------
|
||||
|
||||
DEFAULT_IDENTITY_FILE = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "config"
|
||||
/ "identity.md"
|
||||
)
|
||||
|
||||
DEFAULT_SESSION_FILE = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "config"
|
||||
/ "hermes_session.json"
|
||||
)
|
||||
|
||||
_REQUEST_LOCK = threading.Lock()
|
||||
|
||||
_ID_PATTERN = re.compile(
|
||||
r"^[A-Za-z0-9_:.\-]{1,128}$"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# IDENTITÉ D'ALFRED
|
||||
# --------------------------------------------------
|
||||
|
||||
def _build_prompt(question: str) -> str:
|
||||
"""Send only the real user utterance. Personality is configured in Hermes SOUL.md.
|
||||
|
||||
The previous identity.md wrapper caused absent/unknown-tool decisions in
|
||||
both Spotify and Home Assistant. Never inject it into each user turn.
|
||||
"""
|
||||
return str(question or '').strip()
|
||||
|
||||
|
||||
def _toolsets_for(question: str) -> str | None:
|
||||
"""Conservative domain routing to avoid huge MCP schemas on simple turns.
|
||||
|
||||
Cross-domain, ambiguous, or generic requests retain Hermes' full toolset.
|
||||
Set HERMES_TOOLSET_ROUTING=off for instant rollback without code changes.
|
||||
"""
|
||||
if os.getenv('HERMES_TOOLSET_ROUTING', 'on').lower() not in ('on','1','true','yes'):
|
||||
return None
|
||||
q = str(question or '').casefold()
|
||||
domains = {
|
||||
'spotify': (r'\bspotify\b|\bplaylist\b|\bmusique\b|\bmorceau\b|\bchanson\b|\balbum\b|\bspotify connect\b'),
|
||||
'homeassistant': (r'\blumi[eè]res?\b|\blampes?\b|\bvolets?\b|\bdomotique\b|\bthermostat\b|\bchauffage\b|\bprises?\b|\bhome assistant\b|\ballume\b|\b[eé]teins?\b'),
|
||||
'face_recognition': (r'\bface_recognition\b|\bidentify_people\b|\breconnaissance faciale\b'), # DONA_V7_RELEASE
|
||||
}
|
||||
import re as _re
|
||||
matching = [domain for domain, pattern in domains.items() if _re.search(pattern, q)]
|
||||
# Combined or potentially compound queries keep full access to every MCP.
|
||||
if len(matching) != 1:
|
||||
return None
|
||||
if _re.search(r'\b(et aussi|en m[eê]me temps|puis v[eé]rifie|compare avec|cherche sur|envoie|par mail|gmail|calendrier|agenda|wazuh|proxmox|comfyui|document|paperless)\b', q):
|
||||
return None
|
||||
return {'spotify':'spotify', 'homeassistant':'homeassistant,home_assistant', 'face_recognition':'face_recognition'}[matching[0]]
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# FICHIER DE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _session_file() -> Path:
|
||||
|
||||
return Path(
|
||||
os.getenv(
|
||||
"HERMES_SESSION_FILE",
|
||||
str(DEFAULT_SESSION_FILE)
|
||||
)
|
||||
).expanduser()
|
||||
|
||||
|
||||
def _read_session(path: Path) -> str | None:
|
||||
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
obj = json.loads(
|
||||
path.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
sid = obj["session_id"]
|
||||
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError
|
||||
) as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
f"État de session Hermes invalide "
|
||||
f"({path}) : {exc}"
|
||||
) from exc
|
||||
|
||||
if (
|
||||
not isinstance(sid, str)
|
||||
or not _ID_PATTERN.fullmatch(sid)
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Identifiant de session Hermes "
|
||||
f"invalide dans {path}"
|
||||
)
|
||||
|
||||
return sid
|
||||
|
||||
|
||||
def _save_session(path: Path, sid: str):
|
||||
|
||||
if not _ID_PATTERN.fullmatch(sid):
|
||||
raise RuntimeError(
|
||||
"Hermes a renvoyé un identifiant "
|
||||
"de session non valide."
|
||||
)
|
||||
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
temp_path = None
|
||||
|
||||
try:
|
||||
fd, temp_name = tempfile.mkstemp(
|
||||
prefix=".hermes_session_",
|
||||
suffix=".tmp",
|
||||
dir=path.parent
|
||||
)
|
||||
|
||||
temp_path = Path(temp_name)
|
||||
|
||||
with os.fdopen(
|
||||
fd,
|
||||
"w",
|
||||
encoding="utf-8"
|
||||
) as handle:
|
||||
|
||||
json.dump(
|
||||
{"session_id": sid},
|
||||
handle,
|
||||
indent=2
|
||||
)
|
||||
|
||||
handle.write("\n")
|
||||
|
||||
os.replace(temp_path, path)
|
||||
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# VERROUILLAGE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def _session_lock(path: Path):
|
||||
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
lock_path = path.with_name(
|
||||
path.name + ".lock"
|
||||
)
|
||||
|
||||
fd = os.open(
|
||||
lock_path,
|
||||
os.O_CREAT | os.O_RDWR,
|
||||
0o600
|
||||
)
|
||||
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
|
||||
fcntl.flock(
|
||||
handle.fileno(),
|
||||
fcntl.LOCK_EX
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
|
||||
finally:
|
||||
fcntl.flock(
|
||||
handle.fileno(),
|
||||
fcntl.LOCK_UN
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# CONFIGURATION HERMES
|
||||
# --------------------------------------------------
|
||||
|
||||
def _hermes_binary() -> str:
|
||||
|
||||
return os.getenv(
|
||||
"HERMES_CLI",
|
||||
"/root/.local/bin/hermes"
|
||||
)
|
||||
|
||||
|
||||
def _hermes_timeout() -> float:
|
||||
|
||||
return max(
|
||||
10.0,
|
||||
float(
|
||||
os.getenv("HERMES_TIMEOUT", "180")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# ANCIEN MODE SANS SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _oneshot_legacy(prompt: str) -> str:
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
_hermes_binary(),
|
||||
"--oneshot",
|
||||
prompt
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_hermes_timeout(),
|
||||
check=True
|
||||
)
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas répondu "
|
||||
"dans le délai imparti."
|
||||
) from exc
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
|
||||
details = (
|
||||
exc.stderr
|
||||
or exc.stdout
|
||||
or "aucun détail"
|
||||
).strip()
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes a échoué "
|
||||
f"(code {exc.returncode}): {details}"
|
||||
) from exc
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# INTERPRÉTATION RÉPONSE JSONL
|
||||
# --------------------------------------------------
|
||||
|
||||
def _parse_jsonl(stdout: str) -> tuple[str, str]:
|
||||
|
||||
final = None
|
||||
initial_session_id = None
|
||||
|
||||
for line in stdout.splitlines():
|
||||
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
event = json.loads(line)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
if (
|
||||
event.get("type") == "system"
|
||||
and event.get("subtype") == "init"
|
||||
):
|
||||
initial_session_id = event.get(
|
||||
"session_id"
|
||||
)
|
||||
|
||||
if event.get("type") == "result":
|
||||
final = event
|
||||
|
||||
if final is None:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas renvoyé de résultat "
|
||||
"JSONL. Vérifie la prise en charge "
|
||||
"de --format stream-json."
|
||||
)
|
||||
|
||||
if final.get("exit_code", 0) != 0:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes : "
|
||||
f"{final.get('error') or 'échec'}"
|
||||
)
|
||||
|
||||
sid = (
|
||||
final.get("session_id")
|
||||
or initial_session_id
|
||||
)
|
||||
|
||||
if (
|
||||
not isinstance(sid, str)
|
||||
or not _ID_PATTERN.fullmatch(sid)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas fourni "
|
||||
"d'identifiant de session utilisable."
|
||||
)
|
||||
|
||||
answer = final.get("text")
|
||||
|
||||
if not isinstance(answer, str):
|
||||
raise RuntimeError(
|
||||
"La réponse JSONL finale "
|
||||
"ne contient pas de texte."
|
||||
)
|
||||
|
||||
return answer.strip(), sid
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# APPEL HERMES AVEC REPRISE DE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _call_hermes(
|
||||
prompt: str,
|
||||
session_id: str | None
|
||||
) -> tuple[str, str]:
|
||||
|
||||
cmd = [
|
||||
_hermes_binary(),
|
||||
"chat",
|
||||
"--oneshot",
|
||||
"--query-file",
|
||||
"-",
|
||||
"--format",
|
||||
"stream-json",
|
||||
"--source",
|
||||
"cli"
|
||||
]
|
||||
|
||||
route = _toolsets_for(prompt)
|
||||
if route:
|
||||
cmd.extend(["--toolsets", route])
|
||||
|
||||
if session_id:
|
||||
cmd.extend([
|
||||
"--resume",
|
||||
session_id
|
||||
])
|
||||
|
||||
try:
|
||||
process = subprocess.run(
|
||||
cmd,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_hermes_timeout(),
|
||||
check=False
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas répondu "
|
||||
"dans le délai imparti."
|
||||
) from exc
|
||||
|
||||
except FileNotFoundError as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes introuvable : "
|
||||
f"{_hermes_binary()}"
|
||||
) from exc
|
||||
|
||||
if process.returncode != 0:
|
||||
|
||||
msg = (
|
||||
process.stderr
|
||||
or process.stdout
|
||||
or "aucun détail"
|
||||
).strip()
|
||||
|
||||
if len(msg) > 1500:
|
||||
msg = msg[-1500:]
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes a échoué "
|
||||
f"(code {process.returncode}) : "
|
||||
f"{msg}"
|
||||
)
|
||||
|
||||
return _parse_jsonl(process.stdout)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# FONCTION PRINCIPALE APPELÉE PAR FLASK
|
||||
# --------------------------------------------------
|
||||
|
||||
def ask_hermes(text: str) -> str:
|
||||
|
||||
prompt = _build_prompt(text)
|
||||
|
||||
# Ancien fonctionnement sans session
|
||||
if (
|
||||
os.getenv(
|
||||
"HERMES_SESSION_MODE",
|
||||
"persistent"
|
||||
).lower() == "legacy"
|
||||
):
|
||||
return _oneshot_legacy(prompt)
|
||||
|
||||
path = _session_file()
|
||||
|
||||
# Empêche les appels concurrents
|
||||
with _REQUEST_LOCK, _session_lock(path):
|
||||
|
||||
# Recherche la session précédente
|
||||
current_sid = _read_session(path)
|
||||
|
||||
# Envoie la question à Hermes
|
||||
answer, new_sid = _call_hermes(
|
||||
prompt,
|
||||
current_sid
|
||||
)
|
||||
|
||||
# Sauvegarde l'identifiant
|
||||
_save_session(
|
||||
path,
|
||||
new_sid
|
||||
)
|
||||
|
||||
print(
|
||||
f"[ALFRED] Session Hermes : "
|
||||
f"{new_sid} "
|
||||
f"({'reprise' if current_sid else 'création'})",
|
||||
flush=True
|
||||
)
|
||||
|
||||
return answer
|
||||
@@ -0,0 +1,228 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
|
||||
<!-- ALFRED_MAIN_VISUAL_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
|
||||
<!-- ALFRED_LISTES_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
|
||||
<!-- ALFRED_GLASS_V2 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
|
||||
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
|
||||
<!-- ALFRED_REPONSE_CLOSE_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
|
||||
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
|
||||
<!-- DONA_FAMILY_UI_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
|
||||
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
|
||||
<!-- DONA_V7_RELEASE -->
|
||||
<link rel="stylesheet" href="/static/css/dona-v7.css?v=7">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
|
||||
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
|
||||
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
|
||||
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
|
||||
<div id="alfredTimersList"></div>
|
||||
</aside>
|
||||
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
|
||||
|
||||
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
|
||||
aria-modal="true" hidden>
|
||||
<article class="alfred-context-card">
|
||||
<button id="alfredContextClose" class="alfred-context-close" type="button"
|
||||
aria-label="Fermer l'illustration" title="Fermer">×</button>
|
||||
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
|
||||
<div class="alfred-context-details">
|
||||
<h2 id="alfredContextTitle"></h2>
|
||||
<p id="alfredContextText"></p>
|
||||
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<script src="/static/js/alfred-timers-context.js"></script>
|
||||
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
|
||||
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
|
||||
<script src="/static/js/alfred-main-v1.js?v=1"></script>
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<script src="/static/js/alfred-maison-card.js?v=1"></script>
|
||||
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
|
||||
<script src="/static/js/alfred-main-v2.js?v=2"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
|
||||
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
|
||||
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
|
||||
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
|
||||
<script defer src="/static/js/dona-reply-auto-close-v7.js?v=7"></script>
|
||||
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
|
||||
<script defer src="/static/js/dona-spotify.js?v=6"></script>
|
||||
<!-- dona-ui-fix start -->
|
||||
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
|
||||
<!-- dona-ui-fix end -->
|
||||
<script defer src="/static/js/dona-face-v7.js?v=7"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,763 @@
|
||||
|
||||
import os, threading, hmac
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Flask, render_template, request, jsonify, Response
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger la configuration AVANT les imports des services
|
||||
load_dotenv("/opt/alfred/.env")
|
||||
load_dotenv(
|
||||
"/opt/alfred/app/config/control_center.env",
|
||||
override=True
|
||||
)
|
||||
|
||||
from services.audio import listen_once, speak_async, tts_backend_detect
|
||||
from services.wakeword import WakeWordListener
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
from services.piper_tts import stop_playback as duplex_stop_piper
|
||||
from services import admin_console # ALFRED_ADMIN_PAGES_V1
|
||||
from services.hermes_client import ask_hermes
|
||||
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
|
||||
from services import alfred_features # ALFRED_FEATURES_V45
|
||||
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
||||
from services.camera import video_stream
|
||||
from services import ha_views, alfred_views_controller
|
||||
from services import camera_settings, ha_weather, weather_outfits
|
||||
|
||||
load_dotenv()
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
|
||||
##
|
||||
from services.audio import get_vosk_model
|
||||
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
|
||||
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
|
||||
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
|
||||
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||
##
|
||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
||||
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||
STATE_LOCK = threading.RLock()
|
||||
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
|
||||
conversation_lock=threading.Lock()
|
||||
mic_stop_event=threading.Event()
|
||||
# Service temps autonome (survit aux conversations Hermes)
|
||||
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
|
||||
audio_settings.initialize()
|
||||
audio_settings.update_settings({"mic_gain": 30})
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
|
||||
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
|
||||
on_interrupt=duplex_stop_piper,
|
||||
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
|
||||
|
||||
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
|
||||
camera_settings.restore_saved_settings()
|
||||
|
||||
def emit_state(mode=None, **extra):
|
||||
with STATE_LOCK:
|
||||
if mode: STATE["mode"] = mode
|
||||
STATE.update(extra)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return
|
||||
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
|
||||
try:
|
||||
emit_state("listening", partial="", transcript="", wake_score=score)
|
||||
|
||||
print("[ALFRED] Début écoute STT", flush=True)
|
||||
|
||||
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
from services.audio import STTResult
|
||||
r = STTResult(text=injected_text)
|
||||
else:
|
||||
r = listen_once(
|
||||
stop_event=mic_stop_event,
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
|
||||
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
|
||||
|
||||
if audio_settings.mic_muted():
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
if r.error:
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": r.error}
|
||||
)
|
||||
return
|
||||
|
||||
if not r.text:
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
emit_state(
|
||||
"thinking",
|
||||
partial="",
|
||||
transcript=r.text,
|
||||
content={"type": "text", "text": r.text}
|
||||
)
|
||||
|
||||
# DONA_NAV_V83: cartes et itinéraires à la demande, sans LLM.
|
||||
if _dona_nav.is_request(r.text):
|
||||
_nav_reply=_dona_nav.handle_and_emit(r.text,socketio,STATE,STATE_LOCK)
|
||||
if _nav_reply and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_nav_done=threading.Event()
|
||||
_nav_ok=speak_async(_nav_reply,on_end=lambda: (_nav_done.set(),socketio.emit('alfred_tts_finished',{'source':'voice'})))
|
||||
if _nav_ok:_nav_done.wait(timeout=120)
|
||||
emit_state('idle',partial='')
|
||||
return
|
||||
|
||||
# DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix.
|
||||
if _dona_youtube.is_search(r.text):
|
||||
_speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK)
|
||||
if _speech and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_evt=threading.Event()
|
||||
_ok=speak_async(_speech,on_end=_evt.set)
|
||||
if _ok: _evt.wait(timeout=120)
|
||||
emit_state('idle',partial='')
|
||||
return
|
||||
if _dona_youtube.is_control(r.text,_dona_youtube.player_active()):
|
||||
_dona_youtube.control_and_emit(r.text,socketio)
|
||||
emit_state('idle',partial='')
|
||||
return
|
||||
|
||||
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
|
||||
if _dona_dynamic.is_dynamic_request(r.text):
|
||||
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
|
||||
_payload = {'type':'dona_dynamic', 'dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _payload
|
||||
socketio.emit('alfred_render', _payload)
|
||||
if not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_finished = threading.Event()
|
||||
_started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set)
|
||||
if _started: _finished.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
|
||||
# Vue dynamique : commandes déterministes exécutées sans faire
|
||||
# dépendre l'affichage des capacités ou sessions Hermes.
|
||||
visual = None
|
||||
try:
|
||||
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
|
||||
visual = ({"type":"text", "text":str(exc)}, str(exc))
|
||||
|
||||
if visual is not None:
|
||||
content, speech = visual
|
||||
with STATE_LOCK:
|
||||
STATE["content"] = content
|
||||
socketio.emit("alfred_render", content)
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
done.wait(timeout=120)
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_START
|
||||
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
|
||||
try:
|
||||
_lists_reply = _alfred_handle_list_voice(
|
||||
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
|
||||
except Exception as _lists_error:
|
||||
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
|
||||
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
|
||||
if _lists_reply is not None:
|
||||
_lists_display = {'type':'text', 'text':_lists_reply}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _lists_display
|
||||
socketio.emit('alfred_render', _lists_display)
|
||||
if _lists_reply and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_lists_done = threading.Event()
|
||||
speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
_lists_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_END
|
||||
|
||||
print("[ALFRED] Envoi à Hermes", flush=True)
|
||||
# ComfyUI publie l'image pendant cet appel Hermes.
|
||||
with STATE_LOCK:
|
||||
image_revision_before = IMAGE_REVISION
|
||||
# ALFRED_FEATURES_V45 voice opt-in background mode
|
||||
if alfred_features.should_run_in_background(r.text):
|
||||
try:
|
||||
alfred_features.submit(r.text, source='voice')
|
||||
emit_state('idle', partial='', content={
|
||||
'type': 'text',
|
||||
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
|
||||
})
|
||||
except ValueError as exc:
|
||||
emit_state('error', content={'type': 'text', 'text': str(exc)})
|
||||
return
|
||||
# DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal.
|
||||
answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(r.text)
|
||||
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
|
||||
if isinstance(answer, dict):
|
||||
speech = str(answer.get("speech") or answer.get("text") or "")
|
||||
display = answer.get("display") or {
|
||||
"type": "text",
|
||||
"text": speech
|
||||
}
|
||||
else:
|
||||
speech = str(answer or "")
|
||||
display = {"type": "text", "text": speech}
|
||||
|
||||
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
|
||||
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
|
||||
alfred_context_images.schedule_context_image(
|
||||
r.text, speech, socketio,
|
||||
lambda: STATE.get("transcript") == r.text,
|
||||
) # ALFRED_TIMERS_CONTEXT_V1
|
||||
|
||||
# Si le MCP ComfyUI a affiché une image pendant la requête,
|
||||
# ne pas l'écraser par la réponse textuelle d'Hermes.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == image_revision_before:
|
||||
STATE["content"] = display
|
||||
alfred_features.publish_voice_display(display)
|
||||
socketio.emit("alfred_render", display)
|
||||
else:
|
||||
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
|
||||
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
try:
|
||||
started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
if started:
|
||||
done.wait(timeout=120)
|
||||
finally:
|
||||
if not duplex_monitor.interrupted():
|
||||
duplex_monitor.stop()
|
||||
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
|
||||
if duplex_monitor.interrupted():
|
||||
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
|
||||
duplex_monitor.stop()
|
||||
else:
|
||||
# A few wake-free seconds after the assistant finishes speaking.
|
||||
pending_duplex_text = duplex_monitor.listen_conversation()
|
||||
if pending_duplex_text:
|
||||
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
|
||||
|
||||
emit_state("idle", partial="")
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": str(exc)}
|
||||
)
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
# Only the voice is cancelled; the original Hermes job is untouched.
|
||||
wake.pause()
|
||||
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
|
||||
daemon=True, name='dona-duplex-followup').start()
|
||||
elif not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
print("[ALFRED] Wake word réactivé", flush=True)
|
||||
|
||||
|
||||
|
||||
def on_wake(score):
|
||||
if audio_settings.mic_muted():
|
||||
return
|
||||
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
|
||||
threading.Thread(target=conversation,args=(score,),daemon=True).start()
|
||||
|
||||
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
|
||||
|
||||
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
|
||||
@app.get('/api/duplex/status')
|
||||
def duplex_status():
|
||||
return jsonify(duplex_monitor.status())
|
||||
|
||||
@app.get('/api/audio/settings')
|
||||
def get_audio_settings():
|
||||
return jsonify(audio_settings.get_settings())
|
||||
|
||||
@app.post('/api/audio/settings')
|
||||
def set_audio_settings():
|
||||
data = request.get_json(silent=True)
|
||||
try:
|
||||
old = audio_settings.get_settings()
|
||||
new = audio_settings.update_settings(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'ALSA : {exc}'}), 500
|
||||
if new['mic_muted'] != old['mic_muted']:
|
||||
if new['mic_muted']:
|
||||
mic_stop_event.set()
|
||||
wake.pause()
|
||||
else:
|
||||
mic_stop_event.clear()
|
||||
if not conversation_lock.locked():
|
||||
wake.resume()
|
||||
socketio.emit('audio_settings', new)
|
||||
return jsonify(new)
|
||||
|
||||
@app.get('/api/camera/settings')
|
||||
def get_camera_settings():
|
||||
try:
|
||||
return jsonify(camera_settings.get_settings())
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/settings')
|
||||
def set_camera_settings():
|
||||
# Même garde-fou que les commandes calendrier existantes.
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'JSON invalide.'}), 400
|
||||
try:
|
||||
result = camera_settings.update_setting(data.get('control'), data.get('value'))
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/reset')
|
||||
def reset_camera_settings():
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
try:
|
||||
result = camera_settings.reset_defaults()
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.get('/api/camera/stream')
|
||||
def camera_stream():
|
||||
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
|
||||
try:
|
||||
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
|
||||
except (ImportError, ValueError, RuntimeError, OSError) as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
return Response(
|
||||
frames,
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame',
|
||||
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
|
||||
)
|
||||
|
||||
# ALFRED_FEATURES_V45 init
|
||||
alfred_features.init_app(app, ask_hermes, socketio=socketio)
|
||||
|
||||
@app.get("/")
|
||||
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
|
||||
@app.get("/api/state")
|
||||
def state():
|
||||
with STATE_LOCK:
|
||||
snapshot = dict(STATE)
|
||||
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
|
||||
|
||||
@app.post("/api/state")
|
||||
def set_state():
|
||||
data = request.get_json(force=True)
|
||||
with STATE_LOCK:
|
||||
STATE.update(data)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
return jsonify({"ok": True})
|
||||
@app.post("/api/render")
|
||||
def render_payload():
|
||||
global IMAGE_REVISION
|
||||
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||
if token and not hmac.compare_digest(
|
||||
request.headers.get("X-Alfred-Render-Token", ""), token
|
||||
):
|
||||
return jsonify({"ok": False, "error": "Non autorisé"}), 401
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
|
||||
if data.get("type") == "image":
|
||||
image_url = data.get("url")
|
||||
if not isinstance(image_url, str) or not (
|
||||
image_url.startswith(("http://", "https://", "/"))
|
||||
and not image_url.startswith("//")
|
||||
):
|
||||
return jsonify({"ok": False, "error": "URL image invalide"}), 400
|
||||
|
||||
with STATE_LOCK:
|
||||
if data.get("type") == "image":
|
||||
IMAGE_REVISION += 1
|
||||
STATE["content"] = data
|
||||
socketio.emit("alfred_render", data)
|
||||
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
|
||||
# --- Agenda / capteurs / caméras Home Assistant ---
|
||||
def _calendar_range():
|
||||
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
|
||||
now = datetime.now(tz)
|
||||
def parse(name, default):
|
||||
raw = request.args.get(name)
|
||||
if not raw:return default
|
||||
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
|
||||
if value.tzinfo is None: value = value.replace(tzinfo=tz)
|
||||
return value.astimezone(tz)
|
||||
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
|
||||
end = parse('end',start+timedelta(days=7))
|
||||
return start,end
|
||||
|
||||
def _json_error(exc):
|
||||
code = 400 if isinstance(exc,ValueError) else 502
|
||||
return jsonify({'error':str(exc)}),code
|
||||
|
||||
def _writes_enabled():
|
||||
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
|
||||
|
||||
def _same_origin_json():
|
||||
# POSTs require a custom header and same-origin browser requests.
|
||||
if request.headers.get('X-Alfred-Request') != '1':return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
|
||||
return True
|
||||
|
||||
@app.get('/api/calendar/calendars')
|
||||
def calendars_list():
|
||||
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/calendar/events')
|
||||
def calendar_events():
|
||||
try:
|
||||
start,end=_calendar_range()
|
||||
selected=request.args.getlist('calendar_id')
|
||||
return jsonify(ha_views.get_events(start,end,selected or None))
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/create')
|
||||
def calendar_create():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
def read_dt(key):
|
||||
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
|
||||
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
||||
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
|
||||
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
|
||||
str(data.get('location') or ''),bool(data.get('all_day',False)))
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_create()
|
||||
socketio.emit('calendar_changed', {'type':'created'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/delete')
|
||||
def calendar_delete():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
|
||||
start=datetime.fromisoformat(str(data['start']))
|
||||
end=datetime.fromisoformat(str(data['end']))
|
||||
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
|
||||
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
|
||||
rid=str(data.get('recurrence_id') or '')
|
||||
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
|
||||
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
|
||||
result=ha_views.delete_event(eid,uid,rid or None)
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_delete()
|
||||
socketio.emit('calendar_changed',{'type':'deleted'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather')
|
||||
def ha_weather_data():
|
||||
# Read-only route, token always stays on the Alfred server.
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
return jsonify(ha_weather.get_weather(q))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather/outfits')
|
||||
def ha_weather_outfits_data():
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
|
||||
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
|
||||
except (ha_views.HAError, ValueError, RuntimeError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperatures')
|
||||
def ha_temperatures():
|
||||
try:return jsonify({'items':ha_views.get_temperatures()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperature/history')
|
||||
def ha_temperature_history():
|
||||
try:
|
||||
eid=request.args.get('entity_id','')
|
||||
hours=int(request.args.get('hours','24'))
|
||||
return jsonify({'items':ha_views.temperature_history(eid,hours)})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/cameras')
|
||||
def ha_cameras():
|
||||
try:return jsonify({'items':ha_views.list_cameras()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
|
||||
def ha_camera_snapshot(entity_id):
|
||||
try:
|
||||
binary,mime=ha_views.camera_snapshot(entity_id)
|
||||
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/ui/home')
|
||||
def ui_home():
|
||||
with STATE_LOCK:
|
||||
STATE['content']={'type':'home','text':'À ton écoute.'}
|
||||
socketio.emit('alfred_render',STATE['content'])
|
||||
return jsonify({'ok':True})
|
||||
|
||||
@app.post("/api/audio-level")
|
||||
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
|
||||
@app.get("/api/wake/status")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
|
||||
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
|
||||
def _alfred_dashboard_quick_command(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
|
||||
def _run():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_image_revision_before = IMAGE_REVISION
|
||||
emit_state('thinking', transcript=prompt, partial='',
|
||||
content={'type':'text', 'text':prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _image_revision_before:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
if _speech and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_done = threading.Event()
|
||||
speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
except Exception as _exc:
|
||||
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':str(_exc)})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
|
||||
return True
|
||||
|
||||
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
|
||||
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_START
|
||||
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
|
||||
_alfred_register_home_glass(app)
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_END
|
||||
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_START
|
||||
from services.alfred_lists import register_lists as _alfred_register_lists
|
||||
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
|
||||
_alfred_register_lists(app, socketio)
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_GLASS_V2_BACKEND_START
|
||||
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
|
||||
_alfred_register_logs_v2(app)
|
||||
# ALFRED_GLASS_V2_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
|
||||
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
|
||||
_alfred_identity_register(app)
|
||||
|
||||
|
||||
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
|
||||
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
|
||||
_alfred_developer_register(app)
|
||||
|
||||
|
||||
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
|
||||
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
|
||||
_register_hermes_new_session(app)
|
||||
|
||||
|
||||
# DONA_FAMILY_UI_V1_START
|
||||
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
|
||||
def _dona_submit_typed(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _dona_run_typed():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_before_image = IMAGE_REVISION
|
||||
emit_state('thinking', partial='', transcript=prompt,
|
||||
content={'type': 'text', 'text': prompt})
|
||||
# DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes.
|
||||
if _dona_nav.is_request(prompt):
|
||||
_dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK)
|
||||
emit_state('idle',partial='')
|
||||
return
|
||||
|
||||
# DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes.
|
||||
if _dona_youtube.is_search(prompt):
|
||||
_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK)
|
||||
emit_state('idle',partial='')
|
||||
return
|
||||
if _dona_youtube.is_control(prompt,_dona_youtube.player_active()):
|
||||
_dona_youtube.control_and_emit(prompt,socketio)
|
||||
emit_state('idle',partial='')
|
||||
return
|
||||
|
||||
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
|
||||
if _dona_dynamic.is_dynamic_request(prompt):
|
||||
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
|
||||
_payload = {'type':'dona_dynamic','dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _payload
|
||||
socketio.emit('alfred_render',_payload)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _before_image:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
# Deliberately no speak_async(): typed interaction is silent.
|
||||
emit_state('idle', partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
|
||||
return True
|
||||
|
||||
from services.dona_family_ui import register_dona_family as _dona_register_family
|
||||
_dona_register_family(app, adult_command=_dona_submit_typed)
|
||||
# DONA_FAMILY_UI_V1_END
|
||||
|
||||
# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés.
|
||||
from services import dona_dynamic as _dona_dynamic
|
||||
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
|
||||
from services import dona_dashboard_agent as _dona_dashboard_agent
|
||||
|
||||
def _dona_dynamic_submit(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _work():
|
||||
try:
|
||||
wake.pause()
|
||||
emit_state('thinking',transcript=prompt,partial='')
|
||||
_board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard)
|
||||
_payload={'type':'dona_dynamic','dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content']=_payload
|
||||
socketio.emit('alfred_render',_payload)
|
||||
emit_state('idle',partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True)
|
||||
emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start()
|
||||
return True
|
||||
|
||||
_dona_dynamic.register_dynamic(app,_dona_dynamic_submit)
|
||||
|
||||
# DONA_NAV_V83: géocodage, carte et transfert Waze.
|
||||
from services import dona_navigation as _dona_nav
|
||||
_dona_nav.register_navigation(app,socketio)
|
||||
|
||||
# DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé.
|
||||
from services import dona_youtube as _dona_youtube
|
||||
_dona_youtube.register_youtube(app,socketio)
|
||||
|
||||
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
|
||||
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
|
||||
_register_dona_spotify(app)
|
||||
|
||||
# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée.
|
||||
from services.dona_face import (register_face as _dona_register_face,
|
||||
recognize_once as _dona_face_recognize, is_face_request as _dona_face_request)
|
||||
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "9-suite",
|
||||
"app": "/opt/alfred/app",
|
||||
"modified": [
|
||||
"app.py",
|
||||
"templates/index.html"
|
||||
],
|
||||
"created": [
|
||||
"services/dona_suite_v9.py",
|
||||
"static/js/dona-suite-v9.js",
|
||||
"static/css/dona-suite-v9.css"
|
||||
],
|
||||
"before": {
|
||||
"app.py": "cb5eda8f1b56200041ed67817f3cea9fcfd5de22e5358b99c34291d9f7921fb6",
|
||||
"templates/index.html": "4b99c6e6a616f0d1e2f7ace62736a31f598ed49e48157f8a9b0ffdbd1de322cc"
|
||||
},
|
||||
"after": {
|
||||
"services/dona_suite_v9.py": "0f34c24d604509fb88b1dc279b65fb977a5d99fb245aa066e7226aad9736067b",
|
||||
"static/js/dona-suite-v9.js": "30ce72dfcdaa12748d7d7c8a4f063e13fe489c93e0bc088b4ff9042d61db3132",
|
||||
"static/css/dona-suite-v9.css": "bd1939c58750d95f5139c727230152476ecc638d5ffaa11c34c5de7faa184b35",
|
||||
"app.py": "45b3c8229dfdccd7a130657cb45eaa4f7d036d32a935b52e162d6ba256cdd6f2",
|
||||
"templates/index.html": "64bd59ee95c3df351ba7874bae6ebd358e0809746619ec889d63f55e204112e1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
|
||||
<!-- ALFRED_MAIN_VISUAL_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
|
||||
<!-- ALFRED_LISTES_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
|
||||
<!-- ALFRED_GLASS_V2 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
|
||||
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
|
||||
<!-- ALFRED_REPONSE_CLOSE_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
|
||||
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
|
||||
<!-- DONA_FAMILY_UI_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
|
||||
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
|
||||
<!-- DONA_V7_RELEASE -->
|
||||
<link rel="stylesheet" href="/static/css/dona-v7.css?v=7">
|
||||
<!-- DONA_V8_DYNAMIC -->
|
||||
<link rel="stylesheet" href="/static/css/dona-dynamic-v8.css?v=8">
|
||||
<!-- DONA_V8_1_ISOLATION -->
|
||||
<link rel="stylesheet" href="/static/css/dona-header-v8-1.css?v=81">
|
||||
<!-- DONA_YOUTUBE_V82 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-youtube-v8-2.css?v=82">
|
||||
<!-- DONA_NAV_V83 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-navigation-v8-3.css?v=83">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
|
||||
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
|
||||
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
|
||||
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
|
||||
<div id="alfredTimersList"></div>
|
||||
</aside>
|
||||
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
|
||||
|
||||
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
|
||||
aria-modal="true" hidden>
|
||||
<article class="alfred-context-card">
|
||||
<button id="alfredContextClose" class="alfred-context-close" type="button"
|
||||
aria-label="Fermer l'illustration" title="Fermer">×</button>
|
||||
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
|
||||
<div class="alfred-context-details">
|
||||
<h2 id="alfredContextTitle"></h2>
|
||||
<p id="alfredContextText"></p>
|
||||
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<script src="/static/js/alfred-timers-context.js"></script>
|
||||
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
|
||||
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
|
||||
<script src="/static/js/alfred-main-v1.js?v=1"></script>
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<script src="/static/js/alfred-maison-card.js?v=1"></script>
|
||||
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
|
||||
<script src="/static/js/alfred-main-v2.js?v=2"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
|
||||
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
|
||||
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
|
||||
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
|
||||
<script defer src="/static/js/dona-reply-auto-close-v7.js?v=7"></script>
|
||||
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
|
||||
<script defer src="/static/js/dona-spotify.js?v=6"></script>
|
||||
<!-- dona-ui-fix start -->
|
||||
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
|
||||
<!-- dona-ui-fix end -->
|
||||
<script defer src="/static/js/dona-face-v7.js?v=7"></script>
|
||||
<script defer src="/static/js/dona-dynamic-v8.js?v=8"></script>
|
||||
<script defer src="/static/js/dona-header-v8-1.js?v=81"></script>
|
||||
<script defer src="/static/js/dona-youtube-v8-2.js?v=82"></script>
|
||||
<script defer src="/static/js/dona-navigation-v8-3.js?v=83"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,713 @@
|
||||
|
||||
import os, threading, hmac
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Flask, render_template, request, jsonify, Response
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger la configuration AVANT les imports des services
|
||||
load_dotenv("/opt/alfred/.env")
|
||||
load_dotenv(
|
||||
"/opt/alfred/app/config/control_center.env",
|
||||
override=True
|
||||
)
|
||||
|
||||
from services.audio import listen_once, speak_async, tts_backend_detect
|
||||
from services.wakeword import WakeWordListener
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
from services.piper_tts import stop_playback as duplex_stop_piper
|
||||
from services import admin_console # ALFRED_ADMIN_PAGES_V1
|
||||
from services.hermes_client import ask_hermes
|
||||
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
|
||||
from services import alfred_features # ALFRED_FEATURES_V45
|
||||
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
||||
from services.camera import video_stream
|
||||
from services import ha_views, alfred_views_controller
|
||||
from services import camera_settings, ha_weather, weather_outfits
|
||||
|
||||
load_dotenv()
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
|
||||
##
|
||||
from services.audio import get_vosk_model
|
||||
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
|
||||
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
|
||||
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
|
||||
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||
##
|
||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
||||
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||
STATE_LOCK = threading.RLock()
|
||||
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
|
||||
conversation_lock=threading.Lock()
|
||||
mic_stop_event=threading.Event()
|
||||
# Service temps autonome (survit aux conversations Hermes)
|
||||
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
|
||||
audio_settings.initialize()
|
||||
audio_settings.update_settings({"mic_gain": 30})
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
|
||||
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
|
||||
on_interrupt=duplex_stop_piper,
|
||||
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
|
||||
|
||||
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
|
||||
camera_settings.restore_saved_settings()
|
||||
|
||||
def emit_state(mode=None, **extra):
|
||||
with STATE_LOCK:
|
||||
if mode: STATE["mode"] = mode
|
||||
STATE.update(extra)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return
|
||||
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
|
||||
try:
|
||||
emit_state("listening", partial="", transcript="", wake_score=score)
|
||||
|
||||
print("[ALFRED] Début écoute STT", flush=True)
|
||||
|
||||
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
from services.audio import STTResult
|
||||
r = STTResult(text=injected_text)
|
||||
else:
|
||||
r = listen_once(
|
||||
stop_event=mic_stop_event,
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
|
||||
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
|
||||
|
||||
if audio_settings.mic_muted():
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
if r.error:
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": r.error}
|
||||
)
|
||||
return
|
||||
|
||||
if not r.text:
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
emit_state(
|
||||
"thinking",
|
||||
partial="",
|
||||
transcript=r.text,
|
||||
content={"type": "text", "text": r.text}
|
||||
)
|
||||
|
||||
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
|
||||
if _dona_dynamic.is_dynamic_request(r.text):
|
||||
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
|
||||
_payload = {'type':'dona_dynamic', 'dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _payload
|
||||
socketio.emit('alfred_render', _payload)
|
||||
if not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_finished = threading.Event()
|
||||
_started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set)
|
||||
if _started: _finished.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
|
||||
# Vue dynamique : commandes déterministes exécutées sans faire
|
||||
# dépendre l'affichage des capacités ou sessions Hermes.
|
||||
visual = None
|
||||
try:
|
||||
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
|
||||
visual = ({"type":"text", "text":str(exc)}, str(exc))
|
||||
|
||||
if visual is not None:
|
||||
content, speech = visual
|
||||
with STATE_LOCK:
|
||||
STATE["content"] = content
|
||||
socketio.emit("alfred_render", content)
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
done.wait(timeout=120)
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_START
|
||||
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
|
||||
try:
|
||||
_lists_reply = _alfred_handle_list_voice(
|
||||
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
|
||||
except Exception as _lists_error:
|
||||
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
|
||||
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
|
||||
if _lists_reply is not None:
|
||||
_lists_display = {'type':'text', 'text':_lists_reply}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _lists_display
|
||||
socketio.emit('alfred_render', _lists_display)
|
||||
if _lists_reply and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_lists_done = threading.Event()
|
||||
speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
_lists_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_END
|
||||
|
||||
print("[ALFRED] Envoi à Hermes", flush=True)
|
||||
# ComfyUI publie l'image pendant cet appel Hermes.
|
||||
with STATE_LOCK:
|
||||
image_revision_before = IMAGE_REVISION
|
||||
# ALFRED_FEATURES_V45 voice opt-in background mode
|
||||
if alfred_features.should_run_in_background(r.text):
|
||||
try:
|
||||
alfred_features.submit(r.text, source='voice')
|
||||
emit_state('idle', partial='', content={
|
||||
'type': 'text',
|
||||
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
|
||||
})
|
||||
except ValueError as exc:
|
||||
emit_state('error', content={'type': 'text', 'text': str(exc)})
|
||||
return
|
||||
# DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal.
|
||||
answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(r.text)
|
||||
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
|
||||
if isinstance(answer, dict):
|
||||
speech = str(answer.get("speech") or answer.get("text") or "")
|
||||
display = answer.get("display") or {
|
||||
"type": "text",
|
||||
"text": speech
|
||||
}
|
||||
else:
|
||||
speech = str(answer or "")
|
||||
display = {"type": "text", "text": speech}
|
||||
|
||||
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
|
||||
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
|
||||
alfred_context_images.schedule_context_image(
|
||||
r.text, speech, socketio,
|
||||
lambda: STATE.get("transcript") == r.text,
|
||||
) # ALFRED_TIMERS_CONTEXT_V1
|
||||
|
||||
# Si le MCP ComfyUI a affiché une image pendant la requête,
|
||||
# ne pas l'écraser par la réponse textuelle d'Hermes.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == image_revision_before:
|
||||
STATE["content"] = display
|
||||
alfred_features.publish_voice_display(display)
|
||||
socketio.emit("alfred_render", display)
|
||||
else:
|
||||
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
|
||||
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
try:
|
||||
started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
if started:
|
||||
done.wait(timeout=120)
|
||||
finally:
|
||||
if not duplex_monitor.interrupted():
|
||||
duplex_monitor.stop()
|
||||
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
|
||||
if duplex_monitor.interrupted():
|
||||
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
|
||||
duplex_monitor.stop()
|
||||
else:
|
||||
# A few wake-free seconds after the assistant finishes speaking.
|
||||
pending_duplex_text = duplex_monitor.listen_conversation()
|
||||
if pending_duplex_text:
|
||||
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
|
||||
|
||||
emit_state("idle", partial="")
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": str(exc)}
|
||||
)
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
# Only the voice is cancelled; the original Hermes job is untouched.
|
||||
wake.pause()
|
||||
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
|
||||
daemon=True, name='dona-duplex-followup').start()
|
||||
elif not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
print("[ALFRED] Wake word réactivé", flush=True)
|
||||
|
||||
|
||||
|
||||
def on_wake(score):
|
||||
if audio_settings.mic_muted():
|
||||
return
|
||||
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
|
||||
threading.Thread(target=conversation,args=(score,),daemon=True).start()
|
||||
|
||||
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
|
||||
|
||||
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
|
||||
@app.get('/api/duplex/status')
|
||||
def duplex_status():
|
||||
return jsonify(duplex_monitor.status())
|
||||
|
||||
@app.get('/api/audio/settings')
|
||||
def get_audio_settings():
|
||||
return jsonify(audio_settings.get_settings())
|
||||
|
||||
@app.post('/api/audio/settings')
|
||||
def set_audio_settings():
|
||||
data = request.get_json(silent=True)
|
||||
try:
|
||||
old = audio_settings.get_settings()
|
||||
new = audio_settings.update_settings(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'ALSA : {exc}'}), 500
|
||||
if new['mic_muted'] != old['mic_muted']:
|
||||
if new['mic_muted']:
|
||||
mic_stop_event.set()
|
||||
wake.pause()
|
||||
else:
|
||||
mic_stop_event.clear()
|
||||
if not conversation_lock.locked():
|
||||
wake.resume()
|
||||
socketio.emit('audio_settings', new)
|
||||
return jsonify(new)
|
||||
|
||||
@app.get('/api/camera/settings')
|
||||
def get_camera_settings():
|
||||
try:
|
||||
return jsonify(camera_settings.get_settings())
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/settings')
|
||||
def set_camera_settings():
|
||||
# Même garde-fou que les commandes calendrier existantes.
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'JSON invalide.'}), 400
|
||||
try:
|
||||
result = camera_settings.update_setting(data.get('control'), data.get('value'))
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/reset')
|
||||
def reset_camera_settings():
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
try:
|
||||
result = camera_settings.reset_defaults()
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.get('/api/camera/stream')
|
||||
def camera_stream():
|
||||
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
|
||||
try:
|
||||
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
|
||||
except (ImportError, ValueError, RuntimeError, OSError) as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
return Response(
|
||||
frames,
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame',
|
||||
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
|
||||
)
|
||||
|
||||
# ALFRED_FEATURES_V45 init
|
||||
alfred_features.init_app(app, ask_hermes, socketio=socketio)
|
||||
|
||||
@app.get("/")
|
||||
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
|
||||
@app.get("/api/state")
|
||||
def state():
|
||||
with STATE_LOCK:
|
||||
snapshot = dict(STATE)
|
||||
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
|
||||
|
||||
@app.post("/api/state")
|
||||
def set_state():
|
||||
data = request.get_json(force=True)
|
||||
with STATE_LOCK:
|
||||
STATE.update(data)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
return jsonify({"ok": True})
|
||||
@app.post("/api/render")
|
||||
def render_payload():
|
||||
global IMAGE_REVISION
|
||||
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||
if token and not hmac.compare_digest(
|
||||
request.headers.get("X-Alfred-Render-Token", ""), token
|
||||
):
|
||||
return jsonify({"ok": False, "error": "Non autorisé"}), 401
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
|
||||
if data.get("type") == "image":
|
||||
image_url = data.get("url")
|
||||
if not isinstance(image_url, str) or not (
|
||||
image_url.startswith(("http://", "https://", "/"))
|
||||
and not image_url.startswith("//")
|
||||
):
|
||||
return jsonify({"ok": False, "error": "URL image invalide"}), 400
|
||||
|
||||
with STATE_LOCK:
|
||||
if data.get("type") == "image":
|
||||
IMAGE_REVISION += 1
|
||||
STATE["content"] = data
|
||||
socketio.emit("alfred_render", data)
|
||||
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
|
||||
# --- Agenda / capteurs / caméras Home Assistant ---
|
||||
def _calendar_range():
|
||||
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
|
||||
now = datetime.now(tz)
|
||||
def parse(name, default):
|
||||
raw = request.args.get(name)
|
||||
if not raw:return default
|
||||
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
|
||||
if value.tzinfo is None: value = value.replace(tzinfo=tz)
|
||||
return value.astimezone(tz)
|
||||
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
|
||||
end = parse('end',start+timedelta(days=7))
|
||||
return start,end
|
||||
|
||||
def _json_error(exc):
|
||||
code = 400 if isinstance(exc,ValueError) else 502
|
||||
return jsonify({'error':str(exc)}),code
|
||||
|
||||
def _writes_enabled():
|
||||
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
|
||||
|
||||
def _same_origin_json():
|
||||
# POSTs require a custom header and same-origin browser requests.
|
||||
if request.headers.get('X-Alfred-Request') != '1':return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
|
||||
return True
|
||||
|
||||
@app.get('/api/calendar/calendars')
|
||||
def calendars_list():
|
||||
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/calendar/events')
|
||||
def calendar_events():
|
||||
try:
|
||||
start,end=_calendar_range()
|
||||
selected=request.args.getlist('calendar_id')
|
||||
return jsonify(ha_views.get_events(start,end,selected or None))
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/create')
|
||||
def calendar_create():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
def read_dt(key):
|
||||
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
|
||||
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
||||
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
|
||||
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
|
||||
str(data.get('location') or ''),bool(data.get('all_day',False)))
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_create()
|
||||
socketio.emit('calendar_changed', {'type':'created'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/delete')
|
||||
def calendar_delete():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
|
||||
start=datetime.fromisoformat(str(data['start']))
|
||||
end=datetime.fromisoformat(str(data['end']))
|
||||
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
|
||||
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
|
||||
rid=str(data.get('recurrence_id') or '')
|
||||
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
|
||||
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
|
||||
result=ha_views.delete_event(eid,uid,rid or None)
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_delete()
|
||||
socketio.emit('calendar_changed',{'type':'deleted'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather')
|
||||
def ha_weather_data():
|
||||
# Read-only route, token always stays on the Alfred server.
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
return jsonify(ha_weather.get_weather(q))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather/outfits')
|
||||
def ha_weather_outfits_data():
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
|
||||
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
|
||||
except (ha_views.HAError, ValueError, RuntimeError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperatures')
|
||||
def ha_temperatures():
|
||||
try:return jsonify({'items':ha_views.get_temperatures()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperature/history')
|
||||
def ha_temperature_history():
|
||||
try:
|
||||
eid=request.args.get('entity_id','')
|
||||
hours=int(request.args.get('hours','24'))
|
||||
return jsonify({'items':ha_views.temperature_history(eid,hours)})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/cameras')
|
||||
def ha_cameras():
|
||||
try:return jsonify({'items':ha_views.list_cameras()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
|
||||
def ha_camera_snapshot(entity_id):
|
||||
try:
|
||||
binary,mime=ha_views.camera_snapshot(entity_id)
|
||||
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/ui/home')
|
||||
def ui_home():
|
||||
with STATE_LOCK:
|
||||
STATE['content']={'type':'home','text':'À ton écoute.'}
|
||||
socketio.emit('alfred_render',STATE['content'])
|
||||
return jsonify({'ok':True})
|
||||
|
||||
@app.post("/api/audio-level")
|
||||
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
|
||||
@app.get("/api/wake/status")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
|
||||
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
|
||||
def _alfred_dashboard_quick_command(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
|
||||
def _run():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_image_revision_before = IMAGE_REVISION
|
||||
emit_state('thinking', transcript=prompt, partial='',
|
||||
content={'type':'text', 'text':prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _image_revision_before:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
if _speech and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_done = threading.Event()
|
||||
speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
|
||||
_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
except Exception as _exc:
|
||||
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':str(_exc)})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
|
||||
return True
|
||||
|
||||
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
|
||||
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_START
|
||||
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
|
||||
_alfred_register_home_glass(app)
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_END
|
||||
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_START
|
||||
from services.alfred_lists import register_lists as _alfred_register_lists
|
||||
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
|
||||
_alfred_register_lists(app, socketio)
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_GLASS_V2_BACKEND_START
|
||||
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
|
||||
_alfred_register_logs_v2(app)
|
||||
# ALFRED_GLASS_V2_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
|
||||
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
|
||||
_alfred_identity_register(app)
|
||||
|
||||
|
||||
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
|
||||
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
|
||||
_alfred_developer_register(app)
|
||||
|
||||
|
||||
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
|
||||
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
|
||||
_register_hermes_new_session(app)
|
||||
|
||||
|
||||
# DONA_FAMILY_UI_V1_START
|
||||
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
|
||||
def _dona_submit_typed(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _dona_run_typed():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_before_image = IMAGE_REVISION
|
||||
emit_state('thinking', partial='', transcript=prompt,
|
||||
content={'type': 'text', 'text': prompt})
|
||||
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
|
||||
if _dona_dynamic.is_dynamic_request(prompt):
|
||||
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
|
||||
_payload = {'type':'dona_dynamic','dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _payload
|
||||
socketio.emit('alfred_render',_payload)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _before_image:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
# Deliberately no speak_async(): typed interaction is silent.
|
||||
emit_state('idle', partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
|
||||
return True
|
||||
|
||||
from services.dona_family_ui import register_dona_family as _dona_register_family
|
||||
_dona_register_family(app, adult_command=_dona_submit_typed)
|
||||
# DONA_FAMILY_UI_V1_END
|
||||
|
||||
# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés.
|
||||
from services import dona_dynamic as _dona_dynamic
|
||||
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
|
||||
from services import dona_dashboard_agent as _dona_dashboard_agent
|
||||
|
||||
def _dona_dynamic_submit(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _work():
|
||||
try:
|
||||
wake.pause()
|
||||
emit_state('thinking',transcript=prompt,partial='')
|
||||
_board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard)
|
||||
_payload={'type':'dona_dynamic','dashboard':_board}
|
||||
with STATE_LOCK:
|
||||
STATE['content']=_payload
|
||||
socketio.emit('alfred_render',_payload)
|
||||
emit_state('idle',partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True)
|
||||
emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start()
|
||||
return True
|
||||
|
||||
_dona_dynamic.register_dynamic(app,_dona_dynamic_submit)
|
||||
|
||||
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
|
||||
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
|
||||
_register_dona_spotify(app)
|
||||
|
||||
# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée.
|
||||
from services.dona_face import (register_face as _dona_register_face,
|
||||
recognize_once as _dona_face_recognize, is_face_request as _dona_face_request)
|
||||
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "8.2-youtube",
|
||||
"app": "/opt/alfred/app",
|
||||
"modified": [
|
||||
"app.py",
|
||||
"templates/index.html"
|
||||
],
|
||||
"created": [
|
||||
"services/dona_youtube.py",
|
||||
"static/js/dona-youtube-v8-2.js",
|
||||
"static/css/dona-youtube-v8-2.css"
|
||||
],
|
||||
"before": {
|
||||
"app.py": "34464a73b500dd95353b83fc5896f60503193a1dfb1321b2069f5c0ec90e8ff2",
|
||||
"templates/index.html": "2506dc710ef641bc7b960124191b3c2a6821bdbd8e0e97dacd52248b57b6b2c1"
|
||||
},
|
||||
"after": {
|
||||
"services/dona_youtube.py": "a16d6261dd997d64cc83a6a40fe3cb1dcaa950788f2ccbbbf644103ca68d57f0",
|
||||
"static/js/dona-youtube-v8-2.js": "03194da41824bc4e5de4b5e090189d8030cb7c8eb68c93e9a5d7b664f5b2dfd6",
|
||||
"static/css/dona-youtube-v8-2.css": "787f2d17dd164544a974f38d21318d2f4e46074a887399fda136155eaa745acd",
|
||||
"app.py": "33702677173cad2c0920dd5a276293b072d23bb39a339adfad2891c2aa36e874",
|
||||
"templates/index.html": "9b50d8e21c4464c307e6f2a09f52642830a8f67455aded9b2a594d78755e4a0c"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
|
||||
<!-- ALFRED_MAIN_VISUAL_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
|
||||
<!-- ALFRED_LISTES_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
|
||||
<!-- ALFRED_GLASS_V2 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
|
||||
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
|
||||
<!-- ALFRED_REPONSE_CLOSE_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
|
||||
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
|
||||
<!-- DONA_FAMILY_UI_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
|
||||
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
|
||||
<!-- DONA_V7_RELEASE -->
|
||||
<link rel="stylesheet" href="/static/css/dona-v7.css?v=7">
|
||||
<!-- DONA_V8_DYNAMIC -->
|
||||
<link rel="stylesheet" href="/static/css/dona-dynamic-v8.css?v=8">
|
||||
<!-- DONA_V8_1_ISOLATION -->
|
||||
<link rel="stylesheet" href="/static/css/dona-header-v8-1.css?v=81">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
|
||||
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
|
||||
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
|
||||
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
|
||||
<div id="alfredTimersList"></div>
|
||||
</aside>
|
||||
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
|
||||
|
||||
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
|
||||
aria-modal="true" hidden>
|
||||
<article class="alfred-context-card">
|
||||
<button id="alfredContextClose" class="alfred-context-close" type="button"
|
||||
aria-label="Fermer l'illustration" title="Fermer">×</button>
|
||||
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
|
||||
<div class="alfred-context-details">
|
||||
<h2 id="alfredContextTitle"></h2>
|
||||
<p id="alfredContextText"></p>
|
||||
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<script src="/static/js/alfred-timers-context.js"></script>
|
||||
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
|
||||
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
|
||||
<script src="/static/js/alfred-main-v1.js?v=1"></script>
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<script src="/static/js/alfred-maison-card.js?v=1"></script>
|
||||
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
|
||||
<script src="/static/js/alfred-main-v2.js?v=2"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
|
||||
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
|
||||
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
|
||||
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
|
||||
<script defer src="/static/js/dona-reply-auto-close-v7.js?v=7"></script>
|
||||
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
|
||||
<script defer src="/static/js/dona-spotify.js?v=6"></script>
|
||||
<!-- dona-ui-fix start -->
|
||||
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
|
||||
<!-- dona-ui-fix end -->
|
||||
<script defer src="/static/js/dona-face-v7.js?v=7"></script>
|
||||
<script defer src="/static/js/dona-dynamic-v8.js?v=8"></script>
|
||||
<script defer src="/static/js/dona-header-v8-1.js?v=81"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user