refactor(config): niente configurazione cablata — tutto parametrizzato (v0.3.0)

Prima di pubblicare: eliminati dai sorgenti tutti i valori "cablati".

- settings: default neutri (URL, voce, engine vuoti = "decide il server");
  nuove chiavi ttsVoices/ttsEngines (preset dei selettori) e vadMaxMs
  (durata massima registrazione, prima fissa a 60 s nel codice)
- ApiClient: guard su baseUrl vuoto (checkAuth/login si comportano senza
  rete), voce/engine omessi nella richiesta TTS se vuoti, Content-Type
  audio coerente col formato reale del file (ogg/wav/mp3/m4a/opus)
- SettingsPage: selettori voce/engine costruiti dai preset configurabili
  (Repeater dentro ContextMenu = pattern provato su tide) + campi per
  modificare i preset; LoginPage richiede indirizzo server non vuoto
- VoiceRecorder: tetto di registrazione da configurazione
- README: sezione "Configuration" completa; docs neutralizzate (niente
  URL/valori personali: repo public-friendly)
This commit is contained in:
2026-09-12 12:42:06 +02:00
parent 558a384339
commit a5932f0025
11 changed files with 232 additions and 64 deletions
+26 -7
View File
@@ -9,18 +9,37 @@ chat with your Hermes agent from the phone — and use it with your **voice**.
- Sessions list, new session, open any session - Sessions list, new session, open any session
- Chat with **live streaming** replies (SSE token deltas) - Chat with **live streaming** replies (SSE token deltas)
- **Voice dictation**: tap Mic, speak, tap Stop → server-side Whisper - **Voice dictation**: tap Mic, speak, tap Stop → server-side Whisper
transcription lands in the composer transcription lands in the composer (optionally sent right away)
- **Read aloud**: server-side TTS (edge engine, Italian voices) played through - **Read aloud**: server-side TTS (voice/engine configurable) played through
QMediaPlayer; optional auto-read of every completed reply QMediaPlayer; optional auto-read of every completed reply
- **Voice dialog (hands-free)**: continuous listen → transcribe → send →
read-aloud loop, auto-stopping on speech pauses (tunable)
- Cancel a running turn, resync from server state, cover page - Cancel a running turn, resync from server state, cover page
## Requirements ## Requirements
- Sailfish OS 4.4+ (developed against Sailfish 5.1) - Sailfish OS 4.4+ (developed against Sailfish 5.1)
- A running `hermes-webui` instance reachable over HTTPS (default: - A running `hermes-webui` instance reachable over HTTPS (the address is
`https://hermes.hackatoniclife.com`) configured in the app — nothing is compiled in)
- Sailjail permissions `Internet;Audio;Microphone` (declared in the .desktop) - Sailjail permissions `Internet;Audio;Microphone` (declared in the .desktop)
## Configuration
No configuration is compiled into the binary: everything lives in the app's
Settings (persisted in `~/.config/harbour/hermes.conf`, sandbox-persistent).
Defaults are neutral — empty means "the server decides" — and the login screen
requires a server address.
| Setting | Meaning |
|---|---|
| Server | Hermes Web UI address, e.g. `https://hermes.example.com` (required) |
| Read-aloud voice / TTS engine | empty = server defaults |
| Voice/engine presets | comma-separated lists used to fill the pickers |
| Read replies aloud | auto-play every reply (off by default) |
| Send right after dictation | auto-send when dictation lands (on by default) |
| Auto-stop pause / Mic sensitivity | voice-dialog tuning (1.5 s / medium) |
| Max recording time | voice-dialog safety cap (60 s) |
## Build ## Build
See [docs/BUILD.md](docs/BUILD.md) (Sailfish SDK, `sfdk build` / `sfdk deploy`) See [docs/BUILD.md](docs/BUILD.md) (Sailfish SDK, `sfdk build` / `sfdk deploy`)
@@ -29,6 +48,6 @@ speaks (validated live against a real instance).
## Status ## Status
Version 0.2.1 — protocol and C++ core validated against a real `hermes-webui` Version 0.3.0 — protocol and C++ core validated against a real `hermes-webui`
(login, sessions, streaming chat, TTS, STT); on-device testing (audio paths, (login, sessions, streaming chat, TTS, STT, voice dialog); on-device testing
Sailjail prompts, keyboard handling) still pending. still ongoing.
+4 -4
View File
@@ -75,13 +75,13 @@ richiede il probe WebUI su :8899). Esito 12/09/2026: unit 9/9, live completo OK.
## 4. Tarball per la build da Qt Creator ## 4. Tarball per la build da Qt Creator
```bash ```bash
cd /home/kaneda/workspace cd ~/workspace
tar czf harbour-hermes-0.2.1.tar.gz \ tar czf harbour-hermes-0.3.0.tar.gz \
--transform 's,^harbour-hermes,harbour-hermes-0.2.1,' \ --transform 's,^harbour-hermes,harbour-hermes-0.3.0,' \
--exclude='.git' harbour-hermes --exclude='.git' harbour-hermes
``` ```
Lo spec si aspetta la directory `harbour-hermes-0.2.1/` (pattern degli altri Lo spec si aspetta la directory `harbour-hermes-0.3.0/` (pattern degli altri
progetti: cercato in modo robusto anche per i sorgenti live di sfdk). progetti: cercato in modo robusto anche per i sorgenti live di sfdk).
## 5. Dipendenze lato server (WebUI) ## 5. Dipendenze lato server (WebUI)
+1 -1
View File
@@ -10,7 +10,7 @@ Documentazione del protocollo HTTP/SSE usato da harbour-hermes.
> al client. > al client.
Server di riferimento: in ascolto su `127.0.0.1:8787` dietro reverse proxy Server di riferimento: in ascolto su `127.0.0.1:8787` dietro reverse proxy
(`https://hermes.hackatoniclife.com`). TLS obbligatorio in produzione. (URL configurabile nell'app). TLS obbligatorio in produzione.
## 1. Autenticazione ## 1. Autenticazione
+1 -1
View File
@@ -45,7 +45,7 @@ Page {
Button { Button {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: api.busy ? qsTr("Signing in…") : qsTr("Sign in") text: api.busy ? qsTr("Signing in…") : qsTr("Sign in")
enabled: !api.busy && passField.text.length > 0 enabled: !api.busy && passField.text.length > 0 && urlField.text.trim().length > 0
onClicked: page.doLogin() onClicked: page.doLogin()
} }
+79 -34
View File
@@ -4,14 +4,10 @@ import Sailfish.Silica 1.0
Page { Page {
id: page id: page
property var voiceNames: [ // Nessun elenco cablato: le voci/gli engine dei selettori vengono dalle
"it-IT-ElsaNeural", // impostazioni (ttsVoices/ttsEngines) e il valore corrente è sempre
"it-IT-DiegoNeural", // incluso. loading evita di salvare durante il caricamento iniziale.
"it-IT-IsabellaNeural", property bool loading: true
"it-IT-GiuseppeMultilingualNeural",
"en-US-AriaNeural"
]
property var engineNames: ["edge", "openai", "elevenlabs"]
SilicaFlickable { SilicaFlickable {
anchors.fill: parent anchors.fill: parent
@@ -42,51 +38,61 @@ Page {
ComboBox { ComboBox {
label: qsTr("Read-aloud voice") label: qsTr("Read-aloud voice")
currentIndex: Math.max(0, page.voiceNames.indexOf(appSettings.ttsVoice)) currentIndex: page.voiceIndex()
// MenuItems espliciti (no Repeater: pattern non provato sul kit 5.1) // Repeater dentro ContextMenu = pattern provato sul device (tide)
menu: ContextMenu { menu: ContextMenu {
MenuItem { MenuItem {
text: "it-IT-ElsaNeural" text: qsTr("(server default)")
onClicked: appSettings.ttsVoice = "it-IT-ElsaNeural" onClicked: appSettings.ttsVoice = ""
} }
MenuItem { Repeater {
text: "it-IT-DiegoNeural" model: page.voiceChoices().length
onClicked: appSettings.ttsVoice = "it-IT-DiegoNeural" delegate: MenuItem {
text: page.voiceChoices()[index]
onClicked: appSettings.ttsVoice = page.voiceChoices()[index]
} }
MenuItem {
text: "it-IT-IsabellaNeural"
onClicked: appSettings.ttsVoice = "it-IT-IsabellaNeural"
} }
MenuItem {
text: "it-IT-GiuseppeMultilingualNeural"
onClicked: appSettings.ttsVoice = "it-IT-GiuseppeMultilingualNeural"
} }
MenuItem {
text: "en-US-AriaNeural"
onClicked: appSettings.ttsVoice = "en-US-AriaNeural"
} }
TextField {
id: voicesField
width: parent.width
label: qsTr("Voice presets (comma-separated)")
onTextChanged: {
if (!page.loading)
appSettings.ttsVoices = text.split(",")
} }
} }
ComboBox { ComboBox {
label: qsTr("TTS engine") label: qsTr("TTS engine")
currentIndex: Math.max(0, page.engineNames.indexOf(appSettings.ttsEngine)) currentIndex: page.engineIndex()
menu: ContextMenu { menu: ContextMenu {
MenuItem { MenuItem {
text: "edge" text: qsTr("(server default)")
onClicked: appSettings.ttsEngine = "edge" onClicked: appSettings.ttsEngine = ""
} }
MenuItem { Repeater {
text: "openai" model: page.engineChoices().length
onClicked: appSettings.ttsEngine = "openai" delegate: MenuItem {
text: page.engineChoices()[index]
onClicked: appSettings.ttsEngine = page.engineChoices()[index]
} }
MenuItem {
text: "elevenlabs"
onClicked: appSettings.ttsEngine = "elevenlabs"
} }
} }
} }
TextField {
id: enginesField
width: parent.width
label: qsTr("Engine presets (comma-separated)")
onTextChanged: {
if (!page.loading)
appSettings.ttsEngines = text.split(",")
}
}
TextSwitch { TextSwitch {
text: qsTr("Read replies aloud") text: qsTr("Read replies aloud")
description: qsTr("Speak every completed reply automatically") description: qsTr("Speak every completed reply automatically")
@@ -159,5 +165,44 @@ Page {
pageStack.replace(Qt.resolvedUrl("LoginPage.qml")) pageStack.replace(Qt.resolvedUrl("LoginPage.qml"))
} }
Component.onCompleted: urlField.text = appSettings.baseUrl // Elenchi dei selettori: preset configurati + valore corrente (così resta
// selezionabile anche se non è tra i preset). Indice 0 = "(server default)".
function voiceChoices() {
var out = appSettings.ttsVoices.slice()
var cur = appSettings.ttsVoice
if (cur.length > 0 && out.indexOf(cur) < 0)
out.push(cur)
return out
}
function voiceIndex() {
var cur = appSettings.ttsVoice
if (cur.length === 0)
return 0
var i = page.voiceChoices().indexOf(cur)
return i < 0 ? 0 : i + 1
}
function engineChoices() {
var out = appSettings.ttsEngines.slice()
var cur = appSettings.ttsEngine
if (cur.length > 0 && out.indexOf(cur) < 0)
out.push(cur)
return out
}
function engineIndex() {
var cur = appSettings.ttsEngine
if (cur.length === 0)
return 0
var i = page.engineChoices().indexOf(cur)
return i < 0 ? 0 : i + 1
}
Component.onCompleted: {
urlField.text = appSettings.baseUrl
voicesField.text = appSettings.ttsVoices.join(", ")
enginesField.text = appSettings.ttsEngines.join(", ")
page.loading = false
}
} }
+7 -1
View File
@@ -1,6 +1,6 @@
Name: harbour-hermes Name: harbour-hermes
Summary: Client for the Hermes Web UI with voice Summary: Client for the Hermes Web UI with voice
Version: 0.2.1 Version: 0.3.0
Release: 1 Release: 1
Group: Qt/Qt Group: Qt/Qt
License: MIT License: MIT
@@ -24,6 +24,12 @@ Sessions list, chat with live streaming replies, voice dictation
(server-side TTS), plus a hands-free conversation mode. (server-side TTS), plus a hands-free conversation mode.
%changelog %changelog
* Sat Sep 12 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.3.0-1
- Nessuna configurazione cablata nel binario: indirizzo server, voce,
engine e preset sono impostazioni dell'app (default neutri; vuoto =
decide il server); selettori voce/engine costruiti dai preset
configurabili; Content-Type audio coerente col formato reale; durata
massima registrazione configurabile (vadMaxMs)
* Sat Sep 12 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.1-1 * Sat Sep 12 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.1-1
- Fix build: rimosso il warning -Wreorder (ordine dell'inizializzazione - Fix build: rimosso il warning -Wreorder (ordine dell'inizializzazione
di m_sessionsModel nel costruttore di ApiClient) di m_sessionsModel nel costruttore di ApiClient)
+27 -1
View File
@@ -120,6 +120,13 @@ void ApiClient::clearError()
void ApiClient::checkAuth() void ApiClient::checkAuth()
{ {
// Senza indirizzo configurato non c'e' nulla da verificare: si parte
// dalla pagina di login, che lo richiede.
if (m_settings->baseUrl().isEmpty()) {
setLoggedIn(false);
emit authChecked(false);
return;
}
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/auth/status"))); QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/auth/status")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater(); reply->deleteLater();
@@ -144,6 +151,10 @@ void ApiClient::checkAuth()
void ApiClient::login(const QString &password) void ApiClient::login(const QString &password)
{ {
if (m_settings->baseUrl().isEmpty()) {
setLastError(QStringLiteral("Server address not configured"));
return;
}
setLastError(QString()); setLastError(QString());
setBusy(true); setBusy(true);
QJsonObject body; QJsonObject body;
@@ -429,7 +440,19 @@ void ApiClient::transcribeFile(const QString &path)
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
QHttpPart filePart; QHttpPart filePart;
filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QStringLiteral("audio/ogg"))); // Content-Type coerente col formato reale (ogg = dettatura manuale,
// wav = modalita' dialogo, ...).
QString mime = QStringLiteral("audio/ogg");
const QString suffix = QFileInfo(path).suffix().toLower();
if (suffix == QLatin1String("wav"))
mime = QStringLiteral("audio/wav");
else if (suffix == QLatin1String("mp3"))
mime = QStringLiteral("audio/mpeg");
else if (suffix == QLatin1String("m4a"))
mime = QStringLiteral("audio/mp4");
else if (suffix == QLatin1String("opus"))
mime = QStringLiteral("audio/opus");
filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(mime));
filePart.setHeader(QNetworkRequest::ContentDispositionHeader, filePart.setHeader(QNetworkRequest::ContentDispositionHeader,
QVariant(QStringLiteral("form-data; name=\"file\"; filename=\"%1\"") QVariant(QStringLiteral("form-data; name=\"file\"; filename=\"%1\"")
.arg(QFileInfo(path).fileName()))); .arg(QFileInfo(path).fileName())));
@@ -477,7 +500,10 @@ void ApiClient::speak(const QString &text)
setLastError(QString()); setLastError(QString());
QJsonObject body; QJsonObject body;
body.insert(QStringLiteral("text"), spoken); body.insert(QStringLiteral("text"), spoken);
// Voce/engine vuoti = default del server: i parametri si omettono.
if (!m_settings->ttsVoice().isEmpty())
body.insert(QStringLiteral("voice"), m_settings->ttsVoice()); body.insert(QStringLiteral("voice"), m_settings->ttsVoice());
if (!m_settings->ttsEngine().isEmpty())
body.insert(QStringLiteral("engine"), m_settings->ttsEngine()); body.insert(QStringLiteral("engine"), m_settings->ttsEngine());
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/tts")), QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/tts")),
QJsonDocument(body).toJson(QJsonDocument::Compact)); QJsonDocument(body).toJson(QJsonDocument::Compact));
+2 -2
View File
@@ -19,9 +19,9 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
// AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox). // AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox).
app->setOrganizationName(QStringLiteral("harbour")); app->setOrganizationName(QStringLiteral("harbour"));
app->setApplicationName(QStringLiteral("hermes")); app->setApplicationName(QStringLiteral("hermes"));
app->setApplicationVersion(QStringLiteral("0.2.1")); app->setApplicationVersion(QStringLiteral("0.3.0"));
qDebug() << "harbour-hermes v0.2.1 build" << __DATE__ << __TIME__; qDebug() << "harbour-hermes v0.3.0 build" << __DATE__ << __TIME__;
Settings settings; Settings settings;
ApiClient api(&settings); ApiClient api(&settings);
+57 -5
View File
@@ -1,10 +1,11 @@
#include "settings.h" #include "settings.h"
// Default: l'istanza pubblica del WebUI usa questo host (HTTPS, cert valido). // Nessuna configurazione personale e' compilata nel binario: i default sono
// L'utente puo' cambiarlo dalle impostazioni (LAN, VPN, altro dominio). // neutri e TUTTO si imposta dalle Impostazioni dell'app (QSettings in
static const char *kDefaultBaseUrl = "https://hermes.hackatoniclife.com"; // ~/.config/harbour/hermes.conf). Vuoto = "decide il server".
static const char *kDefaultTtsVoice = "it-IT-ElsaNeural"; static const char *kDefaultBaseUrl = "";
static const char *kDefaultTtsEngine = "edge"; static const char *kDefaultTtsVoice = "";
static const char *kDefaultTtsEngine = "";
Settings::Settings(QObject *parent) Settings::Settings(QObject *parent)
: QObject(parent) : QObject(parent)
@@ -109,6 +110,57 @@ void Settings::setVadThreshold(int threshold)
emit vadThresholdChanged(); emit vadThresholdChanged();
} }
int Settings::vadMaxMs() const
{
return m_settings.value(QStringLiteral("vadMaxMs"), 60000).toInt();
}
void Settings::setVadMaxMs(int ms)
{
if (ms == vadMaxMs())
return;
m_settings.setValue(QStringLiteral("vadMaxMs"), ms);
emit vadMaxMsChanged();
}
QStringList Settings::ttsVoices() const
{
return m_settings.value(QStringLiteral("ttsVoices")).toStringList();
}
void Settings::setTtsVoices(const QStringList &list)
{
QStringList clean;
for (const QString &v : list) {
const QString t = v.trimmed();
if (!t.isEmpty() && !clean.contains(t))
clean << t;
}
if (clean == ttsVoices())
return;
m_settings.setValue(QStringLiteral("ttsVoices"), clean);
emit ttsVoicesChanged();
}
QStringList Settings::ttsEngines() const
{
return m_settings.value(QStringLiteral("ttsEngines")).toStringList();
}
void Settings::setTtsEngines(const QStringList &list)
{
QStringList clean;
for (const QString &v : list) {
const QString t = v.trimmed();
if (!t.isEmpty() && !clean.contains(t))
clean << t;
}
if (clean == ttsEngines())
return;
m_settings.setValue(QStringLiteral("ttsEngines"), clean);
emit ttsEnginesChanged();
}
QString Settings::defaultWorkspace() const QString Settings::defaultWorkspace() const
{ {
return m_settings.value(QStringLiteral("defaultWorkspace"), QString()).toString(); return m_settings.value(QStringLiteral("defaultWorkspace"), QString()).toString();
+20
View File
@@ -4,6 +4,7 @@
#include <QObject> #include <QObject>
#include <QSettings> #include <QSettings>
#include <QString> #include <QString>
#include <QStringList>
// Impostazioni persistenti dell'app. // Impostazioni persistenti dell'app.
// QSettings con OrganizationName "harbour" / ApplicationName "hermes" (impostati // QSettings con OrganizationName "harbour" / ApplicationName "hermes" (impostati
@@ -25,6 +26,12 @@ class Settings : public QObject
NOTIFY vadSilenceMsChanged) NOTIFY vadSilenceMsChanged)
Q_PROPERTY(int vadThreshold READ vadThreshold WRITE setVadThreshold Q_PROPERTY(int vadThreshold READ vadThreshold WRITE setVadThreshold
NOTIFY vadThresholdChanged) NOTIFY vadThresholdChanged)
Q_PROPERTY(int vadMaxMs READ vadMaxMs WRITE setVadMaxMs
NOTIFY vadMaxMsChanged)
Q_PROPERTY(QStringList ttsVoices READ ttsVoices WRITE setTtsVoices
NOTIFY ttsVoicesChanged)
Q_PROPERTY(QStringList ttsEngines READ ttsEngines WRITE setTtsEngines
NOTIFY ttsEnginesChanged)
Q_PROPERTY(QString defaultWorkspace READ defaultWorkspace WRITE setDefaultWorkspace Q_PROPERTY(QString defaultWorkspace READ defaultWorkspace WRITE setDefaultWorkspace
NOTIFY defaultWorkspaceChanged) NOTIFY defaultWorkspaceChanged)
@@ -52,6 +59,16 @@ public:
int vadThreshold() const; int vadThreshold() const;
void setVadThreshold(int threshold); void setVadThreshold(int threshold);
int vadMaxMs() const;
void setVadMaxMs(int ms);
// Preset per i selettori voce/engine (liste configurabili, default vuote).
QStringList ttsVoices() const;
void setTtsVoices(const QStringList &list);
QStringList ttsEngines() const;
void setTtsEngines(const QStringList &list);
QString defaultWorkspace() const; QString defaultWorkspace() const;
void setDefaultWorkspace(const QString &ws); void setDefaultWorkspace(const QString &ws);
@@ -63,6 +80,9 @@ signals:
void dictationAutoSendChanged(); void dictationAutoSendChanged();
void vadSilenceMsChanged(); void vadSilenceMsChanged();
void vadThresholdChanged(); void vadThresholdChanged();
void vadMaxMsChanged();
void ttsVoicesChanged();
void ttsEnginesChanged();
void defaultWorkspaceChanged(); void defaultWorkspaceChanged();
private: private:
+2 -2
View File
@@ -174,8 +174,8 @@ void VoiceRecorder::onSilenceCheck()
return; return;
} }
// Tetto di sicurezza anti-registrazione infinita. // Tetto di sicurezza anti-registrazione infinita (configurabile: vadMaxMs).
if (elapsed >= 60000) { if (elapsed >= qint64(m_settings->vadMaxMs())) {
qDebug() << "[voice] stop automatico: durata massima raggiunta"; qDebug() << "[voice] stop automatico: durata massima raggiunta";
finalize(); finalize();
} }