feat: v0.1.0 — client Sailfish per hermes-webui con voce

- ApiClient HTTP/SSE: login, sessioni, chat/start, stream SSE, transcribe, tts
- ChatModel: streaming live + risincronizzazione dallo stato del server
- CookieJar persistente (0600, mai la password); Settings
- Recorder OGG/Opus (fallback WAV/PCM, retry); Player TTS (QMediaPlayer)
- UI Silica: splash, login, sessioni, chat (dettatura + read-aloud), settings, cover
- Sailjail Internet;Audio;Microphone; icone; spec RPM; docs PROTOCOL/BUILD
- tests/core_test: unit parser SSE + integrazione live (probe :8899)
This commit is contained in:
2026-09-12 07:11:20 +02:00
commit 9c6a1242f7
36 changed files with 2954 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
*.o
moc_*
qmake.stash
Makefile*
*.qm
build/
core-test
*.tar.gz
+34
View File
@@ -0,0 +1,34 @@
# harbour-hermes
Native **Sailfish OS** client for the **Hermes Web UI** (`hermes-webui`):
chat with your Hermes agent from the phone — and use it with your **voice**.
## Features
- Sign in to your Hermes Web UI (session cookie persisted; password never stored)
- Sessions list, new session, open any session
- Chat with **live streaming** replies (SSE token deltas)
- **Voice dictation**: tap Mic, speak, tap Stop → server-side Whisper
transcription lands in the composer
- **Read aloud**: server-side TTS (edge engine, Italian voices) played through
QMediaPlayer; optional auto-read of every completed reply
- Cancel a running turn, resync from server state, cover page
## Requirements
- Sailfish OS 4.4+ (developed against Sailfish 5.1)
- A running `hermes-webui` instance reachable over HTTPS (default:
`https://hermes.hackatoniclife.com`)
- Sailjail permissions `Internet;Audio;Microphone` (declared in the .desktop)
## Build
See [docs/BUILD.md](docs/BUILD.md) (Sailfish SDK, `sfdk build` / `sfdk deploy`)
and [docs/PROTOCOL.md](docs/PROTOCOL.md) for the HTTP/SSE protocol the app
speaks (validated live against a real instance).
## Status
Version 0.1.0 — protocol and C++ core validated against a real `hermes-webui`
(login, sessions, streaming chat, TTS, STT); on-device testing (audio paths,
Sailjail prompts, keyboard handling) still pending.
+93
View File
@@ -0,0 +1,93 @@
# Build: compilazione, test e deploy
## 1. Prerequisiti SDK
- Sailfish SDK (testato su 5.1.0.11) con target del dispositivo aarch64:
`SailfishOS-5.0.0.62-aarch64` (Jolla Phone 2026) o equivalente.
- Nel target SDK serve **QtMultimedia** (registrazione/riproduzione):
se `pkgconfig(Qt5Multimedia)` non è trovato in fase di build, installa il
pacchetto devel nel target:
```bash
sfdk tools exec SailfishOS-5.0.0.62-aarch64 zypper install -y qt5-qtmultimedia-devel
```
(oppure via Qt Creator: Tools → Sailfish OS → Manage targets → Packages,
oppure `sfdk target install` con un RPM locale.)
## 2. Build e deploy dell'app
```bash
cd harbour-hermes
sfdk build # genera RPMS/
sfdk deploy # installa sul device collegato
```
Note:
- Il target è Qt 5.6: il codice del core è scritto per compilare anche lì
(niente API Qt > 5.6; per il TTS si usa `audio/mpeg` di edge-tts).
- Il profilo SailJail è nel `rpm/harbour-hermes.desktop`
(`Internet;Audio;Microphone`) e deve restare allineato a
`setOrganizationName("harbour")` / `setApplicationName("hermes")` in `main.cpp`.
- Al primo uso del microfono Sailfish chiede il consenso al permesso
**Microphone**.
## 3. Test del core in locale (senza SDK)
Il core (ApiClient, ChatModel, parser SSE, Recorder, Player) si compila anche
su desktop con Qt 5.15 per i test:
```bash
# Qt desktop (qui: Qt 5.15.2 nel kit locali)
export PATH=<Qt5.15>/gcc_64/bin:$PATH
mkdir -p /tmp/hh-test && cd /tmp/hh-test
qmake /percorso/harbour-hermes/tests/core_test.pro
make -j4
```
- **Unit** (parser SSE): `./core-test`
- **Integrazione live**: `./core-test --live`
richiede un'istanza di prova di hermes-webui su `http://127.0.0.1:8899`
con password in `/tmp/hermes-probe/pw` e l'audio di prova in
`/tmp/hermes-probe/out/prova.ogg` (vedi note in `tests/core_test.cpp`).
Il flusso coperto: login → nuova sessione → turno con streaming → TTS →
trascrizione.
Nota desktop: se manca `libGL.so` di sviluppo, creare un symlink
`libGL.so -> /usr/lib/x86_64-linux-gnu/libGL.so.1` e assicurarsi che
`core_test.pro` punti lì (`GLSTUB`), oppure lanciare con `LD_LIBRARY_PATH`
puntato alla lib dir di Qt.
## 4. Tarball per la build da Qt Creator
```bash
cd /home/kaneda/workspace
tar czf harbour-hermes-0.1.0.tar.gz \
--transform 's,^harbour-hermes,harbour-hermes-0.1.0,' \
--exclude='.git' harbour-hermes
```
Lo spec si aspetta la directory `harbour-hermes-0.1.0/` (pattern degli altri
progetti: cercato in modo robusto anche per i sorgenti live di sfdk).
## 5. Dipendenze lato server (WebUI)
- `/api/transcribe` richiede un provider STT attivo sul server
(qui: faster-whisper locale — verificato con `/api/transcribe/capability`).
- `/api/tts` con engine `edge` non richiede chiavi API.
- Le voci italiane di edge sono state aggiunte all'allowlist del server
(patch a `hermes-webui/api/routes.py` + test
`tests/test_italian_voices_tts_allowlist.py`): per attivarle **riavviare
il servizio WebUI** (`sudo systemctl restart hermes-webui`).
- Per una dettatura italiana corretta impostare in `~/.hermes/config.yaml`:
```yaml
stt:
provider: local
local:
model: base
language: it
```
(senza, whisper può tradurre/maltrascrivere l'italiano). Richiede il
riavvio del WebUI.
+175
View File
@@ -0,0 +1,175 @@
# Protocollo `hermes-webui` ↔ client
Documentazione del protocollo HTTP/SSE usato da harbour-hermes.
**Validato dal vivo il 12/09/2026** contro un'istanza reale di
`nesquena/hermes-webui` (v0.52.x, `server.py`, ThreadingHTTPServer).
> Il protocollo non è documentato ufficialmente: è replicato dal frontend web
> del progetto (`static/*.js` è la reference implementation) e pinnato alla
> versione attuale. Un aggiornamento del WebUI può richiedere aggiornamenti
> al client.
Server di riferimento: in ascolto su `127.0.0.1:8787` dietro reverse proxy
(`https://hermes.hackatoniclife.com`). TLS obbligatorio in produzione.
## 1. Autenticazione
Cookie di sessione **`hermes_session`** = `token.firma_hmac` (TTL 30 giorni
di default; `HERMES_WEBUI_SESSION_TTL` per cambiarlo). Auth attiva solo se il
server ha una password impostata.
| Endpoint | Metodo | Body / Risposta |
|---|---|---|
| `/api/auth/login` | POST | `{"password": "..."}` → `200 {"ok": true}` + `Set-Cookie` |
| `/api/auth/status` | GET | `{"auth_enabled": bool, "logged_in": bool, "password_auth_enabled": bool, ...}` |
| `/api/auth/logout` | POST | invalida il cookie |
- Errori: `401 {"error": "Authentication required"}`; rate limit sul login.
- Se `auth_enabled` è `false`, tutte le API rispondono senza cookie.
- Il client persiste SOLO il cookie (file `0600`); la password non è mai salvata.
## 2. Sessioni
`GET /api/sessions` → lista per la sidebar:
```json
{
"sessions": [
{
"session_id": "171ff18f9313",
"title": "Webui Session",
"workspace": "/home/kaneda/.hermes/profiles/carlo/home/workspace",
"model": "deepseek-v4-flash",
"model_provider": "deepseek",
"message_count": 2,
"created_at": 1785701975.8797414,
"updated_at": 1785701985.0872252,
"pinned": false, "archived": false,
"profile": "default",
"source_tag": "webui", "session_source": "webui",
"is_cli_session": false, "is_streaming": false,
"active_stream_id": null
}
],
"active_profile": "default",
"server_time": 1789188804.1041577
}
```
`GET /api/session?session_id=ID` → `{"session": { ... }}` con in più:
- `messages`: array di `{"role": "user"|"assistant", "content": "...",
"timestamp": 1789188869.6, ...}` (i messaggi assistente possono includere
`reasoning`, `finish_reason`, `id`, `_turnDuration`, `_turnTps`,
`_firstTokenMs`; i messaggi "solo tool" hanno `content` vuoto)
- `tool_calls`, `input_tokens`, `output_tokens`, `estimated_cost`,
`read_only`, `active_stream_id`, `enabled_toolsets`, ...
`POST /api/session/new` con body `{"workspace": "/path"}` (opzionale; il
workspace deve essere sotto la home dell'utente, nella lista salvata o sotto
il default) → `{"session": {...}}` (sessione vuota).
## 3. Chat con streaming
`POST /api/chat/start`:
```json
{
"session_id": "f517dae7a2da",
"message": "Rispondi solo con la parola pong",
"model": "deepseek-v4-flash",
"model_provider": "deepseek",
"workspace": "/home/kaneda/.hermes/profiles/carlo/home/workspace",
"profile": "default"
}
```
Risposta:
```json
{
"stream_id": "a1e0ec82169b4673a13bf9bd466afd3f",
"session_id": "f517dae7a2da",
"pending_started_at": 1789188858.196835,
"turn_id": "20260912T045418Z-ba85fcc8cebe",
"title": "Rispondi solo con la parola pong. Non usare strumenti.",
"effective_model_provider": "deepseek"
}
```
Errori noti: `404` sessione inesistente; `400` "Missing required field(s)";
conflitto se la sessione ha già uno stream attivo ("session already has an
active stream").
`GET /api/chat/stream?stream_id=ID` → **SSE** `text/event-stream`,
heartbeat ogni 5 s (`: heartbeat`), ogni evento ha `id: <stream_id>:<seq>`:
| Evento | Payload | Uso nel client |
|---|---|---|
| `context_status` | `{session_id, prefill}` | ignorato |
| `token` | `{"text": "..."}` | **delta di testo** della risposta |
| `metering` | `{tps, ttft_ms, usage, ...}` | ignorato (v1) |
| `done` | `{"session": {...con messages finali}, "usage": {...}}` | **risincronizza** il modello e chiude il turno |
| `title_status` | `{session_id, status, title}` | ignorato |
| `title` | `{session_id, "title": "..."}` | aggiorna il titolo |
| `stream_end` | `{session_id}` | fine stream |
Altri eventi possono esistere (attività tool, approval/clarify) e vanno
**ignorati** senza errore (il parser li scarta).
Ripresa: `GET /api/chat/stream/status?stream_id=ID` →
`{"active": bool, "stream_id", "replay_available"}`. Se `GET /api/session`
mostra `active_stream_id` non nullo, riagganciare lo stream con lo stesso URL.
Annullamento: `GET /api/chat/cancel?stream_id=ID` →
`{"ok": true, "cancelled": bool, "stream_id"}`.
## 4. Voce
### Trascrizione (voce → testo)
`POST /api/transcribe` — `multipart/form-data`, campo **`file`**
(OGG/Opus, WebM, M4A, WAV...; il server converte con ffmpeg se serve):
```json
{"ok": true, "transcript": "Questa è una prova di trascrizione vocale..."}
```
- Provider STT del server (qui: `local` = faster-whisper). Latenza osservata:
~10 s per 6 s di audio (modello `base`, CPU).
- ⚠️ **Lingua**: con `stt.local.language: ''` whisper può tradurre l'italiano
in inglese; impostare `stt.local.language: it` in `~/.hermes/config.yaml`
(richiede riavvio del WebUI).
- `GET /api/transcribe/capability` → `{"ok": true, "available": true, "provider": "local"}`.
### Sintesi (testo → voce)
`POST /api/tts` — JSON:
```json
{"text": "Ciao", "voice": "it-IT-ElsaNeural", "engine": "edge",
"rate": "+0%", "pitch": "+0Hz"}
```
→ `200` `audio/mpeg` (mp3) oppure `4xx` `{"error": "..."}`.
- Limiti server: **5000 caratteri**; rate limit **2 s per client**;
**allowlist voci hardcoded** nella funzione `_handle_tts` di
`api/routes.py`. Voci ammesse oggi: zh-CN (5), en-US (2), fr-CA (4),
fr-FR (3), id-ID (1) + **it-IT (4: ElsaNeural, DiegoNeural, IsabellaNeural,
GiuseppeMultilingualNeural)** — le italiane sono state aggiunte da noi
(patch + test): il server va **riavviato** per attivarle.
- Engine: `edge` (default, gratis), `elevenlabs`, `openai` (richiedono chiavi
configurate sul server). `browser` è solo client-side, non usabile qui.
## 5. Note operative
- Tutti gli errori API sono JSON `{"error": "..."}`.
- Il server non espone CORS: il client nativo non serve preflight.
- Endpoint utili non usati dalla v1: `/api/clarify/*` e `/api/approval/*`
(richieste interattive dell'agente: vanno gestite per non lasciare un run
in attesa), `/api/upload` (allegati), `/api/settings`, `/api/models`,
`/api/sessions/events` (SSE globale di invalidazione lista).
- L'istanza di prova usata per la validazione era isolata (porta 8899,
`HERMES_WEBUI_STATE_DIR` dedicata, password temporanea) e non ha toccato
né il server live né i dati utente.
+47
View File
@@ -0,0 +1,47 @@
TEMPLATE = app
TARGET = harbour-hermes
CONFIG += sailfishapp
CONFIG += c++11
QT += network
QT += multimedia
SOURCES += \
src/main.cpp \
src/settings.cpp \
src/cookiejar.cpp \
src/sseparser.cpp \
src/apiclient.cpp \
src/chatmodel.cpp \
src/recorder.cpp \
src/player.cpp
HEADERS += \
src/settings.h \
src/cookiejar.h \
src/sseparser.h \
src/apiclient.h \
src/chatmodel.h \
src/recorder.h \
src/player.h
OTHER_FILES += \
qml/harbour-hermes.qml \
qml/pages/*.qml \
qml/cover/*.qml \
rpm/harbour-hermes.spec \
rpm/harbour-hermes.desktop
DISTFILES += $$OTHER_FILES
# Icone installate via make install (struttura standard hicolor)
icons86.path = /usr/share/icons/hicolor/86x86/apps
icons86.files = $$PWD/icons/86x86/harbour-hermes.png
icons108.path = /usr/share/icons/hicolor/108x108/apps
icons108.files = $$PWD/icons/108x108/harbour-hermes.png
icons128.path = /usr/share/icons/hicolor/128x128/apps
icons128.files = $$PWD/icons/128x128/harbour-hermes.png
icons172.path = /usr/share/icons/hicolor/172x172/apps
icons172.files = $$PWD/icons/172x172/harbour-hermes.png
INSTALLS += icons86 icons108 icons128 icons172
Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

+30
View File
@@ -0,0 +1,30 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
CoverBackground {
Label {
id: title
anchors.top: parent.top
anchors.topMargin: Theme.paddingLarge
anchors.horizontalCenter: parent.horizontalCenter
text: "Hermes"
font.pixelSize: Theme.fontSizeLarge
color: Theme.highlightColor
}
Label {
anchors.centerIn: parent
text: chat.count + qsTr(" messages")
color: Theme.primaryColor
}
Label {
anchors.bottom: parent.bottom
anchors.bottomMargin: Theme.paddingLarge
anchors.horizontalCenter: parent.horizontalCenter
visible: chat.busy || recorder.recording
font.pixelSize: Theme.fontSizeExtraSmall
color: Theme.secondaryColor
text: recorder.recording ? qsTr("Recording…") : qsTr("Writing…")
}
}
+14
View File
@@ -0,0 +1,14 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
import "pages"
import "cover"
ApplicationWindow {
id: app
initialPage: Component { SplashPage {} }
cover: Component { CoverPage {} }
allowedOrientations: Orientation.Portrait
_defaultPageOrientations: Orientation.Portrait
}
+240
View File
@@ -0,0 +1,240 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
// Conversazione con Hermes: bolle, streaming live, composer con dettatura.
Page {
id: page
property string sessionId
property string noticeText: ""
// Altezza tastiera: alza il composer quando la tastiera e' aperta.
property real kbHeight: Qt.inputMethod.visible
? Math.max(0, page.height - Qt.inputMethod.keyboardRectangle.y)
: 0
allowedOrientations: Orientation.Portrait
SilicaListView {
id: list
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: composer.top
model: chat
spacing: Theme.paddingSmall
clip: true
header: Item {
width: list.width
height: headerCol.height
Column {
id: headerCol
width: parent.width
PageHeader {
title: chat.title.length > 0 ? chat.title : qsTr("Chat")
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
font.pixelSize: Theme.fontSizeExtraSmall
color: Theme.secondaryHighlightColor
visible: chat.busy || player.playing
text: chat.busy ? qsTr("Hermes is writing…")
: qsTr("Speaking…")
}
}
}
PullDownMenu {
MenuItem {
text: qsTr("Read last reply aloud")
onClicked: page.readLast()
}
MenuItem {
text: appSettings.ttsAutoRead ? qsTr("Auto-read: on") : qsTr("Auto-read: off")
onClicked: appSettings.ttsAutoRead = !appSettings.ttsAutoRead
}
MenuItem {
text: qsTr("Stop playback")
visible: player.playing
onClicked: player.stop()
}
MenuItem {
text: qsTr("Cancel run")
visible: chat.busy
onClicked: chat.cancel()
}
MenuItem {
text: qsTr("Resync")
onClicked: chat.resync()
}
}
delegate: Item {
id: row
width: list.width
height: bubble.height + Theme.paddingMedium
Rectangle {
id: bubble
property real maxW: list.width - 2 * Theme.horizontalPageMargin
width: Math.min(lbl.implicitWidth + 2 * Theme.paddingMedium, maxW)
height: lbl.height + 2 * Theme.paddingSmall
radius: Theme.paddingSmall
x: model.role === "user"
? list.width - width - Theme.horizontalPageMargin
: Theme.horizontalPageMargin
color: model.role === "user"
? Theme.rgba(Theme.highlightBackgroundColor, 0.35)
: Theme.rgba(Theme.secondaryHighlightColor, 0.18)
Label {
id: lbl
x: Theme.paddingMedium
y: Theme.paddingSmall
width: Math.min(implicitWidth, bubble.maxW - 2 * Theme.paddingMedium)
wrapMode: Text.Wrap
color: Theme.primaryColor
text: model.content + (model.streaming ? " ▍" : "")
}
}
}
}
Rectangle {
id: composer
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.bottomMargin: page.kbHeight
height: compCol.height + Theme.paddingMedium
color: Theme.highlightDimmerColor
Column {
id: compCol
width: parent.width
spacing: Theme.paddingSmall / 2
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
font.pixelSize: Theme.fontSizeExtraSmall
color: recorder.recording ? Theme.highlightColor
: Theme.secondaryHighlightColor
visible: recorder.recording || api.transcribing || page.noticeText.length > 0
truncationMode: TruncationMode.Fade
text: recorder.recording
? qsTr("Recording… %1 — tap Stop to transcribe").arg(page.formatMs(recorder.durationMs))
: api.transcribing
? qsTr("Transcribing…")
: page.noticeText
}
Row {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
spacing: Theme.paddingSmall
Button {
id: micBtn
text: recorder.recording ? qsTr("Stop") : qsTr("Mic")
highlighted: recorder.recording
enabled: !api.transcribing && !chat.busy || recorder.recording
onClicked: {
if (recorder.recording)
recorder.stop()
else {
player.stop()
recorder.start()
}
}
}
TextField {
id: input
width: parent.width - micBtn.width - sendBtn.width - 2 * parent.spacing
placeholderText: qsTr("Message…")
onAccepted: page.doSend()
}
Button {
id: sendBtn
text: qsTr("Send")
enabled: input.text.length > 0 && !chat.busy
onClicked: page.doSend()
}
}
}
}
Timer {
id: scrollDebounce
interval: 150
onTriggered: list.positionViewAtEnd()
}
Timer {
id: noticeClear
interval: 5000
onTriggered: page.noticeText = ""
}
Connections {
target: chat
onRowCountChanged: scrollDebounce.restart()
onStreamTick: scrollDebounce.restart()
onAssistantFinished: {
if (appSettings.ttsAutoRead)
page.readLast()
}
onNotice: page.showNotice(text)
}
Connections {
target: api
onTranscriptionReady: {
input.text = text
input.focus = true
}
onTranscriptionFailed: page.showNotice(error)
onTtsFailed: page.showNotice(error)
}
Component.onCompleted: {
if (sessionId.length > 0)
chat.openSession(sessionId)
}
function doSend() {
var t = input.text
if (t.trim().length === 0)
return
input.text = ""
chat.send(t)
scrollDebounce.restart()
}
function readLast() {
var t = chat.lastAssistantText()
if (t.length === 0) {
page.showNotice(qsTr("No reply to read yet"))
return
}
api.speak(t)
}
function showNotice(text) {
page.noticeText = text
noticeClear.restart()
}
function formatMs(ms) {
var s = Math.floor(ms / 1000)
var m = Math.floor(s / 60)
s = s % 60
return m + ":" + (s < 10 ? "0" + s : s)
}
}
+76
View File
@@ -0,0 +1,76 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
// Login alla Web UI di Hermes: URL del server + password.
// La password non viene salvata: dopo il login resta il cookie di sessione.
Page {
id: page
Column {
anchors.left: parent.left
anchors.right: parent.right
spacing: Theme.paddingLarge
PageHeader { title: qsTr("Sign in") }
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
wrapMode: Text.WordWrap
font.pixelSize: Theme.fontSizeSmall
color: Theme.secondaryColor
text: qsTr("Use the same password you use in the Hermes Web UI in your browser.")
}
TextField {
id: urlField
width: parent.width
label: qsTr("Server")
placeholderText: "https://hermes.example.com"
inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase
}
TextField {
id: passField
width: parent.width
label: qsTr("Password")
echoMode: TextInput.Password
inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText
onAccepted: page.doLogin()
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: api.busy ? qsTr("Signing in…") : qsTr("Sign in")
enabled: !api.busy && passField.text.length > 0
onClicked: page.doLogin()
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
wrapMode: Text.WordWrap
visible: api.lastError.length > 0
color: Theme.errorColor
text: api.lastError
}
}
Connections {
target: api
onLoggedInChanged: {
if (api.loggedIn)
pageStack.replace(Qt.resolvedUrl("SessionsPage.qml"))
}
}
Component.onCompleted: urlField.text = appSettings.baseUrl
function doLogin() {
if (passField.text.length === 0)
return
api.clearError()
appSettings.baseUrl = urlField.text
api.login(passField.text)
}
}
+115
View File
@@ -0,0 +1,115 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
// Lista delle sessioni della Web UI di Hermes (sidebar).
Page {
id: page
// true solo quando e' questa pagina a chiedere una nuova sessione:
// serve a distinguere la creazione dall'apertura di una sessione esistente.
property bool expectNewSession: false
SilicaListView {
id: list
anchors.fill: parent
model: api.sessions
spacing: Theme.paddingSmall
header: PageHeader { title: qsTr("Sessions") }
PullDownMenu {
MenuItem {
text: qsTr("New session")
onClicked: page.createSession()
}
MenuItem {
text: qsTr("Refresh")
onClicked: api.refreshSessions()
}
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
MenuItem {
text: qsTr("Sign out")
onClicked: api.logout()
}
}
delegate: ListItem {
id: item
contentHeight: col.height + Theme.paddingMedium
Column {
id: col
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.paddingSmall / 2
Label {
width: parent.width
truncationMode: TruncationMode.Fade
color: item.highlighted ? Theme.highlightColor : Theme.primaryColor
text: ("" + model.title).length > 0 ? model.title : model.session_id
}
Label {
width: parent.width
font.pixelSize: Theme.fontSizeExtraSmall
color: Theme.secondaryColor
text: page.describe(model)
}
}
onClicked: page.openSession(model.session_id)
}
ViewPlaceholder {
enabled: api.sessions.length === 0 && !api.busy
text: qsTr("No sessions")
hintText: qsTr("Pull down to create a new session")
}
VerticalScrollDecorator {}
}
BusyIndicator {
anchors.centerIn: parent
size: BusyIndicatorSize.Large
running: api.busy
}
Connections {
target: api
onLoggedInChanged: {
if (!api.loggedIn)
pageStack.replace(Qt.resolvedUrl("LoginPage.qml"))
}
onSessionOpened: {
if (page.expectNewSession) {
page.expectNewSession = false
pageStack.push(Qt.resolvedUrl("ChatPage.qml"),
{ sessionId: api.session.session_id })
}
}
}
Component.onCompleted: api.refreshSessions()
function openSession(id) {
expectNewSession = false
pageStack.push(Qt.resolvedUrl("ChatPage.qml"), { sessionId: id })
}
function createSession() {
expectNewSession = true
chat.newSession()
}
function describe(m) {
var n = m.message_count ? m.message_count : 0
var d = new Date((m.updated_at ? m.updated_at : 0) * 1000)
return n + qsTr(" messages") + " · " + Qt.formatDateTime(d, "dd/MM HH:mm")
}
}
+108
View File
@@ -0,0 +1,108 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
property var voiceNames: [
"it-IT-ElsaNeural",
"it-IT-DiegoNeural",
"it-IT-IsabellaNeural",
"it-IT-GiuseppeMultilingualNeural",
"en-US-AriaNeural"
]
property var engineNames: ["edge", "openai", "elevenlabs"]
SilicaFlickable {
anchors.fill: parent
contentHeight: col.height + Theme.paddingLarge
Column {
id: col
width: parent.width
spacing: Theme.paddingMedium
PageHeader { title: qsTr("Settings") }
SectionHeader { text: qsTr("Server") }
TextField {
id: urlField
width: parent.width
label: qsTr("Web UI address")
placeholderText: "https://hermes.example.com"
inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase
onTextChanged: {
if (text !== appSettings.baseUrl)
appSettings.baseUrl = text
}
}
SectionHeader { text: qsTr("Voice") }
ComboBox {
label: qsTr("Read-aloud voice")
currentIndex: Math.max(0, page.voiceNames.indexOf(appSettings.ttsVoice))
menu: ContextMenu {
Repeater {
model: page.voiceNames
MenuItem {
text: modelData
onClicked: appSettings.ttsVoice = modelData
}
}
}
}
ComboBox {
label: qsTr("TTS engine")
currentIndex: Math.max(0, page.engineNames.indexOf(appSettings.ttsEngine))
menu: ContextMenu {
Repeater {
model: page.engineNames
MenuItem {
text: modelData
onClicked: appSettings.ttsEngine = modelData
}
}
}
}
TextSwitch {
text: qsTr("Read replies aloud")
description: qsTr("Speak every completed reply automatically")
checked: appSettings.ttsAutoRead
onCheckedChanged: appSettings.ttsAutoRead = checked
}
SectionHeader { text: qsTr("Account") }
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Sign out")
onClicked: {
api.logout()
pageStack.replace(Qt.resolvedUrl("LoginPage.qml"))
}
}
SectionHeader { text: qsTr("About") }
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
wrapMode: Text.WordWrap
font.pixelSize: Theme.fontSizeSmall
color: Theme.secondaryColor
text: "harbour-hermes " + appVersion + "\n\n" +
qsTr("Native client for the Hermes Web UI (hermes-webui): " +
"sessions, live streaming chat, server-side voice " +
"dictation and read-aloud.")
}
}
VerticalScrollDecorator {}
}
Component.onCompleted: urlField.text = appSettings.baseUrl
}
+41
View File
@@ -0,0 +1,41 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
// Avvio: verifica lo stato dell'autenticazione (cookie persistito) e
// indirizza verso la lista sessioni o il login.
Page {
id: page
Column {
anchors.centerIn: parent
spacing: Theme.paddingLarge
width: parent.width - 2 * Theme.horizontalPageMargin
Label {
anchors.horizontalCenter: parent.horizontalCenter
text: "Hermes"
font.pixelSize: Theme.fontSizeHuge
color: Theme.highlightColor
}
BusyIndicator {
anchors.horizontalCenter: parent.horizontalCenter
size: BusyIndicatorSize.Large
running: true
}
}
Connections {
target: api
onAuthChecked: page.route(loggedIn)
}
Component.onCompleted: api.checkAuth()
function route(loggedIn) {
if (loggedIn)
pageStack.replace(Qt.resolvedUrl("SessionsPage.qml"))
else
pageStack.replace(Qt.resolvedUrl("LoginPage.qml"))
}
}
+22
View File
@@ -0,0 +1,22 @@
[Desktop Entry]
Name=Hermes
Name[it_IT]=Hermes
Comment=Client for the Hermes Web UI with voice
Comment[it_IT]=Client per la Web UI di Hermes con voce
Exec=harbour-hermes
Icon=harbour-hermes
Type=Application
X-Nemo-Application-Type=silica-qt5
Categories=Network;
# Profilo SailJail esplicito: senza X-Sailjail la sandbox di Sailfish 5.1 usa il
# profilo default, che nasconde ~/.config/harbour (config non persistente).
# Org/App dichiarati QUI (letti dalla sandbox) e nel main.cpp (letti da
# QStandardPaths): AppConfigLocation = ~/.config/harbour/hermes (persistente).
# Internet = Web UI di Hermes (HTTPS). Microphone = dettatura vocale.
# Audio = riproduzione delle risposte TTS e registrazione (PulseAudio non
# separa i flussi: il permesso Audio copre entrambi).
[X-Sailjail]
Permissions=Internet;Audio;Microphone
OrganizationName=harbour
ApplicationName=hermes
+63
View File
@@ -0,0 +1,63 @@
Name: harbour-hermes
Summary: Client for the Hermes Web UI with voice
Version: 0.1.0
Release: 1
Group: Qt/Qt
License: MIT
URL: https://git.hackatoniclife.com/kaneda/harbour-hermes
Source0: %{name}-%{version}.tar.gz
Source1: %{name}.desktop
Requires: sailfishsilica-qt5 >= 0.10.9
BuildRequires: pkgconfig(sailfishapp) >= 1.0.2
BuildRequires: pkgconfig(Qt5Core)
BuildRequires: pkgconfig(Qt5Qml)
BuildRequires: pkgconfig(Qt5Quick)
BuildRequires: pkgconfig(Qt5Network)
BuildRequires: pkgconfig(Qt5Multimedia)
BuildRequires: desktop-file-utils
%description
Native Sailfish OS client for the Hermes Web UI (hermes-webui).
Sessions list, chat with live streaming replies, voice dictation
(server-side Whisper transcription) and read-aloud of replies
(server-side TTS), plus a hands-free conversation mode.
%changelog
* Sat Sep 12 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.0-1
- Prima release: login alla Web UI di Hermes, lista sessioni,
chat con streaming SSE, dettatura vocale (registrazione OGG/Opus
-> /api/transcribe), lettura risposte (/api/tts -> QMediaPlayer),
nuova sessione, sandbox SailJail (Internet;Audio;Microphone)
%prep
%setup -q -n %{name}-%{version}
%build
%qmake5
make %{?_smp_mflags}
%install
make install INSTALL_ROOT=%{buildroot}
mkdir -p %{buildroot}%{_datadir}/applications
# Desktop file cercato in modo robusto: sfdk usa i sorgenti live (shadow build)
# e la directory sorgente puo' chiamarsi %{name}-%{version} (tarball),
# %{name} (clone) o %{name}-main (zip di Gitea); %{SOURCE1} e' il fallback.
DESKTOP=$(find %{_builddir}/.. -maxdepth 4 -name '%{name}.desktop' 2>/dev/null | head -1)
if [ -z "$DESKTOP" ] && [ -f %{SOURCE1} ]; then DESKTOP=%{SOURCE1}; fi
if [ -z "$DESKTOP" ]; then
echo "ERROR: %{name}.desktop not found" >&2
exit 1
fi
install -m 0644 "$DESKTOP" %{buildroot}%{_datadir}/applications/%{name}.desktop
desktop-file-validate %{buildroot}%{_datadir}/applications/%{name}.desktop || true
%files
%defattr(-,root,root,-)
%{_bindir}/%{name}
%{_datadir}/applications/%{name}.desktop
%{_datadir}/icons/hicolor/86x86/apps/%{name}.png
%{_datadir}/icons/hicolor/108x108/apps/%{name}.png
%{_datadir}/icons/hicolor/128x128/apps/%{name}.png
%{_datadir}/icons/hicolor/172x172/apps/%{name}.png
%{_datadir}/%{name}/qml
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Icone per harbour-hermes: bolla di chat con onde sonore, palette Nord.
Uso: python3 scripts/make_icons.py [<output_dir>] [<name>]
Scrive <out>/<size>x<size>/<name>.png per 86, 108, 128, 172.
"""
import sys
import os
from PIL import Image, ImageDraw
BG = (46, 52, 64) # #2E3440
ACCENT = (136, 192, 208) # #88C0D0
PAGE = (229, 233, 240) # #E5E9F0
WAVE = (163, 190, 140) # #A3BE8C
def draw_icon(size):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
m = size * 0.05
d.rounded_rectangle([m, m, size - m, size - m], radius=size * 0.16, fill=BG)
# bolla di chat a sinistra
bx0, by0 = size * 0.18, size * 0.26
bx1, by1 = size * 0.56, size * 0.62
d.rounded_rectangle([bx0, by0, bx1, by1], radius=size * 0.07, fill=PAGE)
d.polygon([(size * 0.30, by1 - size * 0.01),
(size * 0.20, size * 0.74),
(size * 0.40, by1)], fill=PAGE)
# onde sonore a destra (tre archi concentrici)
cx, cy = size * 0.66, size * 0.585
for i, rr in enumerate((0.085, 0.155, 0.225)):
r = size * rr
d.arc([cx - r, cy - r, cx + r, cy + r], 300, 60,
fill=ACCENT if i < 2 else WAVE,
width=max(2, int(size * 0.028)))
# puntino sorgente
pr = size * 0.035
d.ellipse([cx - pr, cy - pr, cx + pr, cy + pr], fill=ACCENT)
return img
def main():
out_dir = sys.argv[1] if len(sys.argv) > 1 else "icons"
name = sys.argv[2] if len(sys.argv) > 2 else "harbour-hermes"
for s in (86, 108, 128, 172):
d = os.path.join(out_dir, "%dx%d" % (s, s))
os.makedirs(d, exist_ok=True)
draw_icon(s).save(os.path.join(d, name + ".png"))
print("icone scritte in %s/ (86..172)" % out_dir)
if __name__ == "__main__":
main()
+505
View File
@@ -0,0 +1,505 @@
#include "apiclient.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHttpMultiPart>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
#include <QUrlQuery>
ApiClient::ApiClient(Settings *settings, QObject *parent)
: QObject(parent)
, m_settings(settings)
, m_jar(new CookieJar(this))
, m_loggedIn(false)
, m_busy(false)
, m_streaming(false)
, m_transcribing(false)
, m_streamReply(nullptr)
, m_streamEndedEmitted(false)
{
// Cookie di sessione persistente (0600), nella zona config della sandbox.
const QString cfg = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
QDir().mkpath(cfg);
m_jar->setFilePath(cfg + QStringLiteral("/cookies.dat"));
m_jar->load();
m_nam.setCookieJar(m_jar);
}
// ---------------------------------------------------------------- helpers --
QUrl ApiClient::apiUrl(const QString &path) const
{
return QUrl(m_settings->baseUrl() + path);
}
QNetworkRequest ApiClient::jsonRequest(const QString &path, bool post) const
{
Q_UNUSED(post)
QNetworkRequest req(apiUrl(path));
req.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/json"));
return req;
}
QString ApiClient::cacheDir(const QString &sub) const
{
const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
const QString dir = base + QStringLiteral("/") + sub;
QDir().mkpath(dir);
return dir;
}
QString ApiClient::errorFromReply(QNetworkReply *reply, const QByteArray &body) const
{
const QJsonDocument doc = QJsonDocument::fromJson(body);
if (doc.isObject()) {
const QString err = doc.object().value(QStringLiteral("error")).toString();
if (!err.isEmpty())
return err;
}
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status == 401)
return QStringLiteral("Authentication required");
if (status > 0)
return QStringLiteral("HTTP %1").arg(status);
return reply->errorString();
}
void ApiClient::setBusy(bool on)
{
if (on == m_busy)
return;
m_busy = on;
emit busyChanged();
}
void ApiClient::setStreaming(bool on)
{
if (on == m_streaming)
return;
m_streaming = on;
emit streamingChanged();
}
void ApiClient::setTranscribing(bool on)
{
if (on == m_transcribing)
return;
m_transcribing = on;
emit transcribingChanged();
}
void ApiClient::setLastError(const QString &error)
{
if (error == m_lastError)
return;
m_lastError = error;
emit lastErrorChanged();
}
void ApiClient::setLoggedIn(bool on)
{
if (on == m_loggedIn)
return;
m_loggedIn = on;
emit loggedInChanged();
}
void ApiClient::clearError()
{
setLastError(QString());
}
// ------------------------------------------------------------------- auth --
void ApiClient::checkAuth()
{
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/auth/status")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
setLoggedIn(false);
emit authChecked(false);
return;
}
const QJsonObject obj = QJsonDocument::fromJson(body).object();
const bool authEnabled = obj.value(QStringLiteral("auth_enabled")).toBool();
const bool sessionValid = obj.value(QStringLiteral("logged_in")).toBool();
if (!authEnabled || sessionValid) {
setLoggedIn(true);
refreshSessions();
} else {
setLoggedIn(false);
}
emit authChecked(m_loggedIn);
});
}
void ApiClient::login(const QString &password)
{
setLastError(QString());
setBusy(true);
QJsonObject body;
body.insert(QStringLiteral("password"), password);
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/auth/login")),
QJsonDocument(body).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
setBusy(false);
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
setLastError(errorFromReply(reply, data));
return;
}
setLoggedIn(true);
refreshSessions();
});
}
void ApiClient::logout()
{
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/auth/logout")), QByteArray());
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
});
m_jar->clear();
setLoggedIn(false);
m_sessions.clear();
emit sessionsChanged();
m_session.clear();
emit sessionChanged();
}
// --------------------------------------------------------------- sessioni --
void ApiClient::refreshSessions()
{
setBusy(true);
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/sessions")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
setBusy(false);
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
setLastError(errorFromReply(reply, body));
return;
}
const QJsonObject obj = QJsonDocument::fromJson(body).object();
const QJsonArray arr = obj.value(QStringLiteral("sessions")).toArray();
m_sessions = arr.toVariantList();
emit sessionsChanged();
});
}
void ApiClient::openSession(const QString &id)
{
setLastError(QString());
setBusy(true);
QUrl url = apiUrl(QStringLiteral("/api/session"));
QUrlQuery q;
q.addQueryItem(QStringLiteral("session_id"), id);
url.setQuery(q);
QNetworkReply *reply = m_nam.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
setBusy(false);
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
setLastError(errorFromReply(reply, body));
return;
}
const QJsonObject obj = QJsonDocument::fromJson(body).object();
m_session = obj.value(QStringLiteral("session")).toObject().toVariantMap();
emit sessionChanged();
emit sessionOpened();
// Se la sessione ha gia' un run attivo (ripresa dopo chiusura app),
// riaggancia lo stream SSE.
const QString active = m_session.value(QStringLiteral("active_stream_id")).toString();
if (!active.isEmpty())
startStream(active);
});
}
void ApiClient::newSession()
{
setLastError(QString());
setBusy(true);
QJsonObject body;
const QString ws = m_settings->defaultWorkspace();
if (!ws.isEmpty())
body.insert(QStringLiteral("workspace"), ws);
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/session/new")),
QJsonDocument(body).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
setBusy(false);
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
setLastError(errorFromReply(reply, data));
return;
}
const QJsonObject obj = QJsonDocument::fromJson(data).object();
m_session = obj.value(QStringLiteral("session")).toObject().toVariantMap();
emit sessionChanged();
emit sessionOpened();
refreshSessions();
});
}
// ------------------------------------------------------------------- chat --
void ApiClient::startChat(const QString &sessionId, const QString &message)
{
setLastError(QString());
QJsonObject body;
body.insert(QStringLiteral("session_id"), sessionId);
body.insert(QStringLiteral("message"), message);
const QString model = m_session.value(QStringLiteral("model")).toString();
const QString provider = m_session.value(QStringLiteral("model_provider")).toString();
const QString workspace = m_session.value(QStringLiteral("workspace")).toString();
QString profile = m_session.value(QStringLiteral("profile")).toString();
if (profile.isEmpty())
profile = QStringLiteral("default");
body.insert(QStringLiteral("model"), model);
body.insert(QStringLiteral("model_provider"), provider);
body.insert(QStringLiteral("workspace"), workspace);
body.insert(QStringLiteral("profile"), profile);
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/chat/start")),
QJsonDocument(body).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
handleStartReply(reply);
});
}
void ApiClient::handleStartReply(QNetworkReply *reply)
{
reply->deleteLater();
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
const QString err = errorFromReply(reply, body);
setLastError(err);
emit chatStartFailed(err);
return;
}
const QJsonObject obj = QJsonDocument::fromJson(body).object();
const QString streamId = obj.value(QStringLiteral("stream_id")).toString();
if (streamId.isEmpty()) {
const QString err = QStringLiteral("No stream_id in response");
setLastError(err);
emit chatStartFailed(err);
return;
}
const QString sid = obj.value(QStringLiteral("session_id")).toString();
if (!sid.isEmpty())
m_session.insert(QStringLiteral("session_id"), sid);
emit chatStarted(streamId);
startStream(streamId);
}
void ApiClient::cancelRun()
{
if (m_streamId.isEmpty())
return;
QUrl url = apiUrl(QStringLiteral("/api/chat/cancel"));
QUrlQuery q;
q.addQueryItem(QStringLiteral("stream_id"), m_streamId);
url.setQuery(q);
QNetworkReply *reply = m_nam.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
});
}
// -------------------------------------------------------------------- SSE --
void ApiClient::startStream(const QString &streamId)
{
if (m_streamReply) {
m_streamReply->abort();
m_streamReply->deleteLater();
m_streamReply = nullptr;
}
m_streamId = streamId;
m_streamEndedEmitted = false;
m_parser.reset();
QUrl url = apiUrl(QStringLiteral("/api/chat/stream"));
QUrlQuery q;
q.addQueryItem(QStringLiteral("stream_id"), streamId);
url.setQuery(q);
QNetworkRequest req(url);
req.setRawHeader("Accept", "text/event-stream");
m_streamReply = m_nam.get(req);
setStreaming(true);
connect(m_streamReply, &QNetworkReply::readyRead, this, [this]() {
if (!m_streamReply)
return;
const QList<SseParser::Event> events = m_parser.feed(m_streamReply->readAll());
for (int i = 0; i < events.size(); ++i)
dispatchSseEvent(events.at(i));
});
connect(m_streamReply, &QNetworkReply::finished, this, [this]() {
QNetworkReply *reply = m_streamReply;
m_streamReply = nullptr;
if (reply) {
if (reply->error() != QNetworkReply::NoError
&& reply->error() != QNetworkReply::OperationCanceledError) {
const QString err = reply->errorString();
setLastError(err);
emit streamError(err);
}
reply->deleteLater();
}
emitStreamEndedOnce();
setStreaming(false);
});
}
void ApiClient::dispatchSseEvent(const SseParser::Event &ev)
{
const QJsonObject obj = QJsonDocument::fromJson(ev.data.toUtf8()).object();
if (ev.name == QLatin1String("token")) {
emit streamDelta(obj.value(QStringLiteral("text")).toString());
} else if (ev.name == QLatin1String("done")) {
const QVariantMap session = obj.value(QStringLiteral("session")).toObject().toVariantMap();
if (!session.isEmpty()) {
m_session = session;
emit sessionChanged();
}
emit streamDone(session);
} else if (ev.name == QLatin1String("title")) {
const QString title = obj.value(QStringLiteral("title")).toString();
if (!title.isEmpty()) {
m_session.insert(QStringLiteral("title"), title);
emit sessionChanged();
emit streamTitle(title);
}
} else if (ev.name == QLatin1String("stream_end")) {
emitStreamEndedOnce();
}
// Altri eventi noti (context_status, metering, title_status) e sconosciuti:
// ignorati intenzionalmente in questa versione.
}
void ApiClient::emitStreamEndedOnce()
{
if (m_streamEndedEmitted)
return;
m_streamEndedEmitted = true;
emit streamEnded();
}
// ------------------------------------------------------------------- voce --
void ApiClient::transcribeFile(const QString &path)
{
setLastError(QString());
setTranscribing(true);
QFile *file = new QFile(path);
if (!file->open(QIODevice::ReadOnly)) {
file->deleteLater();
setTranscribing(false);
const QString err = QStringLiteral("Cannot open audio file");
setLastError(err);
emit transcriptionFailed(err);
return;
}
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
QHttpPart filePart;
filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QStringLiteral("audio/ogg")));
filePart.setHeader(QNetworkRequest::ContentDispositionHeader,
QVariant(QStringLiteral("form-data; name=\"file\"; filename=\"%1\"")
.arg(QFileInfo(path).fileName())));
filePart.setBodyDevice(file);
file->setParent(multiPart);
multiPart->append(filePart);
QNetworkRequest req(apiUrl(QStringLiteral("/api/transcribe")));
QNetworkReply *reply = m_nam.post(req, multiPart);
multiPart->setParent(reply);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
setTranscribing(false);
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
const QString err = errorFromReply(reply, body);
setLastError(err);
emit transcriptionFailed(err);
return;
}
const QJsonObject obj = QJsonDocument::fromJson(body).object();
if (!obj.value(QStringLiteral("ok")).toBool()) {
QString err = obj.value(QStringLiteral("error")).toString();
if (err.isEmpty())
err = QStringLiteral("Transcription failed");
setLastError(err);
emit transcriptionFailed(err);
return;
}
emit transcriptionReady(obj.value(QStringLiteral("transcript")).toString().trimmed());
});
}
void ApiClient::speak(const QString &text)
{
const QString trimmed = text.trimmed();
if (trimmed.isEmpty())
return;
// Il server accetta max 5000 caratteri per richiesta: tronca con avviso.
QString spoken = trimmed;
if (spoken.size() > 4900)
spoken = spoken.left(4900);
setLastError(QString());
QJsonObject body;
body.insert(QStringLiteral("text"), spoken);
body.insert(QStringLiteral("voice"), m_settings->ttsVoice());
body.insert(QStringLiteral("engine"), m_settings->ttsEngine());
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/tts")),
QJsonDocument(body).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
const QString err = errorFromReply(reply, body);
setLastError(err);
emit ttsFailed(err);
return;
}
const QVariant contentType = reply->header(QNetworkRequest::ContentTypeHeader);
if (!contentType.toString().startsWith(QLatin1String("audio/"))) {
QString err = QStringLiteral("TTS returned non-audio response");
const QJsonObject obj = QJsonDocument::fromJson(body).object();
const QString jsonErr = obj.value(QStringLiteral("error")).toString();
if (!jsonErr.isEmpty())
err = jsonErr;
setLastError(err);
emit ttsFailed(err);
return;
}
const QString file = cacheDir(QStringLiteral("tts"))
+ QStringLiteral("/reply-%1.mp3")
.arg(QDateTime::currentMSecsSinceEpoch());
QFile f(file);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
const QString err = QStringLiteral("Cannot write audio file");
setLastError(err);
emit ttsFailed(err);
return;
}
f.write(body);
f.close();
emit ttsReady(file);
});
}
+131
View File
@@ -0,0 +1,131 @@
#ifndef APICLIENT_H
#define APICLIENT_H
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QObject>
#include <QVariantList>
#include <QVariantMap>
#include "cookiejar.h"
#include "settings.h"
#include "sseparser.h"
class QHttpMultiPart;
// Client HTTP per la Web UI di Hermes (hermes-webui).
//
// Protocollo (vedi docs/PROTOCOL.md, validato contro il server reale):
// POST /api/auth/login {password} -> cookie di sessione
// GET /api/auth/status -> {auth_enabled, logged_in}
// GET /api/sessions -> {sessions: [...]}
// GET /api/session?session_id=ID -> {session: {...messages}}
// POST /api/session/new {workspace?} -> {session: {...}}
// POST /api/chat/start {session_id, message, ...} -> {stream_id, ...}
// GET /api/chat/stream?stream_id=ID -> SSE (text/event-stream)
// GET /api/chat/cancel?stream_id=ID -> {ok, cancelled}
// POST /api/transcribe (multipart "file") -> {ok, transcript}
// POST /api/tts {text, voice, engine} -> audio/mpeg
//
// L'autenticazione e' a cookie: il jar e' persistente (CookieJar).
class ApiClient : public QObject
{
Q_OBJECT
Q_PROPERTY(bool loggedIn READ loggedIn NOTIFY loggedInChanged)
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
Q_PROPERTY(bool transcribing READ transcribing NOTIFY transcribingChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(QVariantList sessions READ sessions NOTIFY sessionsChanged)
Q_PROPERTY(QVariantMap session READ session NOTIFY sessionChanged)
public:
explicit ApiClient(Settings *settings, QObject *parent = nullptr);
bool loggedIn() const { return m_loggedIn; }
bool busy() const { return m_busy; }
bool streaming() const { return m_streaming; }
bool transcribing() const { return m_transcribing; }
QString lastError() const { return m_lastError; }
QVariantList sessions() const { return m_sessions; }
QVariantMap session() const { return m_session; }
// Avvio: verifica lo stato auth (cookie persistito o auth disabilitata).
Q_INVOKABLE void checkAuth();
Q_INVOKABLE void login(const QString &password);
Q_INVOKABLE void logout();
Q_INVOKABLE void refreshSessions();
Q_INVOKABLE void openSession(const QString &id);
Q_INVOKABLE void newSession();
// Avvia un turno e collega automaticamente lo stream SSE.
Q_INVOKABLE void startChat(const QString &sessionId, const QString &message);
Q_INVOKABLE void cancelRun();
// Voce: trascrizione di un file audio (multipart) e sintesi TTS.
Q_INVOKABLE void transcribeFile(const QString &path);
Q_INVOKABLE void speak(const QString &text);
Q_INVOKABLE void clearError();
signals:
void loggedInChanged();
void busyChanged();
void streamingChanged();
void transcribingChanged();
void lastErrorChanged();
void sessionsChanged();
void sessionChanged();
void authChecked(bool loggedIn);
void sessionOpened(); // openSession/newSession completata
void chatStarted(const QString &streamId);
void chatStartFailed(const QString &error);
void streamDelta(const QString &text);
void streamDone(const QVariantMap &session);
void streamTitle(const QString &title);
void streamEnded();
void streamError(const QString &error);
void transcriptionReady(const QString &text);
void transcriptionFailed(const QString &error);
void ttsReady(const QString &filePath);
void ttsFailed(const QString &error);
private:
void setBusy(bool on);
void setStreaming(bool on);
void setTranscribing(bool on);
void setLastError(const QString &error);
void setLoggedIn(bool on);
QUrl apiUrl(const QString &path) const;
QNetworkRequest jsonRequest(const QString &path, bool post = false) const;
QString errorFromReply(QNetworkReply *reply, const QByteArray &body) const;
QString cacheDir(const QString &sub) const;
void startStream(const QString &streamId);
void dispatchSseEvent(const SseParser::Event &ev);
void emitStreamEndedOnce();
void handleStartReply(QNetworkReply *reply);
Settings *m_settings;
QNetworkAccessManager m_nam;
CookieJar *m_jar;
bool m_loggedIn;
bool m_busy;
bool m_streaming;
bool m_transcribing;
QString m_lastError;
QVariantList m_sessions;
QVariantMap m_session;
QString m_streamId;
QNetworkReply *m_streamReply;
SseParser m_parser;
bool m_streamEndedEmitted;
};
#endif // APICLIENT_H
+252
View File
@@ -0,0 +1,252 @@
#include "chatmodel.h"
#include <QTimer>
ChatModel::ChatModel(ApiClient *api, QObject *parent)
: QAbstractListModel(parent)
, m_api(api)
, m_busy(false)
, m_finalizedThisRun(false)
{
connect(m_api, &ApiClient::sessionOpened, this, &ChatModel::onSessionOpened);
connect(m_api, &ApiClient::chatStarted, this, &ChatModel::onChatStarted);
connect(m_api, &ApiClient::chatStartFailed, this, &ChatModel::onChatStartFailed);
connect(m_api, &ApiClient::streamDelta, this, &ChatModel::onStreamDelta);
connect(m_api, &ApiClient::streamDone, this, &ChatModel::onStreamDone);
connect(m_api, &ApiClient::streamEnded, this, &ChatModel::onStreamEnded);
connect(m_api, &ApiClient::streamError, this, &ChatModel::onStreamError);
}
int ChatModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid())
return 0;
return m_msgs.size();
}
QVariant ChatModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_msgs.size())
return QVariant();
const Msg &m = m_msgs.at(index.row());
switch (role) {
case RoleRole:
return m.role;
case ContentRole:
return m.content;
case StreamingRole:
return m.streaming;
case TimestampRole:
return m.ts;
default:
return QVariant();
}
}
QHash<int, QByteArray> ChatModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles.insert(RoleRole, "role");
roles.insert(ContentRole, "content");
roles.insert(StreamingRole, "streaming");
roles.insert(TimestampRole, "timestamp");
return roles;
}
// ------------------------------------------------------------ flusso chat --
void ChatModel::openSession(const QString &id)
{
m_sessionId = id;
m_title.clear();
setFromMessages(QVariantList());
emit sessionInfoChanged();
m_api->openSession(id);
}
void ChatModel::newSession()
{
m_title.clear();
setFromMessages(QVariantList());
emit sessionInfoChanged();
m_api->newSession();
}
void ChatModel::resync()
{
if (!m_sessionId.isEmpty())
m_api->openSession(m_sessionId);
}
void ChatModel::send(const QString &text)
{
const QString trimmed = text.trimmed();
if (trimmed.isEmpty() || m_sessionId.isEmpty() || m_busy)
return;
m_finalizedThisRun = false;
appendMessage(QStringLiteral("user"), trimmed, false);
beginAssistant();
setBusy(true);
m_api->startChat(m_sessionId, trimmed);
}
void ChatModel::cancel()
{
m_api->cancelRun();
}
QString ChatModel::lastAssistantText() const
{
for (int i = m_msgs.size() - 1; i >= 0; --i) {
const Msg &m = m_msgs.at(i);
if (m.role == QLatin1String("assistant") && !m.content.trimmed().isEmpty())
return m.content;
}
return QString();
}
void ChatModel::onSessionOpened()
{
const QVariantMap s = m_api->session();
const QString sid = s.value(QStringLiteral("session_id")).toString();
if (!sid.isEmpty())
m_sessionId = sid;
m_title = s.value(QStringLiteral("title")).toString();
setFromMessages(s.value(QStringLiteral("messages")).toList());
emit sessionInfoChanged();
// Ripresa: la sessione aveva un run attivo e lo stream e' stato riagganciato.
if (m_api->streaming()) {
beginAssistant();
setBusy(true);
}
}
void ChatModel::onChatStarted(const QString &streamId)
{
Q_UNUSED(streamId)
if (m_msgs.isEmpty() || m_msgs.last().role != QLatin1String("assistant"))
beginAssistant();
setBusy(true);
}
void ChatModel::onChatStartFailed(const QString &error)
{
// Rimuove il placeholder assistente rimasto vuoto.
if (m_msgs.size() > 0 && m_msgs.last().role == QLatin1String("assistant")
&& m_msgs.last().content.isEmpty()) {
beginRemoveRows(QModelIndex(), m_msgs.size() - 1, m_msgs.size() - 1);
m_msgs.removeLast();
endRemoveRows();
emit rowCountChanged();
}
setBusy(false);
emit notice(error);
}
void ChatModel::onStreamDelta(const QString &text)
{
if (m_msgs.isEmpty() || m_msgs.last().role != QLatin1String("assistant"))
beginAssistant();
Msg &last = m_msgs.last();
if (!last.streaming) {
last.streaming = true;
}
last.content += text;
const QModelIndex idx = index(m_msgs.size() - 1, 0);
emit dataChanged(idx, idx, QVector<int>() << ContentRole << StreamingRole);
emit streamTick();
}
void ChatModel::onStreamDone(const QVariantMap &session)
{
m_finalizedThisRun = true;
m_title = session.value(QStringLiteral("title")).toString();
setFromMessages(session.value(QStringLiteral("messages")).toList());
emit sessionInfoChanged();
setBusy(false);
emit assistantFinished();
}
void ChatModel::onStreamEnded()
{
if (!m_msgs.isEmpty() && m_msgs.last().streaming) {
Msg &last = m_msgs.last();
last.streaming = false;
// Placeholder rimasto vuoto (run interrotto prima del primo token).
if (last.role == QLatin1String("assistant") && last.content.isEmpty()) {
beginRemoveRows(QModelIndex(), m_msgs.size() - 1, m_msgs.size() - 1);
m_msgs.removeLast();
endRemoveRows();
emit rowCountChanged();
} else {
const QModelIndex idx = index(m_msgs.size() - 1, 0);
emit dataChanged(idx, idx, QVector<int>() << StreamingRole);
}
}
setBusy(false);
// Aggiorna la lista sessioni (titolo/ordinamento possono essere cambiati).
m_api->refreshSessions();
}
void ChatModel::onStreamError(const QString &error)
{
emit notice(error);
// Lo stream puo' cadere per sospensione dell'app: risincronizza dallo stato
// del server tra un attimo (se nel frattempo non e' gia' ripartito qualcosa).
QTimer::singleShot(1200, this, [this]() {
if (!m_api->streaming() && !m_sessionId.isEmpty())
m_api->openSession(m_sessionId);
});
}
// ------------------------------------------------------------- modello ----
void ChatModel::setFromMessages(const QVariantList &messages)
{
beginResetModel();
m_msgs.clear();
for (int i = 0; i < messages.size(); ++i) {
const QVariantMap m = messages.at(i).toMap();
const QString role = m.value(QStringLiteral("role")).toString();
if (role != QLatin1String("user") && role != QLatin1String("assistant"))
continue;
const QString content = m.value(QStringLiteral("content")).toString();
// Messaggi assistente vuoti (solo chiamate a strumenti): non mostrati.
if (role == QLatin1String("assistant") && content.trimmed().isEmpty())
continue;
Msg msg;
msg.role = role;
msg.content = content;
msg.streaming = false;
msg.ts = m.value(QStringLiteral("timestamp")).toDouble();
m_msgs.append(msg);
}
endResetModel();
emit rowCountChanged();
}
void ChatModel::appendMessage(const QString &role, const QString &content, bool streaming)
{
beginInsertRows(QModelIndex(), m_msgs.size(), m_msgs.size());
Msg msg;
msg.role = role;
msg.content = content;
msg.streaming = streaming;
msg.ts = 0;
m_msgs.append(msg);
endInsertRows();
emit rowCountChanged();
}
void ChatModel::beginAssistant()
{
appendMessage(QStringLiteral("assistant"), QString(), true);
}
void ChatModel::setBusy(bool on)
{
if (on == m_busy)
return;
m_busy = on;
emit busyChanged();
}
+93
View File
@@ -0,0 +1,93 @@
#ifndef CHATMODEL_H
#define CHATMODEL_H
#include <QAbstractListModel>
#include <QHash>
#include <QList>
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#include "apiclient.h"
// Modello della conversazione corrente + orchestrazione del flusso chat.
//
// Riceve gli eventi da ApiClient e mantiene la lista messaggi per la UI:
// - apertura/nuova sessione -> reset dal JSON della sessione
// - invio -> messaggio utente ottimistico + placeholder assistente
// - streaming -> append dei delta "token"
// - done -> risincronizzazione con i messaggi finali della sessione
class ChatModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionInfoChanged)
Q_PROPERTY(QString title READ title NOTIFY sessionInfoChanged)
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(bool canSend READ canSend NOTIFY busyChanged)
Q_PROPERTY(int count READ count NOTIFY rowCountChanged)
public:
enum Roles {
RoleRole = Qt::UserRole + 1,
ContentRole,
StreamingRole,
TimestampRole
};
explicit ChatModel(ApiClient *api, QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
QString sessionId() const { return m_sessionId; }
QString title() const { return m_title; }
bool busy() const { return m_busy; }
bool canSend() const { return !m_busy && !m_sessionId.isEmpty(); }
int count() const { return m_msgs.size(); }
Q_INVOKABLE void openSession(const QString &id);
Q_INVOKABLE void newSession();
Q_INVOKABLE void send(const QString &text);
Q_INVOKABLE void cancel();
Q_INVOKABLE void resync();
Q_INVOKABLE QString lastAssistantText() const;
signals:
void sessionInfoChanged();
void busyChanged();
void rowCountChanged();
void streamTick(); // ogni delta di streaming (per l'auto-scroll)
void assistantFinished(); // risposta completata (per auto-read TTS)
void notice(const QString &text); // avvisi non bloccanti per la UI
private:
struct Msg {
QString role;
QString content;
bool streaming;
double ts;
};
void setFromMessages(const QVariantList &messages);
void appendMessage(const QString &role, const QString &content, bool streaming);
void beginAssistant();
void setBusy(bool on);
void onSessionOpened();
void onChatStarted(const QString &streamId);
void onChatStartFailed(const QString &error);
void onStreamDelta(const QString &text);
void onStreamDone(const QVariantMap &session);
void onStreamEnded();
void onStreamError(const QString &error);
ApiClient *m_api;
QList<Msg> m_msgs;
QString m_sessionId;
QString m_title;
bool m_busy;
bool m_finalizedThisRun;
};
#endif // CHATMODEL_H
+72
View File
@@ -0,0 +1,72 @@
#include "cookiejar.h"
#include <QFile>
#include <QDir>
#include <QFileInfo>
#include <QNetworkCookie>
CookieJar::CookieJar(QObject *parent)
: QNetworkCookieJar(parent)
{
}
void CookieJar::setFilePath(const QString &path)
{
m_path = path;
}
QString CookieJar::filePath() const
{
return m_path;
}
bool CookieJar::load()
{
if (m_path.isEmpty())
return false;
QFile f(m_path);
if (!f.open(QIODevice::ReadOnly))
return false;
const QByteArray data = f.readAll();
f.close();
const QList<QNetworkCookie> cookies = QNetworkCookie::parseCookies(data);
if (cookies.isEmpty())
return false;
setAllCookies(cookies);
return true;
}
bool CookieJar::save() const
{
if (m_path.isEmpty())
return false;
const QFileInfo info(m_path);
QDir().mkpath(info.absolutePath());
QFile f(m_path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate))
return false;
// Permessi solo proprietario PRIMA di scrivere il contenuto.
f.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
const QList<QNetworkCookie> cookies = allCookies();
for (int i = 0; i < cookies.size(); ++i) {
f.write(cookies.at(i).toRawForm(QNetworkCookie::Full));
f.write("\n");
}
f.close();
return true;
}
void CookieJar::clear()
{
setAllCookies(QList<QNetworkCookie>());
if (!m_path.isEmpty())
QFile::remove(m_path);
}
bool CookieJar::setCookiesFromUrl(const QList<QNetworkCookie> &cookieList, const QUrl &url)
{
const bool changed = QNetworkCookieJar::setCookiesFromUrl(cookieList, url);
if (changed)
save();
return changed;
}
+31
View File
@@ -0,0 +1,31 @@
#ifndef COOKIEJAR_H
#define COOKIEJAR_H
#include <QNetworkCookie>
#include <QNetworkCookieJar>
#include <QString>
// Cookie jar persistente su file (0600).
// Il cookie di sessione del WebUI di Hermes e' una credenziale: vive solo in
// ~/.config/harbour/hermes/cookies.dat con permessi del solo proprietario.
// Il salvataggio avviene automaticamente a ogni inserimento di cookie.
class CookieJar : public QNetworkCookieJar
{
public:
explicit CookieJar(QObject *parent = nullptr);
void setFilePath(const QString &path);
QString filePath() const;
bool load();
bool save() const;
void clear();
protected:
bool setCookiesFromUrl(const QList<QNetworkCookie> &cookieList, const QUrl &url) override;
private:
QString m_path;
};
#endif // COOKIEJAR_H
+48
View File
@@ -0,0 +1,48 @@
#include <QGuiApplication>
#include <QQmlContext>
#include <QQuickView>
#include <QDebug>
#include <sailfishapp.h>
#include "apiclient.h"
#include "chatmodel.h"
#include "player.h"
#include "recorder.h"
#include "settings.h"
Q_DECL_EXPORT int main(int argc, char *argv[])
{
QGuiApplication *app = SailfishApp::application(argc, argv);
// Identita' allineata al profilo X-Sailjail del desktop file:
// AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox).
app->setOrganizationName(QStringLiteral("harbour"));
app->setApplicationName(QStringLiteral("hermes"));
app->setApplicationVersion(QStringLiteral("0.1.0"));
qDebug() << "harbour-hermes v0.1.0 build" << __DATE__ << __TIME__;
Settings settings;
ApiClient api(&settings);
ChatModel chat(&api);
Recorder recorder;
Player player;
// Flusso voce: registrazione -> trascrizione; TTS pronto -> riproduzione.
QObject::connect(&recorder, &Recorder::finished, &api, &ApiClient::transcribeFile);
QObject::connect(&api, &ApiClient::ttsReady, &player, &Player::play);
QQuickView *view = SailfishApp::createView();
view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings);
view->rootContext()->setContextProperty(QStringLiteral("api"), &api);
view->rootContext()->setContextProperty(QStringLiteral("chat"), &chat);
view->rootContext()->setContextProperty(QStringLiteral("recorder"), &recorder);
view->rootContext()->setContextProperty(QStringLiteral("player"), &player);
view->rootContext()->setContextProperty(QStringLiteral("appVersion"),
QCoreApplication::applicationVersion());
view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-hermes.qml")));
view->show();
return app->exec();
}
+39
View File
@@ -0,0 +1,39 @@
#include "player.h"
#include <QMediaContent>
#include <QUrl>
Player::Player(QObject *parent)
: QObject(parent)
{
connect(&m_player, &QMediaPlayer::stateChanged, this,
[this](QMediaPlayer::State) { emit playingChanged(); });
connect(&m_player, &QMediaPlayer::mediaStatusChanged, this,
[this](QMediaPlayer::MediaStatus status) {
if (status == QMediaPlayer::EndOfMedia
|| status == QMediaPlayer::InvalidMedia) {
emit playingChanged();
emit finished();
}
});
}
bool Player::playing() const
{
return m_player.state() == QMediaPlayer::PlayingState;
}
void Player::play(const QString &path)
{
stop();
m_player.setMedia(QMediaContent(QUrl::fromLocalFile(path)));
m_player.play();
emit playingChanged();
}
void Player::stop()
{
if (m_player.state() != QMediaPlayer::StoppedState)
m_player.stop();
emit playingChanged();
}
+31
View File
@@ -0,0 +1,31 @@
#ifndef PLAYER_H
#define PLAYER_H
#include <QMediaPlayer>
#include <QObject>
#include <QString>
// Riproduttore audio per le risposte TTS (QMediaPlayer, mp3 dal server).
// L'output passa da PulseAudio; il volume segue il sistema.
class Player : public QObject
{
Q_OBJECT
Q_PROPERTY(bool playing READ playing NOTIFY playingChanged)
public:
explicit Player(QObject *parent = nullptr);
bool playing() const;
Q_INVOKABLE void play(const QString &path);
Q_INVOKABLE void stop();
signals:
void playingChanged();
void finished();
private:
QMediaPlayer m_player;
};
#endif // PLAYER_H
+143
View File
@@ -0,0 +1,143 @@
#include "recorder.h"
#include <QAudioEncoderSettings>
#include <QAudioRecorder>
#include <QDateTime>
#include <QDir>
#include <QStandardPaths>
#include <QUrl>
#include <QVideoEncoderSettings>
namespace {
// Container preferito: OGG; ripiego WAV.
QString chooseContainer(const QStringList &containers)
{
if (containers.contains(QStringLiteral("audio/ogg")))
return QStringLiteral("audio/ogg");
if (containers.contains(QStringLiteral("audio/x-wav")))
return QStringLiteral("audio/x-wav");
return containers.isEmpty() ? QString() : containers.first();
}
// Codec preferito: Opus, poi Vorbis, poi PCM.
QString chooseCodec(const QStringList &codecs)
{
if (codecs.contains(QStringLiteral("audio/opus")))
return QStringLiteral("audio/opus");
if (codecs.contains(QStringLiteral("audio/vorbis")))
return QStringLiteral("audio/vorbis");
if (codecs.contains(QStringLiteral("audio/pcm")))
return QStringLiteral("audio/pcm");
return codecs.isEmpty() ? QString() : codecs.first();
}
} // namespace
Recorder::Recorder(QObject *parent)
: QObject(parent)
, m_rec(new QAudioRecorder(this))
, m_stopping(false)
, m_fallbackTried(false)
{
connect(m_rec,
static_cast<void (QAudioRecorder::*)(QMediaRecorder::State)>(&QAudioRecorder::stateChanged),
this, [this](QMediaRecorder::State) { onStateChanged(); });
connect(m_rec,
static_cast<void (QAudioRecorder::*)(QMediaRecorder::Error)>(&QAudioRecorder::error),
this, [this](QMediaRecorder::Error) { onError(); });
m_tick.setInterval(250);
connect(&m_tick, &QTimer::timeout, this, [this]() { emit durationChanged(); });
}
bool Recorder::recording() const
{
return m_rec->state() == QMediaRecorder::RecordingState;
}
int Recorder::durationMs() const
{
return static_cast<int>(m_rec->duration());
}
bool Recorder::start()
{
if (recording())
return true;
const QString container = chooseContainer(m_rec->supportedContainers());
const QString codec = chooseCodec(m_rec->supportedAudioCodecs());
if (container.isEmpty() || codec.isEmpty()) {
emit failed(QStringLiteral("No audio format available"));
return false;
}
m_fallbackTried = false;
return beginRecording(container, codec);
}
void Recorder::stop()
{
if (!recording())
return;
m_stopping = true;
m_rec->stop();
}
bool Recorder::beginRecording(const QString &container, const QString &codec)
{
const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
const QString dir = base + QStringLiteral("/rec");
QDir().mkpath(dir);
const QString ext = container == QLatin1String("audio/x-wav") ? QStringLiteral(".wav")
: QStringLiteral(".ogg");
m_path = dir + QStringLiteral("/rec-%1%2")
.arg(QDateTime::currentMSecsSinceEpoch())
.arg(ext);
m_container = container;
QAudioEncoderSettings enc;
enc.setCodec(codec);
enc.setSampleRate(16000);
enc.setChannelCount(1);
enc.setQuality(QMultimedia::NormalQuality);
m_rec->setEncodingSettings(enc, QVideoEncoderSettings(), container);
m_rec->setOutputLocation(QUrl::fromLocalFile(m_path));
m_stopping = false;
m_rec->record();
return true;
}
void Recorder::onStateChanged()
{
const bool rec = recording();
if (rec)
m_tick.start();
else
m_tick.stop();
emit recordingChanged();
emit durationChanged();
if (!rec && m_stopping) {
m_stopping = false;
if (m_path.isEmpty())
return;
emit finished(m_path);
}
}
void Recorder::onError()
{
// Primo tentativo fallito (es. codec non disponibile nel target):
// un solo ritentativo con WAV/PCM, poi si arrende.
if (!m_fallbackTried && !m_path.isEmpty()) {
m_fallbackTried = true;
m_rec->stop();
if (beginRecording(QStringLiteral("audio/x-wav"), QStringLiteral("audio/pcm")))
return;
}
m_tick.stop();
emit recordingChanged();
emit failed(m_rec->errorString());
}
+51
View File
@@ -0,0 +1,51 @@
#ifndef RECORDER_H
#define RECORDER_H
#include <QObject>
#include <QString>
#include <QStringList>
#include <QTimer>
class QAudioRecorder;
// Registratore vocale (QAudioRecorder) con fallback di formato:
// container/codec preferiti OGG/Opus, poi OGG/Vorbis, poi WAV/PCM.
// Se il primo tentativo fallisce a runtime (codec assente nel target),
// riprova una volta con WAV/PCM.
// I file finiscono in <AppData>/rec/ (zona dati della sandbox SailJail).
// Il permesso Microphone e' dichiarato nel profilo X-Sailjail del desktop file.
class Recorder : public QObject
{
Q_OBJECT
Q_PROPERTY(bool recording READ recording NOTIFY recordingChanged)
Q_PROPERTY(int durationMs READ durationMs NOTIFY durationChanged)
public:
explicit Recorder(QObject *parent = nullptr);
bool recording() const;
int durationMs() const;
Q_INVOKABLE bool start();
Q_INVOKABLE void stop();
signals:
void recordingChanged();
void durationChanged();
void finished(const QString &path); // file pronto per la trascrizione
void failed(const QString &error);
private:
void onStateChanged();
void onError();
bool beginRecording(const QString &container, const QString &codec);
QAudioRecorder *m_rec;
QTimer m_tick;
QString m_path;
QString m_container;
bool m_stopping;
bool m_fallbackTried;
};
#endif // RECORDER_H
+84
View File
@@ -0,0 +1,84 @@
#include "settings.h"
// Default: l'istanza pubblica del WebUI usa questo host (HTTPS, cert valido).
// L'utente puo' cambiarlo dalle impostazioni (LAN, VPN, altro dominio).
static const char *kDefaultBaseUrl = "https://hermes.hackatoniclife.com";
static const char *kDefaultTtsVoice = "it-IT-ElsaNeural";
static const char *kDefaultTtsEngine = "edge";
Settings::Settings(QObject *parent)
: QObject(parent)
{
}
QString Settings::baseUrl() const
{
QString v = m_settings.value(QStringLiteral("baseUrl"), QString::fromLatin1(kDefaultBaseUrl)).toString();
// Normalizza: niente slash finale (componiamo gli URL con path assoluti).
while (v.endsWith(QLatin1Char('/')))
v.chop(1);
return v;
}
void Settings::setBaseUrl(const QString &url)
{
QString v = url.trimmed();
while (v.endsWith(QLatin1Char('/')))
v.chop(1);
if (v == baseUrl())
return;
m_settings.setValue(QStringLiteral("baseUrl"), v);
emit baseUrlChanged();
}
QString Settings::ttsVoice() const
{
return m_settings.value(QStringLiteral("ttsVoice"), QString::fromLatin1(kDefaultTtsVoice)).toString();
}
void Settings::setTtsVoice(const QString &voice)
{
if (voice == ttsVoice())
return;
m_settings.setValue(QStringLiteral("ttsVoice"), voice);
emit ttsVoiceChanged();
}
QString Settings::ttsEngine() const
{
return m_settings.value(QStringLiteral("ttsEngine"), QString::fromLatin1(kDefaultTtsEngine)).toString();
}
void Settings::setTtsEngine(const QString &engine)
{
if (engine == ttsEngine())
return;
m_settings.setValue(QStringLiteral("ttsEngine"), engine);
emit ttsEngineChanged();
}
bool Settings::ttsAutoRead() const
{
return m_settings.value(QStringLiteral("ttsAutoRead"), false).toBool();
}
void Settings::setTtsAutoRead(bool on)
{
if (on == ttsAutoRead())
return;
m_settings.setValue(QStringLiteral("ttsAutoRead"), on);
emit ttsAutoReadChanged();
}
QString Settings::defaultWorkspace() const
{
return m_settings.value(QStringLiteral("defaultWorkspace"), QString()).toString();
}
void Settings::setDefaultWorkspace(const QString &ws)
{
if (ws == defaultWorkspace())
return;
m_settings.setValue(QStringLiteral("defaultWorkspace"), ws);
emit defaultWorkspaceChanged();
}
+54
View File
@@ -0,0 +1,54 @@
#ifndef SETTINGS_H
#define SETTINGS_H
#include <QObject>
#include <QSettings>
#include <QString>
// Impostazioni persistenti dell'app.
// QSettings con OrganizationName "harbour" / ApplicationName "hermes" (impostati
// in main.cpp, allineati al profilo X-Sailjail del desktop file): file in
// ~/.config/harbour/hermes.conf (zona persistente della sandbox).
//
// La password NON viene mai salvata: l'autenticazione si basa sul cookie di
// sessione persistito in modo protetto (vedi CookieJar).
class Settings : public QObject
{
Q_OBJECT
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
Q_PROPERTY(QString ttsVoice READ ttsVoice WRITE setTtsVoice NOTIFY ttsVoiceChanged)
Q_PROPERTY(QString ttsEngine READ ttsEngine WRITE setTtsEngine NOTIFY ttsEngineChanged)
Q_PROPERTY(bool ttsAutoRead READ ttsAutoRead WRITE setTtsAutoRead NOTIFY ttsAutoReadChanged)
Q_PROPERTY(QString defaultWorkspace READ defaultWorkspace WRITE setDefaultWorkspace
NOTIFY defaultWorkspaceChanged)
public:
explicit Settings(QObject *parent = nullptr);
QString baseUrl() const;
void setBaseUrl(const QString &url);
QString ttsVoice() const;
void setTtsVoice(const QString &voice);
QString ttsEngine() const;
void setTtsEngine(const QString &engine);
bool ttsAutoRead() const;
void setTtsAutoRead(bool on);
QString defaultWorkspace() const;
void setDefaultWorkspace(const QString &ws);
signals:
void baseUrlChanged();
void ttsVoiceChanged();
void ttsEngineChanged();
void ttsAutoReadChanged();
void defaultWorkspaceChanged();
private:
QSettings m_settings;
};
#endif // SETTINGS_H
+59
View File
@@ -0,0 +1,59 @@
#include "sseparser.h"
SseParser::SseParser()
{
}
QList<SseParser::Event> SseParser::feed(const QByteArray &chunk)
{
m_buffer += chunk;
// Normalizza le terminazioni di riga (il server usa \n, ma per sicurezza).
if (m_buffer.contains('\r'))
m_buffer.replace("\r\n", "\n");
QList<Event> events;
int idx = -1;
while ((idx = m_buffer.indexOf("\n\n")) >= 0) {
const QByteArray block = m_buffer.left(idx);
m_buffer.remove(0, idx + 2);
Event ev;
bool hasData = false;
const QList<QByteArray> lines = block.split('\n');
for (int i = 0; i < lines.size(); ++i) {
const QByteArray &line = lines.at(i);
if (line.isEmpty() || line.startsWith(':'))
continue; // riga vuota o commento/keepalive
if (line.startsWith("event:")) {
ev.name = QString::fromUtf8(line.mid(6)).trimmed();
} else if (line.startsWith("data:")) {
QString d = QString::fromUtf8(line.mid(5));
if (d.startsWith(QLatin1Char(' ')))
d.remove(0, 1);
if (hasData)
ev.data += QLatin1Char('\n');
ev.data += d;
hasData = true;
} else if (line.startsWith("id:")) {
ev.id = QString::fromUtf8(line.mid(3)).trimmed();
}
}
// Blocco di soli commenti/keepalive: niente da consegnare.
if (!hasData)
continue;
if (ev.name.isEmpty())
ev.name = QStringLiteral("message");
events.append(ev);
}
return events;
}
void SseParser::reset()
{
m_buffer.clear();
}
QByteArray SseParser::pending() const
{
return m_buffer;
}
+40
View File
@@ -0,0 +1,40 @@
#ifndef SSEPARSER_H
#define SSEPARSER_H
#include <QByteArray>
#include <QList>
#include <QString>
// Parser incrementale di Server-Sent Events (text/event-stream).
//
// Il WebUI di Hermes emette eventi nella forma:
// id: <stream_id>:<seq>
// event: token
// data: {"text": "..."}
// <riga vuota>
// piu' commenti di keepalive (": heartbeat") da ignorare.
//
// Logica pura, senza dipendenze da rete: alimentato con i chunk grezzi,
// restituisce gli eventi completi. Testabile in locale (tests/sse_test.cpp).
class SseParser
{
public:
struct Event {
QString name; // campo "event:" (vuoto => "message" SSE standard)
QString data; // campi "data:" concatenati con "\n"
QString id; // campo "id:" (cursore per replay)
};
SseParser();
// Aggiunge un chunk e restituisce tutti gli eventi completati.
QList<Event> feed(const QByteArray &chunk);
void reset();
QByteArray pending() const;
private:
QByteArray m_buffer;
};
#endif // SSEPARSER_H
+166
View File
@@ -0,0 +1,166 @@
// Test locale del core di harbour-hermes (compilato con Qt desktop).
//
// Uso:
// ./core-test -> solo unit test del parser SSE
// ./core-test --live -> integrazione completa contro l'istanza di prova
// del WebUI su http://127.0.0.1:8899
// (login -> nuova sessione -> turno -> TTS -> STT)
//
// Il test live legge la password di prova da /tmp/hermes-probe/pw e usa
// l'audio di prova /tmp/hermes-probe/out/prova.ogg.
#include <QCoreApplication>
#include <QDebug>
#include <QFile>
#include <QTimer>
#include "apiclient.h"
#include "chatmodel.h"
#include "settings.h"
#include "sseparser.h"
static int failures = 0;
#define CHECK(cond, msg) \
do { \
if (!(cond)) { \
qWarning() << "FAIL:" << msg; \
++failures; \
} else { \
qDebug() << "ok :" << msg; \
} \
} while (0)
static void testParser()
{
qDebug() << "== unit: parser SSE ==";
const QByteArray sample =
"id: s1:1\n"
"event: context_status\n"
"data: {\"a\": 1}\n"
"\n"
": heartbeat\n"
"\n"
"id: s1:2\n"
"event: token\n"
"data: {\"text\": \"p\"}\n"
"\n";
SseParser p;
const QList<SseParser::Event> evs = p.feed(sample);
CHECK(evs.size() == 2, "due eventi, keepalive ignorato");
if (evs.size() == 2) {
CHECK(evs.at(0).name == QLatin1String("context_status"), "primo evento context_status");
CHECK(evs.at(0).id == QLatin1String("s1:1"), "id del primo evento");
CHECK(evs.at(1).name == QLatin1String("token"), "secondo evento token");
CHECK(evs.at(1).data == QLatin1String("{\"text\": \"p\"}"), "data del token");
}
// Chunk spezzati a meta' evento (caso reale della rete).
SseParser p2;
CHECK(p2.feed("event: token\ndata: {\"te").isEmpty(), "chunk parziale: nessun evento");
const QList<SseParser::Event> evs2 = p2.feed("xt\": \"ok\"}\n\n");
CHECK(evs2.size() == 1, "evento completato dal secondo chunk");
if (!evs2.isEmpty())
CHECK(evs2.at(0).data.contains(QLatin1String("ok")), "payload ricomposto");
// Evento con data su piu' righe.
SseParser p3;
const QList<SseParser::Event> evs3 = p3.feed("event: x\ndata: a\ndata: b\n\n");
CHECK(evs3.size() == 1 && evs3.at(0).data == QLatin1String("a\nb"),
"data multi-riga unito con newline");
}
static QString readFileTrim(const QString &path)
{
QFile f(path);
if (!f.open(QIODevice::ReadOnly))
return QString();
return QString::fromUtf8(f.readAll()).trimmed();
}
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
app.setOrganizationName(QStringLiteral("harbour-test"));
app.setApplicationName(QStringLiteral("hermes-test"));
testParser();
if (!app.arguments().contains(QStringLiteral("--live"))) {
qDebug() << (failures == 0 ? "UNIT: tutti i test passati" : "UNIT: FALLIMENTI");
return failures == 0 ? 0 : 1;
}
qDebug() << "== integrazione live (probe :8899) ==";
QTimer::singleShot(180000, &app, []() {
qWarning() << "TIMEOUT globale del test live";
qApp->exit(2);
});
Settings settings;
settings.setBaseUrl(QStringLiteral("http://127.0.0.1:8899"));
ApiClient api(&settings);
ChatModel chat(&api);
const QString pw = readFileTrim(QStringLiteral("/tmp/hermes-probe/pw"));
if (pw.isEmpty()) {
qWarning() << "password del probe mancante in /tmp/hermes-probe/pw";
return 1;
}
bool chatSent = false;
bool chatDone = false;
bool ttsDone = false;
bool sttDone = false;
QObject::connect(&api, &ApiClient::loggedInChanged, [&]() {
if (api.loggedIn()) {
qDebug() << "LIVE: login ok";
chat.newSession();
}
});
QObject::connect(&api, &ApiClient::sessionOpened, [&]() {
if (chatSent)
return;
chatSent = true;
qDebug() << "LIVE: sessione" << chat.sessionId();
chat.send(QStringLiteral("Rispondi solo con la parola pong. Non usare strumenti."));
});
QObject::connect(&chat, &ChatModel::assistantFinished, [&]() {
qDebug() << "LIVE: risposta =" << chat.lastAssistantText();
chatDone = chat.lastAssistantText().contains(QLatin1String("pong"));
api.speak(QStringLiteral("Questa e' la voce italiana del client Hermes per Sailfish."));
});
QObject::connect(&api, &ApiClient::ttsReady, [&](const QString &path) {
QFile f(path);
ttsDone = f.size() > 1000;
qDebug() << "LIVE: ttsReady" << path << "bytes:" << f.size();
api.transcribeFile(QStringLiteral("/tmp/hermes-probe/out/prova.ogg"));
});
QObject::connect(&api, &ApiClient::ttsFailed, [&](const QString &err) {
qWarning() << "LIVE: ttsFailed" << err;
api.transcribeFile(QStringLiteral("/tmp/hermes-probe/out/prova.ogg"));
});
QObject::connect(&api, &ApiClient::transcriptionReady, [&](const QString &text) {
qDebug() << "LIVE: trascrizione =" << text;
sttDone = !text.isEmpty();
QTimer::singleShot(200, qApp, [&]() {
const bool pass = chatDone && ttsDone && sttDone;
qDebug() << (pass ? "LIVE: TUTTI I PASSAGGI OK" : "LIVE: qualche passaggio e' fallito");
qApp->exit(pass ? 0 : 1);
});
});
QObject::connect(&api, &ApiClient::transcriptionFailed, [&](const QString &err) {
qWarning() << "LIVE: transcriptionFailed" << err;
qApp->exit(1);
});
QObject::connect(&api, &ApiClient::chatStartFailed, [&](const QString &err) {
qWarning() << "LIVE: chatStartFailed" << err;
qApp->exit(1);
});
api.login(pw);
qDebug() << "LIVE: login inviato";
return app.exec();
}
+33
View File
@@ -0,0 +1,33 @@
TEMPLATE = app
TARGET = core-test
CONFIG += console c++11
QT += core network multimedia
# Workaround per la build locale su desktop senza libGL.so di sviluppo:
# se esiste lo stub dir con il symlink a libGL.so.1, usalo.
GLSTUB = /tmp/hh-test/gl
exists($$GLSTUB/libGL.so) {
LIBS += -L$$GLSTUB
}
SRC = $$PWD/../src
INCLUDEPATH += $$SRC
SOURCES += \
$$SRC/sseparser.cpp \
$$SRC/cookiejar.cpp \
$$SRC/settings.cpp \
$$SRC/apiclient.cpp \
$$SRC/chatmodel.cpp \
$$SRC/recorder.cpp \
$$SRC/player.cpp \
core_test.cpp
HEADERS += \
$$SRC/sseparser.h \
$$SRC/cookiejar.h \
$$SRC/settings.h \
$$SRC/apiclient.h \
$$SRC/chatmodel.h \
$$SRC/recorder.h \
$$SRC/player.h