[FEAT] ARIA voce naturale (proxy TTS nexus-voice-ms) + toggle voce — Fase C

Porting voce TRPG/AllTax (ElevenLabs "Sarah") sull'ARIA chat NIS2:
- AiController::tts() + route POST /api/ai/tts: proxy same-origin a nexus-voice-ms
  (config VOICE_MS_URL=172.21.0.1:4215, voce TTS_VOICE_ID=EXAVITQu4vr4xnSDxMaL,
  eleven_turbo_v2_5). requireAuth, cache MP3 sha256 (sys_get_temp_dir/nis2-tts-cache),
  validazioni (text<=800, voice_id allowlist), stream audio/mpeg, 502 se upstream non-audio.
- common.js: speak(answer) dopo ogni risposta ARIA (voce discreta vol .55, markdown
  strip), toggle 🔊/🔇 nell'header (muto persistito localStorage nis2_aria_voice),
  autoplay best-effort.
Smoke: voice-ms da nis2-app HTTP 200 audio/mpeg 19KB; /api/ai/tts 401 senza auth.
Reachability OK (no allowlist VOX necessaria). Cache-buster common.js -> 20260624g.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-24 08:58:05 +02:00
co-authored by Claude Opus 4.8
parent fe7d0555c6
commit a982797fa2
43 changed files with 161 additions and 39 deletions
+41
View File
@@ -1014,6 +1014,42 @@ function switchLang(lang) {
+ "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;";
document.body.appendChild(panel);
// ── Voce ARIA naturale (TTS via /api/ai/tts → nexus-voice-ms) ─────────────
var ttsMuted = localStorage.getItem('nis2_aria_voice') === 'off';
var ttsAudio = null;
function _ttsPlain(s) {
return String(s || '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[#*_>`|]/g, ' ')
.replace(/\s+/g, ' ').trim().slice(0, 800);
}
function speak(text) {
if (ttsMuted) return;
var clean = _ttsPlain(text);
if (!clean) return;
var tok = localStorage.getItem('nis2_access_token') || '';
fetch('/api/ai/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': tok ? ('Bearer ' + tok) : '' },
body: JSON.stringify({ text: clean })
}).then(function (r) { return r.ok ? r.blob() : null; })
.then(function (b) {
if (!b || ttsMuted) return;
try { if (ttsAudio) ttsAudio.pause(); } catch (e) {}
ttsAudio = new Audio(URL.createObjectURL(b));
ttsAudio.volume = 0.55;
ttsAudio.play().catch(function () {}); // autoplay bloccato → silenzioso
}).catch(function () {});
}
function toggleVoice() {
ttsMuted = !ttsMuted;
localStorage.setItem('nis2_aria_voice', ttsMuted ? 'off' : 'on');
if (ttsMuted && ttsAudio) { try { ttsAudio.pause(); } catch (e) {} }
var vb = document.getElementById('ai-chat-voice');
if (vb) vb.textContent = ttsMuted ? '🔇' : '🔊';
}
var STORAGE_KEY = 'nis2_ai_chat';
var history = [];
var pending = false; // ARIA sta elaborando una risposta (mostra indicatore "sta scrivendo")
@@ -1071,6 +1107,8 @@ function switchLang(lang) {
+ '<div style="display:flex;align-items:center;gap:8px;font-weight:600">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M12 3l1.8 4.6L18.5 9.5l-4.7 1.9L12 16l-1.8-4.6L5.5 9.5l4.7-1.9L12 3z"/></svg> ARIA — Assistente NIS2'
+ '</div>'
+ '<button id="ai-chat-voice" aria-label="Attiva/disattiva voce" title="Voce ARIA" style="background:none;'
+ 'border:none;color:#fff;font-size:15px;cursor:pointer;margin-right:2px">' + (ttsMuted ? '🔇' : '🔊') + '</button>'
+ '<button id="ai-chat-close" aria-label="Chiudi" style="background:none;'
+ 'border:none;color:#fff;font-size:18px;cursor:pointer">&times;</button>'
+ '</div>'
@@ -1106,6 +1144,8 @@ function switchLang(lang) {
+ '</button>'
+ '</form>';
document.getElementById('ai-chat-close').onclick = closePanel;
var _vb = document.getElementById('ai-chat-voice');
if (_vb) _vb.onclick = toggleVoice;
document.getElementById('ai-chat-form').onsubmit = onSubmit;
var inputEl = document.getElementById('ai-chat-input');
if (inputEl) {
@@ -1165,6 +1205,7 @@ function switchLang(lang) {
|| (resp && (resp.answer || resp.message || resp.text))
|| 'ARIA non ha risposto. Riprova tra poco.';
history.push({ role: 'assistant', content: answer });
try { speak(answer); } catch (e2) {} // voce naturale ARIA (se non mutata)
} catch (e) {
history.push({
role: 'assistant',