From 9c6a1242f7f79ee081f9a0558989766a28aabefc Mon Sep 17 00:00:00 2001 From: Carlo Baratto Date: Sat, 12 Sep 2026 07:11:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v0.1.0=20=E2=80=94=20client=20Sailfish?= =?UTF-8?q?=20per=20hermes-webui=20con=20voce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 8 + README.md | 34 +++ docs/BUILD.md | 93 ++++++ docs/PROTOCOL.md | 175 +++++++++++ harbour-hermes.pro | 47 +++ icons/108x108/harbour-hermes.png | Bin 0 -> 921 bytes icons/128x128/harbour-hermes.png | Bin 0 -> 1072 bytes icons/172x172/harbour-hermes.png | Bin 0 -> 1445 bytes icons/86x86/harbour-hermes.png | Bin 0 -> 739 bytes qml/cover/CoverPage.qml | 30 ++ qml/harbour-hermes.qml | 14 + qml/pages/ChatPage.qml | 240 +++++++++++++++ qml/pages/LoginPage.qml | 76 +++++ qml/pages/SessionsPage.qml | 115 +++++++ qml/pages/SettingsPage.qml | 108 +++++++ qml/pages/SplashPage.qml | 41 +++ rpm/harbour-hermes.desktop | 22 ++ rpm/harbour-hermes.spec | 63 ++++ scripts/make_icons.py | 56 ++++ src/apiclient.cpp | 505 +++++++++++++++++++++++++++++++ src/apiclient.h | 131 ++++++++ src/chatmodel.cpp | 252 +++++++++++++++ src/chatmodel.h | 93 ++++++ src/cookiejar.cpp | 72 +++++ src/cookiejar.h | 31 ++ src/main.cpp | 48 +++ src/player.cpp | 39 +++ src/player.h | 31 ++ src/recorder.cpp | 143 +++++++++ src/recorder.h | 51 ++++ src/settings.cpp | 84 +++++ src/settings.h | 54 ++++ src/sseparser.cpp | 59 ++++ src/sseparser.h | 40 +++ tests/core_test.cpp | 166 ++++++++++ tests/core_test.pro | 33 ++ 36 files changed, 2954 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/BUILD.md create mode 100644 docs/PROTOCOL.md create mode 100644 harbour-hermes.pro create mode 100644 icons/108x108/harbour-hermes.png create mode 100644 icons/128x128/harbour-hermes.png create mode 100644 icons/172x172/harbour-hermes.png create mode 100644 icons/86x86/harbour-hermes.png create mode 100644 qml/cover/CoverPage.qml create mode 100644 qml/harbour-hermes.qml create mode 100644 qml/pages/ChatPage.qml create mode 100644 qml/pages/LoginPage.qml create mode 100644 qml/pages/SessionsPage.qml create mode 100644 qml/pages/SettingsPage.qml create mode 100644 qml/pages/SplashPage.qml create mode 100644 rpm/harbour-hermes.desktop create mode 100644 rpm/harbour-hermes.spec create mode 100644 scripts/make_icons.py create mode 100644 src/apiclient.cpp create mode 100644 src/apiclient.h create mode 100644 src/chatmodel.cpp create mode 100644 src/chatmodel.h create mode 100644 src/cookiejar.cpp create mode 100644 src/cookiejar.h create mode 100644 src/main.cpp create mode 100644 src/player.cpp create mode 100644 src/player.h create mode 100644 src/recorder.cpp create mode 100644 src/recorder.h create mode 100644 src/settings.cpp create mode 100644 src/settings.h create mode 100644 src/sseparser.cpp create mode 100644 src/sseparser.h create mode 100644 tests/core_test.cpp create mode 100644 tests/core_test.pro diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09cab8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.o +moc_* +qmake.stash +Makefile* +*.qm +build/ +core-test +*.tar.gz diff --git a/README.md b/README.md new file mode 100644 index 0000000..1386894 --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/BUILD.md b/docs/BUILD.md new file mode 100644 index 0000000..1b0759d --- /dev/null +++ b/docs/BUILD.md @@ -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=/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. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..b8861e6 --- /dev/null +++ b/docs/PROTOCOL.md @@ -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: :`: + +| 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. diff --git a/harbour-hermes.pro b/harbour-hermes.pro new file mode 100644 index 0000000..5176435 --- /dev/null +++ b/harbour-hermes.pro @@ -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 diff --git a/icons/108x108/harbour-hermes.png b/icons/108x108/harbour-hermes.png new file mode 100644 index 0000000000000000000000000000000000000000..9ae2172fbdbc8852a8f70237dd9d61d788455156 GIT binary patch literal 921 zcmV;K17`e*P)q8 zk`qIg4kdME3xh0OxbYNn@S$)aXyJ!pp<4Foby2g;II}+xMs4%2!EV23{ zVa^B&L%M7b@_>Xn1HzmEVa|XsXF!-UAj}yM<_rjP2821|pY{bl-e0vZ5oNR854*M& zstFq@Yr6jEHb|o)%vzx=wWXD_8N=2hO`GaPW>7XCd4F5TN;EZpeYT)gm`>-`|tJPxKj}aVS+5B5y__w z!i0HzcnhXY3J6molr{+WMjQQP-nkAPZdpfwXtcsF2u9oi8dXB8k zc5g}+(P^4S-3acQFs>}C)!H2r8QSJ%Srmt*EUnpNH({}Kc^5^*ljUk7X-L)@ zL=n{`t7SvZkbl3o23eu;DkjU#!w^eZ7H5C8aA%E6S*_dMx^#(ak+h_AVj;0NQ7vIp zy4EU-gTyp`iAusg-e1pJ6p`L@&MLyrSyo6C9)>WAwk#q;cRIWyJsXUHgejmUiwMHR zm?UY+B7!hcv}F-Nm^&cM84%_S2y;eI*!S1xMel}yQ$ik)FlRuRGor%KEsiBtegk=gHW9sh-;^& v*J|$OAg-L2;YDm*g19u80{{R3ICp*l1i4OUDdxIu00000NkvXXu0mjfv>lMK literal 0 HcmV?d00001 diff --git a/icons/128x128/harbour-hermes.png b/icons/128x128/harbour-hermes.png new file mode 100644 index 0000000000000000000000000000000000000000..d050866cbd2cf1c546ed0ebdfc8582463e779f86 GIT binary patch literal 1072 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVE*pu;uumf=j|NBpw|WhZAWL9 z%W3gBoOWgDI>LYUKJHBQj9VLa>;3CVo;gSVZ`Z!*C&UCa zi-FORbbWvt%joy)So4PR%yN^l3@8q)wZ`(UyJEWg+sMWG3Qm*7e5`o z*?bGJ;4bhBWprDyK+w*a#|n$V2>TLReji_5@V{c|zIYacwrw072_m|j0tYz!`QJZ$ z`S9-pOI{#P;+Bv?gQJP61HT68(Hd{{XW8e}-1d6Y1+7dz#lpiaj$cf0e)AcyK9UI&-T4pmbaTuNb znElYpk3Hx$1B*bCWR+Lz@{XY0!3qrw*^08&_iioPu!=$X&)r+s(r>H^&@TQfRRRoL zhZaGr8)8>kKNJX5yDeq@qT%>z#&)sN>i55|yXA7daBb>7{rb(ub)FNn-MK@qGpMqM zckfM(ZmZO}bLG|i{p+@=`2N$gD_h|Gf5ye82+PE`yk&J?^Us&F%;He!ZT*m}qkZ4$ z^?UuH-+lW(KsWSX3nydH;rE(9ELR>}9=89= zANi(6?Z(CJ^SxHTW>&Lu=(*Tz@0Rt|`$9-V#Mf%BeI*aoKZ<@#V_MVt-t>pSw!`wz zoac9czW+)~EW1 z-+$ZQ82nYZ&T8vne#e!}HFbLgYt*D3wjVHW@>wKW$HAt@dRTGR!}bdYmnP`TM8CYw zXU}5Q5NUFVzmYG2Z|wthU`n`k(BFU+njm56h7*`e)Q``L7fpD}Yaj*+;D+KwOa{C? zjymqZ^k&AofcwkYu)30lMo-OqDvU3f9#6W%IAMW)(C1ULm?kXXFDg0}$E?tpTl85o zibbi>*Q@BJ=#!T>f4-mhy_u()D`?3i!G*HbyEkTNyR+CXS@tli*wX04p?O*wjr-t*K5clZA7*? z8y3c5l@vACYb4~6l1MK!iaWP^&*_|d?)RMU_j|sd@ArJa-+w-zR1bI5CM7K;IXSsa zXcuQsS!S;}SWy;V?L!U7$tj#eJ3AhWE}R{<@!XDvk;i%;7Nh7;om{!~1zvbElwxY7 zV0jtrqwPR9F13gZVlblwjqv>A`Nvm-&`R*0>o*Dui#O=X_Iam13JA@;4*O`38Jnswo>*@0hyw{T0P}3uTS>i zA&G;PSb)$9=hz*i>fuWNoGLvC5RkM%ze-t|UGe7OOT%^eGArQL-jM7CuPuf=r^9Fz z8wA#~kwNB@3l9>J1}8-Oef@Wo@7-nSgoe`JC}D1s_W7$QiFCJ6kp^f{bu%L=Tb#!Q zC*l7ELFpb~jhiq-ryW3|(~ph##k+DSyO9@{P9^T~?ynP?-18Vx+-BRG)e`}$f<}pG zs?%Cy)8YL4G_AMY8{2{(W4UI&8D`on%dH}Dw1PPLiIxvYxpW#n^Caucs{oTeTR;cZ zkIy&#ig-!5_xD5pd;5E%J{G%m=~A4)dR4kyhtM{89OwoV8U+YHM{NdcG{SG3_esKV zt(I42+g@pn>itS>U5%#E@evvQ!4QptJGjK+E>%NlOd1`Z@c>f`NN84LKy)^%9nLj*>@hE0@*-1qe_2! zk3&*?uSZ+^kJvWnC4c>Lfw|NouVz`nDR?a`2)$}`O(OpmB?McRFoVsbuwg8aNHI%m zLedt~^m9%E-lkD-7rQbrRL^M5V-UI4-Wodh5|O6E2}LpO;<%YI!3LYX1fvOEpsLBd zp**jtD6@N17szN}aI7W0V6RgTwT(g!h|;bgX8Yzj?fc-!TbeiPyaKAI>RpYV!Y;d8 z9D_wdU&`E6vS^6}8M2d3UQmc{YQ8AkZo!U!AEJLY{i4HigTna)TWdi#p9+ttG>K_E zYcgp!V5`NW!7E*)Juur5M`i7EoUQ;YN#1Rze*!1JikU3Xw~%D26G+J&;|;ubV%1WE zs~3CCU?(LJ5;vTQm$eVqf+sdyEHm><@`ZS+Op_#Sxg(B59yA4{Y#AgF!z(pRSS{$h z95OH)6K6lbhpAaw;GB%Smoq=;b$E*}=W|ghd><^E6R*ZthQyltLimNP>y;&Qn9+j+ zLIA?2{leymHySqhLQ?u@NH(Xwm`h9yDYum1n)B`la^Jpjtq}Mj-gSE6CJqrhUg%C3 z1B6hfd9NR*fa-#$(eTdDxG^^`e#9Ub7bapO)+^&FOubi7@2?^>E z0@V`xYrHg=q49eyRthsm?%Qlp^+y;BEE*^0dK>{D;df{~C)v7g06Y51g;dCYDC+9T zl}XIk(1Q1KZbRqg9O_ ze3V&>z{#6?o6S_pPzHY*f~kFU^m%%_t3$2J&92mt;2X_x-c&|IX3q`fKw4NaMuPhW4T_ Txi#*Hxitihba$?D!c+eO-yWT# literal 0 HcmV?d00001 diff --git a/icons/86x86/harbour-hermes.png b/icons/86x86/harbour-hermes.png new file mode 100644 index 0000000000000000000000000000000000000000..05522787ad4f3d3375987084c28b9445e5097f3f GIT binary patch literal 739 zcmV<90v!E`P)@;pW!z?6c3*i#q?hX4XjVN3vJ>H&!UPwC3gW!o(_d1&e7d%XP!VDg{`j0IL*Wl>)3% zfK>{xNDZnZPSfv1~6kwGCtWtnIx7sXdwONSWA7GuNZ~=R6yFSu(eIz28=>@El!6mAj zUch>?aZBqO_GP!Hm)+ibo4poF>KfLhf!j6g``hc~&mmk>E^gPbM1Qyj*oRxXZ@Y(e zOHD2}z@D?$f?LV1?F!bM1%%E9Vk;rHu3+tbs$EyGCeCEKx8<&bn}&`3wYNXUPHLiW z7S;vR0t_{ISl{JFp$U~bmEu$dJB4eg#;ST^$HZ;<3oEh;#yG&(V}|x;&T9j;iS5{- zaA)%+>#l~wz9=IK>oz8;uOZJ0)L>Jn zW9;SBxhrRqxv9X$P+R?%JT}d_Yk)|371*^3>L&FanK-O(T3bf7C)I~q`ol!k-|{jjTiGtt^(ltOS#~kJ2K-A8s2LrBbE1>aq9&bs}Lbn(J!3S V1DgpnI 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) + } +} diff --git a/qml/pages/LoginPage.qml b/qml/pages/LoginPage.qml new file mode 100644 index 0000000..eff02bc --- /dev/null +++ b/qml/pages/LoginPage.qml @@ -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) + } +} diff --git a/qml/pages/SessionsPage.qml b/qml/pages/SessionsPage.qml new file mode 100644 index 0000000..c2f5907 --- /dev/null +++ b/qml/pages/SessionsPage.qml @@ -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") + } +} diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml new file mode 100644 index 0000000..f585ba0 --- /dev/null +++ b/qml/pages/SettingsPage.qml @@ -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 +} diff --git a/qml/pages/SplashPage.qml b/qml/pages/SplashPage.qml new file mode 100644 index 0000000..4318635 --- /dev/null +++ b/qml/pages/SplashPage.qml @@ -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")) + } +} diff --git a/rpm/harbour-hermes.desktop b/rpm/harbour-hermes.desktop new file mode 100644 index 0000000..d3a56c8 --- /dev/null +++ b/rpm/harbour-hermes.desktop @@ -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 diff --git a/rpm/harbour-hermes.spec b/rpm/harbour-hermes.spec new file mode 100644 index 0000000..2fe4a87 --- /dev/null +++ b/rpm/harbour-hermes.spec @@ -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 - 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 diff --git a/scripts/make_icons.py b/scripts/make_icons.py new file mode 100644 index 0000000..1fed76d --- /dev/null +++ b/scripts/make_icons.py @@ -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 [] [] +Scrive /x/.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() diff --git a/src/apiclient.cpp b/src/apiclient.cpp new file mode 100644 index 0000000..c470e62 --- /dev/null +++ b/src/apiclient.cpp @@ -0,0 +1,505 @@ +#include "apiclient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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); + }); +} diff --git a/src/apiclient.h b/src/apiclient.h new file mode 100644 index 0000000..862a55d --- /dev/null +++ b/src/apiclient.h @@ -0,0 +1,131 @@ +#ifndef APICLIENT_H +#define APICLIENT_H + +#include +#include +#include +#include +#include + +#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 diff --git a/src/chatmodel.cpp b/src/chatmodel.cpp new file mode 100644 index 0000000..05f1da7 --- /dev/null +++ b/src/chatmodel.cpp @@ -0,0 +1,252 @@ +#include "chatmodel.h" + +#include + +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 ChatModel::roleNames() const +{ + QHash 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() << 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() << 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(); +} diff --git a/src/chatmodel.h b/src/chatmodel.h new file mode 100644 index 0000000..c0bff0f --- /dev/null +++ b/src/chatmodel.h @@ -0,0 +1,93 @@ +#ifndef CHATMODEL_H +#define CHATMODEL_H + +#include +#include +#include +#include +#include +#include + +#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 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 m_msgs; + QString m_sessionId; + QString m_title; + bool m_busy; + bool m_finalizedThisRun; +}; + +#endif // CHATMODEL_H diff --git a/src/cookiejar.cpp b/src/cookiejar.cpp new file mode 100644 index 0000000..bc524ac --- /dev/null +++ b/src/cookiejar.cpp @@ -0,0 +1,72 @@ +#include "cookiejar.h" + +#include +#include +#include +#include + +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 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 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()); + if (!m_path.isEmpty()) + QFile::remove(m_path); +} + +bool CookieJar::setCookiesFromUrl(const QList &cookieList, const QUrl &url) +{ + const bool changed = QNetworkCookieJar::setCookiesFromUrl(cookieList, url); + if (changed) + save(); + return changed; +} diff --git a/src/cookiejar.h b/src/cookiejar.h new file mode 100644 index 0000000..2db6a63 --- /dev/null +++ b/src/cookiejar.h @@ -0,0 +1,31 @@ +#ifndef COOKIEJAR_H +#define COOKIEJAR_H + +#include +#include +#include + +// 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 &cookieList, const QUrl &url) override; + +private: + QString m_path; +}; + +#endif // COOKIEJAR_H diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..8ab8347 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,48 @@ +#include +#include +#include +#include +#include + +#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(); +} diff --git a/src/player.cpp b/src/player.cpp new file mode 100644 index 0000000..c2c0092 --- /dev/null +++ b/src/player.cpp @@ -0,0 +1,39 @@ +#include "player.h" + +#include +#include + +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(); +} diff --git a/src/player.h b/src/player.h new file mode 100644 index 0000000..54b313c --- /dev/null +++ b/src/player.h @@ -0,0 +1,31 @@ +#ifndef PLAYER_H +#define PLAYER_H + +#include +#include +#include + +// 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 diff --git a/src/recorder.cpp b/src/recorder.cpp new file mode 100644 index 0000000..31c7326 --- /dev/null +++ b/src/recorder.cpp @@ -0,0 +1,143 @@ +#include "recorder.h" + +#include +#include +#include +#include +#include +#include +#include + +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(&QAudioRecorder::stateChanged), + this, [this](QMediaRecorder::State) { onStateChanged(); }); + connect(m_rec, + static_cast(&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(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()); +} diff --git a/src/recorder.h b/src/recorder.h new file mode 100644 index 0000000..cd93e07 --- /dev/null +++ b/src/recorder.h @@ -0,0 +1,51 @@ +#ifndef RECORDER_H +#define RECORDER_H + +#include +#include +#include +#include + +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 /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 diff --git a/src/settings.cpp b/src/settings.cpp new file mode 100644 index 0000000..dc594d6 --- /dev/null +++ b/src/settings.cpp @@ -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(); +} diff --git a/src/settings.h b/src/settings.h new file mode 100644 index 0000000..3d8ea18 --- /dev/null +++ b/src/settings.h @@ -0,0 +1,54 @@ +#ifndef SETTINGS_H +#define SETTINGS_H + +#include +#include +#include + +// 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 diff --git a/src/sseparser.cpp b/src/sseparser.cpp new file mode 100644 index 0000000..0a1d3fc --- /dev/null +++ b/src/sseparser.cpp @@ -0,0 +1,59 @@ +#include "sseparser.h" + +SseParser::SseParser() +{ +} + +QList 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 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 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; +} diff --git a/src/sseparser.h b/src/sseparser.h new file mode 100644 index 0000000..67c1208 --- /dev/null +++ b/src/sseparser.h @@ -0,0 +1,40 @@ +#ifndef SSEPARSER_H +#define SSEPARSER_H + +#include +#include +#include + +// Parser incrementale di Server-Sent Events (text/event-stream). +// +// Il WebUI di Hermes emette eventi nella forma: +// id: : +// event: token +// data: {"text": "..."} +// +// 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 feed(const QByteArray &chunk); + + void reset(); + QByteArray pending() const; + +private: + QByteArray m_buffer; +}; + +#endif // SSEPARSER_H diff --git a/tests/core_test.cpp b/tests/core_test.cpp new file mode 100644 index 0000000..48ea6c7 --- /dev/null +++ b/tests/core_test.cpp @@ -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 +#include +#include +#include + +#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 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 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 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(); +} diff --git a/tests/core_test.pro b/tests/core_test.pro new file mode 100644 index 0000000..9d02692 --- /dev/null +++ b/tests/core_test.pro @@ -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