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:
@@ -0,0 +1,8 @@
|
||||
*.o
|
||||
moc_*
|
||||
qmake.stash
|
||||
Makefile*
|
||||
*.qm
|
||||
build/
|
||||
core-test
|
||||
*.tar.gz
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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…")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user