feat: v0.1.0 — client Sailfish per hermes-webui con voce
- ApiClient HTTP/SSE: login, sessioni, chat/start, stream SSE, transcribe, tts - ChatModel: streaming live + risincronizzazione dallo stato del server - CookieJar persistente (0600, mai la password); Settings - Recorder OGG/Opus (fallback WAV/PCM, retry); Player TTS (QMediaPlayer) - UI Silica: splash, login, sessioni, chat (dettatura + read-aloud), settings, cover - Sailjail Internet;Audio;Microphone; icone; spec RPM; docs PROTOCOL/BUILD - tests/core_test: unit parser SSE + integrazione live (probe :8899)
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
*.o
|
||||||
|
moc_*
|
||||||
|
qmake.stash
|
||||||
|
Makefile*
|
||||||
|
*.qm
|
||||||
|
build/
|
||||||
|
core-test
|
||||||
|
*.tar.gz
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# harbour-hermes
|
||||||
|
|
||||||
|
Native **Sailfish OS** client for the **Hermes Web UI** (`hermes-webui`):
|
||||||
|
chat with your Hermes agent from the phone — and use it with your **voice**.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Sign in to your Hermes Web UI (session cookie persisted; password never stored)
|
||||||
|
- Sessions list, new session, open any session
|
||||||
|
- Chat with **live streaming** replies (SSE token deltas)
|
||||||
|
- **Voice dictation**: tap Mic, speak, tap Stop → server-side Whisper
|
||||||
|
transcription lands in the composer
|
||||||
|
- **Read aloud**: server-side TTS (edge engine, Italian voices) played through
|
||||||
|
QMediaPlayer; optional auto-read of every completed reply
|
||||||
|
- Cancel a running turn, resync from server state, cover page
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Sailfish OS 4.4+ (developed against Sailfish 5.1)
|
||||||
|
- A running `hermes-webui` instance reachable over HTTPS (default:
|
||||||
|
`https://hermes.hackatoniclife.com`)
|
||||||
|
- Sailjail permissions `Internet;Audio;Microphone` (declared in the .desktop)
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
See [docs/BUILD.md](docs/BUILD.md) (Sailfish SDK, `sfdk build` / `sfdk deploy`)
|
||||||
|
and [docs/PROTOCOL.md](docs/PROTOCOL.md) for the HTTP/SSE protocol the app
|
||||||
|
speaks (validated live against a real instance).
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Version 0.1.0 — protocol and C++ core validated against a real `hermes-webui`
|
||||||
|
(login, sessions, streaming chat, TTS, STT); on-device testing (audio paths,
|
||||||
|
Sailjail prompts, keyboard handling) still pending.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Build: compilazione, test e deploy
|
||||||
|
|
||||||
|
## 1. Prerequisiti SDK
|
||||||
|
|
||||||
|
- Sailfish SDK (testato su 5.1.0.11) con target del dispositivo aarch64:
|
||||||
|
`SailfishOS-5.0.0.62-aarch64` (Jolla Phone 2026) o equivalente.
|
||||||
|
- Nel target SDK serve **QtMultimedia** (registrazione/riproduzione):
|
||||||
|
se `pkgconfig(Qt5Multimedia)` non è trovato in fase di build, installa il
|
||||||
|
pacchetto devel nel target:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sfdk tools exec SailfishOS-5.0.0.62-aarch64 zypper install -y qt5-qtmultimedia-devel
|
||||||
|
```
|
||||||
|
|
||||||
|
(oppure via Qt Creator: Tools → Sailfish OS → Manage targets → Packages,
|
||||||
|
oppure `sfdk target install` con un RPM locale.)
|
||||||
|
|
||||||
|
## 2. Build e deploy dell'app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd harbour-hermes
|
||||||
|
sfdk build # genera RPMS/
|
||||||
|
sfdk deploy # installa sul device collegato
|
||||||
|
```
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Il target è Qt 5.6: il codice del core è scritto per compilare anche lì
|
||||||
|
(niente API Qt > 5.6; per il TTS si usa `audio/mpeg` di edge-tts).
|
||||||
|
- Il profilo SailJail è nel `rpm/harbour-hermes.desktop`
|
||||||
|
(`Internet;Audio;Microphone`) e deve restare allineato a
|
||||||
|
`setOrganizationName("harbour")` / `setApplicationName("hermes")` in `main.cpp`.
|
||||||
|
- Al primo uso del microfono Sailfish chiede il consenso al permesso
|
||||||
|
**Microphone**.
|
||||||
|
|
||||||
|
## 3. Test del core in locale (senza SDK)
|
||||||
|
|
||||||
|
Il core (ApiClient, ChatModel, parser SSE, Recorder, Player) si compila anche
|
||||||
|
su desktop con Qt 5.15 per i test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Qt desktop (qui: Qt 5.15.2 nel kit locali)
|
||||||
|
export PATH=<Qt5.15>/gcc_64/bin:$PATH
|
||||||
|
mkdir -p /tmp/hh-test && cd /tmp/hh-test
|
||||||
|
qmake /percorso/harbour-hermes/tests/core_test.pro
|
||||||
|
make -j4
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Unit** (parser SSE): `./core-test`
|
||||||
|
- **Integrazione live**: `./core-test --live`
|
||||||
|
richiede un'istanza di prova di hermes-webui su `http://127.0.0.1:8899`
|
||||||
|
con password in `/tmp/hermes-probe/pw` e l'audio di prova in
|
||||||
|
`/tmp/hermes-probe/out/prova.ogg` (vedi note in `tests/core_test.cpp`).
|
||||||
|
Il flusso coperto: login → nuova sessione → turno con streaming → TTS →
|
||||||
|
trascrizione.
|
||||||
|
|
||||||
|
Nota desktop: se manca `libGL.so` di sviluppo, creare un symlink
|
||||||
|
`libGL.so -> /usr/lib/x86_64-linux-gnu/libGL.so.1` e assicurarsi che
|
||||||
|
`core_test.pro` punti lì (`GLSTUB`), oppure lanciare con `LD_LIBRARY_PATH`
|
||||||
|
puntato alla lib dir di Qt.
|
||||||
|
|
||||||
|
## 4. Tarball per la build da Qt Creator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/kaneda/workspace
|
||||||
|
tar czf harbour-hermes-0.1.0.tar.gz \
|
||||||
|
--transform 's,^harbour-hermes,harbour-hermes-0.1.0,' \
|
||||||
|
--exclude='.git' harbour-hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
Lo spec si aspetta la directory `harbour-hermes-0.1.0/` (pattern degli altri
|
||||||
|
progetti: cercato in modo robusto anche per i sorgenti live di sfdk).
|
||||||
|
|
||||||
|
## 5. Dipendenze lato server (WebUI)
|
||||||
|
|
||||||
|
- `/api/transcribe` richiede un provider STT attivo sul server
|
||||||
|
(qui: faster-whisper locale — verificato con `/api/transcribe/capability`).
|
||||||
|
- `/api/tts` con engine `edge` non richiede chiavi API.
|
||||||
|
- Le voci italiane di edge sono state aggiunte all'allowlist del server
|
||||||
|
(patch a `hermes-webui/api/routes.py` + test
|
||||||
|
`tests/test_italian_voices_tts_allowlist.py`): per attivarle **riavviare
|
||||||
|
il servizio WebUI** (`sudo systemctl restart hermes-webui`).
|
||||||
|
- Per una dettatura italiana corretta impostare in `~/.hermes/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
stt:
|
||||||
|
provider: local
|
||||||
|
local:
|
||||||
|
model: base
|
||||||
|
language: it
|
||||||
|
```
|
||||||
|
|
||||||
|
(senza, whisper può tradurre/maltrascrivere l'italiano). Richiede il
|
||||||
|
riavvio del WebUI.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Protocollo `hermes-webui` ↔ client
|
||||||
|
|
||||||
|
Documentazione del protocollo HTTP/SSE usato da harbour-hermes.
|
||||||
|
**Validato dal vivo il 12/09/2026** contro un'istanza reale di
|
||||||
|
`nesquena/hermes-webui` (v0.52.x, `server.py`, ThreadingHTTPServer).
|
||||||
|
|
||||||
|
> Il protocollo non è documentato ufficialmente: è replicato dal frontend web
|
||||||
|
> del progetto (`static/*.js` è la reference implementation) e pinnato alla
|
||||||
|
> versione attuale. Un aggiornamento del WebUI può richiedere aggiornamenti
|
||||||
|
> al client.
|
||||||
|
|
||||||
|
Server di riferimento: in ascolto su `127.0.0.1:8787` dietro reverse proxy
|
||||||
|
(`https://hermes.hackatoniclife.com`). TLS obbligatorio in produzione.
|
||||||
|
|
||||||
|
## 1. Autenticazione
|
||||||
|
|
||||||
|
Cookie di sessione **`hermes_session`** = `token.firma_hmac` (TTL 30 giorni
|
||||||
|
di default; `HERMES_WEBUI_SESSION_TTL` per cambiarlo). Auth attiva solo se il
|
||||||
|
server ha una password impostata.
|
||||||
|
|
||||||
|
| Endpoint | Metodo | Body / Risposta |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/auth/login` | POST | `{"password": "..."}` → `200 {"ok": true}` + `Set-Cookie` |
|
||||||
|
| `/api/auth/status` | GET | `{"auth_enabled": bool, "logged_in": bool, "password_auth_enabled": bool, ...}` |
|
||||||
|
| `/api/auth/logout` | POST | invalida il cookie |
|
||||||
|
|
||||||
|
- Errori: `401 {"error": "Authentication required"}`; rate limit sul login.
|
||||||
|
- Se `auth_enabled` è `false`, tutte le API rispondono senza cookie.
|
||||||
|
- Il client persiste SOLO il cookie (file `0600`); la password non è mai salvata.
|
||||||
|
|
||||||
|
## 2. Sessioni
|
||||||
|
|
||||||
|
`GET /api/sessions` → lista per la sidebar:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"session_id": "171ff18f9313",
|
||||||
|
"title": "Webui Session",
|
||||||
|
"workspace": "/home/kaneda/.hermes/profiles/carlo/home/workspace",
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
"model_provider": "deepseek",
|
||||||
|
"message_count": 2,
|
||||||
|
"created_at": 1785701975.8797414,
|
||||||
|
"updated_at": 1785701985.0872252,
|
||||||
|
"pinned": false, "archived": false,
|
||||||
|
"profile": "default",
|
||||||
|
"source_tag": "webui", "session_source": "webui",
|
||||||
|
"is_cli_session": false, "is_streaming": false,
|
||||||
|
"active_stream_id": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"active_profile": "default",
|
||||||
|
"server_time": 1789188804.1041577
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/session?session_id=ID` → `{"session": { ... }}` con in più:
|
||||||
|
|
||||||
|
- `messages`: array di `{"role": "user"|"assistant", "content": "...",
|
||||||
|
"timestamp": 1789188869.6, ...}` (i messaggi assistente possono includere
|
||||||
|
`reasoning`, `finish_reason`, `id`, `_turnDuration`, `_turnTps`,
|
||||||
|
`_firstTokenMs`; i messaggi "solo tool" hanno `content` vuoto)
|
||||||
|
- `tool_calls`, `input_tokens`, `output_tokens`, `estimated_cost`,
|
||||||
|
`read_only`, `active_stream_id`, `enabled_toolsets`, ...
|
||||||
|
|
||||||
|
`POST /api/session/new` con body `{"workspace": "/path"}` (opzionale; il
|
||||||
|
workspace deve essere sotto la home dell'utente, nella lista salvata o sotto
|
||||||
|
il default) → `{"session": {...}}` (sessione vuota).
|
||||||
|
|
||||||
|
## 3. Chat con streaming
|
||||||
|
|
||||||
|
`POST /api/chat/start`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "f517dae7a2da",
|
||||||
|
"message": "Rispondi solo con la parola pong",
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
"model_provider": "deepseek",
|
||||||
|
"workspace": "/home/kaneda/.hermes/profiles/carlo/home/workspace",
|
||||||
|
"profile": "default"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Risposta:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stream_id": "a1e0ec82169b4673a13bf9bd466afd3f",
|
||||||
|
"session_id": "f517dae7a2da",
|
||||||
|
"pending_started_at": 1789188858.196835,
|
||||||
|
"turn_id": "20260912T045418Z-ba85fcc8cebe",
|
||||||
|
"title": "Rispondi solo con la parola pong. Non usare strumenti.",
|
||||||
|
"effective_model_provider": "deepseek"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errori noti: `404` sessione inesistente; `400` "Missing required field(s)";
|
||||||
|
conflitto se la sessione ha già uno stream attivo ("session already has an
|
||||||
|
active stream").
|
||||||
|
|
||||||
|
`GET /api/chat/stream?stream_id=ID` → **SSE** `text/event-stream`,
|
||||||
|
heartbeat ogni 5 s (`: heartbeat`), ogni evento ha `id: <stream_id>:<seq>`:
|
||||||
|
|
||||||
|
| Evento | Payload | Uso nel client |
|
||||||
|
|---|---|---|
|
||||||
|
| `context_status` | `{session_id, prefill}` | ignorato |
|
||||||
|
| `token` | `{"text": "..."}` | **delta di testo** della risposta |
|
||||||
|
| `metering` | `{tps, ttft_ms, usage, ...}` | ignorato (v1) |
|
||||||
|
| `done` | `{"session": {...con messages finali}, "usage": {...}}` | **risincronizza** il modello e chiude il turno |
|
||||||
|
| `title_status` | `{session_id, status, title}` | ignorato |
|
||||||
|
| `title` | `{session_id, "title": "..."}` | aggiorna il titolo |
|
||||||
|
| `stream_end` | `{session_id}` | fine stream |
|
||||||
|
|
||||||
|
Altri eventi possono esistere (attività tool, approval/clarify) e vanno
|
||||||
|
**ignorati** senza errore (il parser li scarta).
|
||||||
|
|
||||||
|
Ripresa: `GET /api/chat/stream/status?stream_id=ID` →
|
||||||
|
`{"active": bool, "stream_id", "replay_available"}`. Se `GET /api/session`
|
||||||
|
mostra `active_stream_id` non nullo, riagganciare lo stream con lo stesso URL.
|
||||||
|
|
||||||
|
Annullamento: `GET /api/chat/cancel?stream_id=ID` →
|
||||||
|
`{"ok": true, "cancelled": bool, "stream_id"}`.
|
||||||
|
|
||||||
|
## 4. Voce
|
||||||
|
|
||||||
|
### Trascrizione (voce → testo)
|
||||||
|
|
||||||
|
`POST /api/transcribe` — `multipart/form-data`, campo **`file`**
|
||||||
|
(OGG/Opus, WebM, M4A, WAV...; il server converte con ffmpeg se serve):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok": true, "transcript": "Questa è una prova di trascrizione vocale..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Provider STT del server (qui: `local` = faster-whisper). Latenza osservata:
|
||||||
|
~10 s per 6 s di audio (modello `base`, CPU).
|
||||||
|
- ⚠️ **Lingua**: con `stt.local.language: ''` whisper può tradurre l'italiano
|
||||||
|
in inglese; impostare `stt.local.language: it` in `~/.hermes/config.yaml`
|
||||||
|
(richiede riavvio del WebUI).
|
||||||
|
- `GET /api/transcribe/capability` → `{"ok": true, "available": true, "provider": "local"}`.
|
||||||
|
|
||||||
|
### Sintesi (testo → voce)
|
||||||
|
|
||||||
|
`POST /api/tts` — JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"text": "Ciao", "voice": "it-IT-ElsaNeural", "engine": "edge",
|
||||||
|
"rate": "+0%", "pitch": "+0Hz"}
|
||||||
|
```
|
||||||
|
|
||||||
|
→ `200` `audio/mpeg` (mp3) oppure `4xx` `{"error": "..."}`.
|
||||||
|
|
||||||
|
- Limiti server: **5000 caratteri**; rate limit **2 s per client**;
|
||||||
|
**allowlist voci hardcoded** nella funzione `_handle_tts` di
|
||||||
|
`api/routes.py`. Voci ammesse oggi: zh-CN (5), en-US (2), fr-CA (4),
|
||||||
|
fr-FR (3), id-ID (1) + **it-IT (4: ElsaNeural, DiegoNeural, IsabellaNeural,
|
||||||
|
GiuseppeMultilingualNeural)** — le italiane sono state aggiunte da noi
|
||||||
|
(patch + test): il server va **riavviato** per attivarle.
|
||||||
|
- Engine: `edge` (default, gratis), `elevenlabs`, `openai` (richiedono chiavi
|
||||||
|
configurate sul server). `browser` è solo client-side, non usabile qui.
|
||||||
|
|
||||||
|
## 5. Note operative
|
||||||
|
|
||||||
|
- Tutti gli errori API sono JSON `{"error": "..."}`.
|
||||||
|
- Il server non espone CORS: il client nativo non serve preflight.
|
||||||
|
- Endpoint utili non usati dalla v1: `/api/clarify/*` e `/api/approval/*`
|
||||||
|
(richieste interattive dell'agente: vanno gestite per non lasciare un run
|
||||||
|
in attesa), `/api/upload` (allegati), `/api/settings`, `/api/models`,
|
||||||
|
`/api/sessions/events` (SSE globale di invalidazione lista).
|
||||||
|
- L'istanza di prova usata per la validazione era isolata (porta 8899,
|
||||||
|
`HERMES_WEBUI_STATE_DIR` dedicata, password temporanea) e non ha toccato
|
||||||
|
né il server live né i dati utente.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
TEMPLATE = app
|
||||||
|
TARGET = harbour-hermes
|
||||||
|
|
||||||
|
CONFIG += sailfishapp
|
||||||
|
CONFIG += c++11
|
||||||
|
|
||||||
|
QT += network
|
||||||
|
QT += multimedia
|
||||||
|
|
||||||
|
SOURCES += \
|
||||||
|
src/main.cpp \
|
||||||
|
src/settings.cpp \
|
||||||
|
src/cookiejar.cpp \
|
||||||
|
src/sseparser.cpp \
|
||||||
|
src/apiclient.cpp \
|
||||||
|
src/chatmodel.cpp \
|
||||||
|
src/recorder.cpp \
|
||||||
|
src/player.cpp
|
||||||
|
|
||||||
|
HEADERS += \
|
||||||
|
src/settings.h \
|
||||||
|
src/cookiejar.h \
|
||||||
|
src/sseparser.h \
|
||||||
|
src/apiclient.h \
|
||||||
|
src/chatmodel.h \
|
||||||
|
src/recorder.h \
|
||||||
|
src/player.h
|
||||||
|
|
||||||
|
OTHER_FILES += \
|
||||||
|
qml/harbour-hermes.qml \
|
||||||
|
qml/pages/*.qml \
|
||||||
|
qml/cover/*.qml \
|
||||||
|
rpm/harbour-hermes.spec \
|
||||||
|
rpm/harbour-hermes.desktop
|
||||||
|
|
||||||
|
DISTFILES += $$OTHER_FILES
|
||||||
|
|
||||||
|
# Icone installate via make install (struttura standard hicolor)
|
||||||
|
icons86.path = /usr/share/icons/hicolor/86x86/apps
|
||||||
|
icons86.files = $$PWD/icons/86x86/harbour-hermes.png
|
||||||
|
icons108.path = /usr/share/icons/hicolor/108x108/apps
|
||||||
|
icons108.files = $$PWD/icons/108x108/harbour-hermes.png
|
||||||
|
icons128.path = /usr/share/icons/hicolor/128x128/apps
|
||||||
|
icons128.files = $$PWD/icons/128x128/harbour-hermes.png
|
||||||
|
icons172.path = /usr/share/icons/hicolor/172x172/apps
|
||||||
|
icons172.files = $$PWD/icons/172x172/harbour-hermes.png
|
||||||
|
INSTALLS += icons86 icons108 icons128 icons172
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 921 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 739 B |
@@ -0,0 +1,30 @@
|
|||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
CoverBackground {
|
||||||
|
Label {
|
||||||
|
id: title
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: Theme.paddingLarge
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: "Hermes"
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
color: Theme.highlightColor
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: chat.count + qsTr(" messages")
|
||||||
|
color: Theme.primaryColor
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: Theme.paddingLarge
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
visible: chat.busy || recorder.recording
|
||||||
|
font.pixelSize: Theme.fontSizeExtraSmall
|
||||||
|
color: Theme.secondaryColor
|
||||||
|
text: recorder.recording ? qsTr("Recording…") : qsTr("Writing…")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
import "pages"
|
||||||
|
import "cover"
|
||||||
|
|
||||||
|
ApplicationWindow {
|
||||||
|
id: app
|
||||||
|
|
||||||
|
initialPage: Component { SplashPage {} }
|
||||||
|
cover: Component { CoverPage {} }
|
||||||
|
|
||||||
|
allowedOrientations: Orientation.Portrait
|
||||||
|
_defaultPageOrientations: Orientation.Portrait
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
// Conversazione con Hermes: bolle, streaming live, composer con dettatura.
|
||||||
|
Page {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
property string sessionId
|
||||||
|
property string noticeText: ""
|
||||||
|
// Altezza tastiera: alza il composer quando la tastiera e' aperta.
|
||||||
|
property real kbHeight: Qt.inputMethod.visible
|
||||||
|
? Math.max(0, page.height - Qt.inputMethod.keyboardRectangle.y)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
allowedOrientations: Orientation.Portrait
|
||||||
|
|
||||||
|
SilicaListView {
|
||||||
|
id: list
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: composer.top
|
||||||
|
model: chat
|
||||||
|
spacing: Theme.paddingSmall
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
width: list.width
|
||||||
|
height: headerCol.height
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: headerCol
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
PageHeader {
|
||||||
|
title: chat.title.length > 0 ? chat.title : qsTr("Chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
x: Theme.horizontalPageMargin
|
||||||
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||||
|
font.pixelSize: Theme.fontSizeExtraSmall
|
||||||
|
color: Theme.secondaryHighlightColor
|
||||||
|
visible: chat.busy || player.playing
|
||||||
|
text: chat.busy ? qsTr("Hermes is writing…")
|
||||||
|
: qsTr("Speaking…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PullDownMenu {
|
||||||
|
MenuItem {
|
||||||
|
text: qsTr("Read last reply aloud")
|
||||||
|
onClicked: page.readLast()
|
||||||
|
}
|
||||||
|
MenuItem {
|
||||||
|
text: appSettings.ttsAutoRead ? qsTr("Auto-read: on") : qsTr("Auto-read: off")
|
||||||
|
onClicked: appSettings.ttsAutoRead = !appSettings.ttsAutoRead
|
||||||
|
}
|
||||||
|
MenuItem {
|
||||||
|
text: qsTr("Stop playback")
|
||||||
|
visible: player.playing
|
||||||
|
onClicked: player.stop()
|
||||||
|
}
|
||||||
|
MenuItem {
|
||||||
|
text: qsTr("Cancel run")
|
||||||
|
visible: chat.busy
|
||||||
|
onClicked: chat.cancel()
|
||||||
|
}
|
||||||
|
MenuItem {
|
||||||
|
text: qsTr("Resync")
|
||||||
|
onClicked: chat.resync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: Item {
|
||||||
|
id: row
|
||||||
|
width: list.width
|
||||||
|
height: bubble.height + Theme.paddingMedium
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: bubble
|
||||||
|
property real maxW: list.width - 2 * Theme.horizontalPageMargin
|
||||||
|
width: Math.min(lbl.implicitWidth + 2 * Theme.paddingMedium, maxW)
|
||||||
|
height: lbl.height + 2 * Theme.paddingSmall
|
||||||
|
radius: Theme.paddingSmall
|
||||||
|
x: model.role === "user"
|
||||||
|
? list.width - width - Theme.horizontalPageMargin
|
||||||
|
: Theme.horizontalPageMargin
|
||||||
|
color: model.role === "user"
|
||||||
|
? Theme.rgba(Theme.highlightBackgroundColor, 0.35)
|
||||||
|
: Theme.rgba(Theme.secondaryHighlightColor, 0.18)
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: lbl
|
||||||
|
x: Theme.paddingMedium
|
||||||
|
y: Theme.paddingSmall
|
||||||
|
width: Math.min(implicitWidth, bubble.maxW - 2 * Theme.paddingMedium)
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: Theme.primaryColor
|
||||||
|
text: model.content + (model.streaming ? " ▍" : "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: composer
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: page.kbHeight
|
||||||
|
height: compCol.height + Theme.paddingMedium
|
||||||
|
color: Theme.highlightDimmerColor
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: compCol
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.paddingSmall / 2
|
||||||
|
|
||||||
|
Label {
|
||||||
|
x: Theme.horizontalPageMargin
|
||||||
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||||
|
font.pixelSize: Theme.fontSizeExtraSmall
|
||||||
|
color: recorder.recording ? Theme.highlightColor
|
||||||
|
: Theme.secondaryHighlightColor
|
||||||
|
visible: recorder.recording || api.transcribing || page.noticeText.length > 0
|
||||||
|
truncationMode: TruncationMode.Fade
|
||||||
|
text: recorder.recording
|
||||||
|
? qsTr("Recording… %1 — tap Stop to transcribe").arg(page.formatMs(recorder.durationMs))
|
||||||
|
: api.transcribing
|
||||||
|
? qsTr("Transcribing…")
|
||||||
|
: page.noticeText
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
x: Theme.horizontalPageMargin
|
||||||
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||||
|
spacing: Theme.paddingSmall
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: micBtn
|
||||||
|
text: recorder.recording ? qsTr("Stop") : qsTr("Mic")
|
||||||
|
highlighted: recorder.recording
|
||||||
|
enabled: !api.transcribing && !chat.busy || recorder.recording
|
||||||
|
onClicked: {
|
||||||
|
if (recorder.recording)
|
||||||
|
recorder.stop()
|
||||||
|
else {
|
||||||
|
player.stop()
|
||||||
|
recorder.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: input
|
||||||
|
width: parent.width - micBtn.width - sendBtn.width - 2 * parent.spacing
|
||||||
|
placeholderText: qsTr("Message…")
|
||||||
|
onAccepted: page.doSend()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: sendBtn
|
||||||
|
text: qsTr("Send")
|
||||||
|
enabled: input.text.length > 0 && !chat.busy
|
||||||
|
onClicked: page.doSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: scrollDebounce
|
||||||
|
interval: 150
|
||||||
|
onTriggered: list.positionViewAtEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: noticeClear
|
||||||
|
interval: 5000
|
||||||
|
onTriggered: page.noticeText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: chat
|
||||||
|
onRowCountChanged: scrollDebounce.restart()
|
||||||
|
onStreamTick: scrollDebounce.restart()
|
||||||
|
onAssistantFinished: {
|
||||||
|
if (appSettings.ttsAutoRead)
|
||||||
|
page.readLast()
|
||||||
|
}
|
||||||
|
onNotice: page.showNotice(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: api
|
||||||
|
onTranscriptionReady: {
|
||||||
|
input.text = text
|
||||||
|
input.focus = true
|
||||||
|
}
|
||||||
|
onTranscriptionFailed: page.showNotice(error)
|
||||||
|
onTtsFailed: page.showNotice(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (sessionId.length > 0)
|
||||||
|
chat.openSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSend() {
|
||||||
|
var t = input.text
|
||||||
|
if (t.trim().length === 0)
|
||||||
|
return
|
||||||
|
input.text = ""
|
||||||
|
chat.send(t)
|
||||||
|
scrollDebounce.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLast() {
|
||||||
|
var t = chat.lastAssistantText()
|
||||||
|
if (t.length === 0) {
|
||||||
|
page.showNotice(qsTr("No reply to read yet"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.speak(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotice(text) {
|
||||||
|
page.noticeText = text
|
||||||
|
noticeClear.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMs(ms) {
|
||||||
|
var s = Math.floor(ms / 1000)
|
||||||
|
var m = Math.floor(s / 60)
|
||||||
|
s = s % 60
|
||||||
|
return m + ":" + (s < 10 ? "0" + s : s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
// Login alla Web UI di Hermes: URL del server + password.
|
||||||
|
// La password non viene salvata: dopo il login resta il cookie di sessione.
|
||||||
|
Page {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
spacing: Theme.paddingLarge
|
||||||
|
|
||||||
|
PageHeader { title: qsTr("Sign in") }
|
||||||
|
|
||||||
|
Label {
|
||||||
|
x: Theme.horizontalPageMargin
|
||||||
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.secondaryColor
|
||||||
|
text: qsTr("Use the same password you use in the Hermes Web UI in your browser.")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: urlField
|
||||||
|
width: parent.width
|
||||||
|
label: qsTr("Server")
|
||||||
|
placeholderText: "https://hermes.example.com"
|
||||||
|
inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: passField
|
||||||
|
width: parent.width
|
||||||
|
label: qsTr("Password")
|
||||||
|
echoMode: TextInput.Password
|
||||||
|
inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText
|
||||||
|
onAccepted: page.doLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: api.busy ? qsTr("Signing in…") : qsTr("Sign in")
|
||||||
|
enabled: !api.busy && passField.text.length > 0
|
||||||
|
onClicked: page.doLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
x: Theme.horizontalPageMargin
|
||||||
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
visible: api.lastError.length > 0
|
||||||
|
color: Theme.errorColor
|
||||||
|
text: api.lastError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: api
|
||||||
|
onLoggedInChanged: {
|
||||||
|
if (api.loggedIn)
|
||||||
|
pageStack.replace(Qt.resolvedUrl("SessionsPage.qml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: urlField.text = appSettings.baseUrl
|
||||||
|
|
||||||
|
function doLogin() {
|
||||||
|
if (passField.text.length === 0)
|
||||||
|
return
|
||||||
|
api.clearError()
|
||||||
|
appSettings.baseUrl = urlField.text
|
||||||
|
api.login(passField.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
// Lista delle sessioni della Web UI di Hermes (sidebar).
|
||||||
|
Page {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
// true solo quando e' questa pagina a chiedere una nuova sessione:
|
||||||
|
// serve a distinguere la creazione dall'apertura di una sessione esistente.
|
||||||
|
property bool expectNewSession: false
|
||||||
|
|
||||||
|
SilicaListView {
|
||||||
|
id: list
|
||||||
|
anchors.fill: parent
|
||||||
|
model: api.sessions
|
||||||
|
spacing: Theme.paddingSmall
|
||||||
|
|
||||||
|
header: PageHeader { title: qsTr("Sessions") }
|
||||||
|
|
||||||
|
PullDownMenu {
|
||||||
|
MenuItem {
|
||||||
|
text: qsTr("New session")
|
||||||
|
onClicked: page.createSession()
|
||||||
|
}
|
||||||
|
MenuItem {
|
||||||
|
text: qsTr("Refresh")
|
||||||
|
onClicked: api.refreshSessions()
|
||||||
|
}
|
||||||
|
MenuItem {
|
||||||
|
text: qsTr("Settings")
|
||||||
|
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
|
||||||
|
}
|
||||||
|
MenuItem {
|
||||||
|
text: qsTr("Sign out")
|
||||||
|
onClicked: api.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: ListItem {
|
||||||
|
id: item
|
||||||
|
contentHeight: col.height + Theme.paddingMedium
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: col
|
||||||
|
x: Theme.horizontalPageMargin
|
||||||
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Theme.paddingSmall / 2
|
||||||
|
|
||||||
|
Label {
|
||||||
|
width: parent.width
|
||||||
|
truncationMode: TruncationMode.Fade
|
||||||
|
color: item.highlighted ? Theme.highlightColor : Theme.primaryColor
|
||||||
|
text: ("" + model.title).length > 0 ? model.title : model.session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
width: parent.width
|
||||||
|
font.pixelSize: Theme.fontSizeExtraSmall
|
||||||
|
color: Theme.secondaryColor
|
||||||
|
text: page.describe(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: page.openSession(model.session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewPlaceholder {
|
||||||
|
enabled: api.sessions.length === 0 && !api.busy
|
||||||
|
text: qsTr("No sessions")
|
||||||
|
hintText: qsTr("Pull down to create a new session")
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalScrollDecorator {}
|
||||||
|
}
|
||||||
|
|
||||||
|
BusyIndicator {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
size: BusyIndicatorSize.Large
|
||||||
|
running: api.busy
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: api
|
||||||
|
onLoggedInChanged: {
|
||||||
|
if (!api.loggedIn)
|
||||||
|
pageStack.replace(Qt.resolvedUrl("LoginPage.qml"))
|
||||||
|
}
|
||||||
|
onSessionOpened: {
|
||||||
|
if (page.expectNewSession) {
|
||||||
|
page.expectNewSession = false
|
||||||
|
pageStack.push(Qt.resolvedUrl("ChatPage.qml"),
|
||||||
|
{ sessionId: api.session.session_id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: api.refreshSessions()
|
||||||
|
|
||||||
|
function openSession(id) {
|
||||||
|
expectNewSession = false
|
||||||
|
pageStack.push(Qt.resolvedUrl("ChatPage.qml"), { sessionId: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSession() {
|
||||||
|
expectNewSession = true
|
||||||
|
chat.newSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(m) {
|
||||||
|
var n = m.message_count ? m.message_count : 0
|
||||||
|
var d = new Date((m.updated_at ? m.updated_at : 0) * 1000)
|
||||||
|
return n + qsTr(" messages") + " · " + Qt.formatDateTime(d, "dd/MM HH:mm")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
Page {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
property var voiceNames: [
|
||||||
|
"it-IT-ElsaNeural",
|
||||||
|
"it-IT-DiegoNeural",
|
||||||
|
"it-IT-IsabellaNeural",
|
||||||
|
"it-IT-GiuseppeMultilingualNeural",
|
||||||
|
"en-US-AriaNeural"
|
||||||
|
]
|
||||||
|
property var engineNames: ["edge", "openai", "elevenlabs"]
|
||||||
|
|
||||||
|
SilicaFlickable {
|
||||||
|
anchors.fill: parent
|
||||||
|
contentHeight: col.height + Theme.paddingLarge
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: col
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.paddingMedium
|
||||||
|
|
||||||
|
PageHeader { title: qsTr("Settings") }
|
||||||
|
|
||||||
|
SectionHeader { text: qsTr("Server") }
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: urlField
|
||||||
|
width: parent.width
|
||||||
|
label: qsTr("Web UI address")
|
||||||
|
placeholderText: "https://hermes.example.com"
|
||||||
|
inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase
|
||||||
|
onTextChanged: {
|
||||||
|
if (text !== appSettings.baseUrl)
|
||||||
|
appSettings.baseUrl = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionHeader { text: qsTr("Voice") }
|
||||||
|
|
||||||
|
ComboBox {
|
||||||
|
label: qsTr("Read-aloud voice")
|
||||||
|
currentIndex: Math.max(0, page.voiceNames.indexOf(appSettings.ttsVoice))
|
||||||
|
menu: ContextMenu {
|
||||||
|
Repeater {
|
||||||
|
model: page.voiceNames
|
||||||
|
MenuItem {
|
||||||
|
text: modelData
|
||||||
|
onClicked: appSettings.ttsVoice = modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ComboBox {
|
||||||
|
label: qsTr("TTS engine")
|
||||||
|
currentIndex: Math.max(0, page.engineNames.indexOf(appSettings.ttsEngine))
|
||||||
|
menu: ContextMenu {
|
||||||
|
Repeater {
|
||||||
|
model: page.engineNames
|
||||||
|
MenuItem {
|
||||||
|
text: modelData
|
||||||
|
onClicked: appSettings.ttsEngine = modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextSwitch {
|
||||||
|
text: qsTr("Read replies aloud")
|
||||||
|
description: qsTr("Speak every completed reply automatically")
|
||||||
|
checked: appSettings.ttsAutoRead
|
||||||
|
onCheckedChanged: appSettings.ttsAutoRead = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionHeader { text: qsTr("Account") }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: qsTr("Sign out")
|
||||||
|
onClicked: {
|
||||||
|
api.logout()
|
||||||
|
pageStack.replace(Qt.resolvedUrl("LoginPage.qml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionHeader { text: qsTr("About") }
|
||||||
|
|
||||||
|
Label {
|
||||||
|
x: Theme.horizontalPageMargin
|
||||||
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.secondaryColor
|
||||||
|
text: "harbour-hermes " + appVersion + "\n\n" +
|
||||||
|
qsTr("Native client for the Hermes Web UI (hermes-webui): " +
|
||||||
|
"sessions, live streaming chat, server-side voice " +
|
||||||
|
"dictation and read-aloud.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalScrollDecorator {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: urlField.text = appSettings.baseUrl
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
// Avvio: verifica lo stato dell'autenticazione (cookie persistito) e
|
||||||
|
// indirizza verso la lista sessioni o il login.
|
||||||
|
Page {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.paddingLarge
|
||||||
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: "Hermes"
|
||||||
|
font.pixelSize: Theme.fontSizeHuge
|
||||||
|
color: Theme.highlightColor
|
||||||
|
}
|
||||||
|
|
||||||
|
BusyIndicator {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
size: BusyIndicatorSize.Large
|
||||||
|
running: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: api
|
||||||
|
onAuthChecked: page.route(loggedIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: api.checkAuth()
|
||||||
|
|
||||||
|
function route(loggedIn) {
|
||||||
|
if (loggedIn)
|
||||||
|
pageStack.replace(Qt.resolvedUrl("SessionsPage.qml"))
|
||||||
|
else
|
||||||
|
pageStack.replace(Qt.resolvedUrl("LoginPage.qml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Name=Hermes
|
||||||
|
Name[it_IT]=Hermes
|
||||||
|
Comment=Client for the Hermes Web UI with voice
|
||||||
|
Comment[it_IT]=Client per la Web UI di Hermes con voce
|
||||||
|
Exec=harbour-hermes
|
||||||
|
Icon=harbour-hermes
|
||||||
|
Type=Application
|
||||||
|
X-Nemo-Application-Type=silica-qt5
|
||||||
|
Categories=Network;
|
||||||
|
|
||||||
|
# Profilo SailJail esplicito: senza X-Sailjail la sandbox di Sailfish 5.1 usa il
|
||||||
|
# profilo default, che nasconde ~/.config/harbour (config non persistente).
|
||||||
|
# Org/App dichiarati QUI (letti dalla sandbox) e nel main.cpp (letti da
|
||||||
|
# QStandardPaths): AppConfigLocation = ~/.config/harbour/hermes (persistente).
|
||||||
|
# Internet = Web UI di Hermes (HTTPS). Microphone = dettatura vocale.
|
||||||
|
# Audio = riproduzione delle risposte TTS e registrazione (PulseAudio non
|
||||||
|
# separa i flussi: il permesso Audio copre entrambi).
|
||||||
|
[X-Sailjail]
|
||||||
|
Permissions=Internet;Audio;Microphone
|
||||||
|
OrganizationName=harbour
|
||||||
|
ApplicationName=hermes
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
Name: harbour-hermes
|
||||||
|
Summary: Client for the Hermes Web UI with voice
|
||||||
|
Version: 0.1.0
|
||||||
|
Release: 1
|
||||||
|
Group: Qt/Qt
|
||||||
|
License: MIT
|
||||||
|
URL: https://git.hackatoniclife.com/kaneda/harbour-hermes
|
||||||
|
Source0: %{name}-%{version}.tar.gz
|
||||||
|
Source1: %{name}.desktop
|
||||||
|
|
||||||
|
Requires: sailfishsilica-qt5 >= 0.10.9
|
||||||
|
BuildRequires: pkgconfig(sailfishapp) >= 1.0.2
|
||||||
|
BuildRequires: pkgconfig(Qt5Core)
|
||||||
|
BuildRequires: pkgconfig(Qt5Qml)
|
||||||
|
BuildRequires: pkgconfig(Qt5Quick)
|
||||||
|
BuildRequires: pkgconfig(Qt5Network)
|
||||||
|
BuildRequires: pkgconfig(Qt5Multimedia)
|
||||||
|
BuildRequires: desktop-file-utils
|
||||||
|
|
||||||
|
%description
|
||||||
|
Native Sailfish OS client for the Hermes Web UI (hermes-webui).
|
||||||
|
Sessions list, chat with live streaming replies, voice dictation
|
||||||
|
(server-side Whisper transcription) and read-aloud of replies
|
||||||
|
(server-side TTS), plus a hands-free conversation mode.
|
||||||
|
|
||||||
|
%changelog
|
||||||
|
* Sat Sep 12 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.1.0-1
|
||||||
|
- Prima release: login alla Web UI di Hermes, lista sessioni,
|
||||||
|
chat con streaming SSE, dettatura vocale (registrazione OGG/Opus
|
||||||
|
-> /api/transcribe), lettura risposte (/api/tts -> QMediaPlayer),
|
||||||
|
nuova sessione, sandbox SailJail (Internet;Audio;Microphone)
|
||||||
|
|
||||||
|
%prep
|
||||||
|
%setup -q -n %{name}-%{version}
|
||||||
|
|
||||||
|
%build
|
||||||
|
%qmake5
|
||||||
|
make %{?_smp_mflags}
|
||||||
|
|
||||||
|
%install
|
||||||
|
make install INSTALL_ROOT=%{buildroot}
|
||||||
|
mkdir -p %{buildroot}%{_datadir}/applications
|
||||||
|
# Desktop file cercato in modo robusto: sfdk usa i sorgenti live (shadow build)
|
||||||
|
# e la directory sorgente puo' chiamarsi %{name}-%{version} (tarball),
|
||||||
|
# %{name} (clone) o %{name}-main (zip di Gitea); %{SOURCE1} e' il fallback.
|
||||||
|
DESKTOP=$(find %{_builddir}/.. -maxdepth 4 -name '%{name}.desktop' 2>/dev/null | head -1)
|
||||||
|
if [ -z "$DESKTOP" ] && [ -f %{SOURCE1} ]; then DESKTOP=%{SOURCE1}; fi
|
||||||
|
if [ -z "$DESKTOP" ]; then
|
||||||
|
echo "ERROR: %{name}.desktop not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
install -m 0644 "$DESKTOP" %{buildroot}%{_datadir}/applications/%{name}.desktop
|
||||||
|
desktop-file-validate %{buildroot}%{_datadir}/applications/%{name}.desktop || true
|
||||||
|
|
||||||
|
%files
|
||||||
|
%defattr(-,root,root,-)
|
||||||
|
%{_bindir}/%{name}
|
||||||
|
%{_datadir}/applications/%{name}.desktop
|
||||||
|
%{_datadir}/icons/hicolor/86x86/apps/%{name}.png
|
||||||
|
%{_datadir}/icons/hicolor/108x108/apps/%{name}.png
|
||||||
|
%{_datadir}/icons/hicolor/128x128/apps/%{name}.png
|
||||||
|
%{_datadir}/icons/hicolor/172x172/apps/%{name}.png
|
||||||
|
%{_datadir}/%{name}/qml
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Icone per harbour-hermes: bolla di chat con onde sonore, palette Nord.
|
||||||
|
|
||||||
|
Uso: python3 scripts/make_icons.py [<output_dir>] [<name>]
|
||||||
|
Scrive <out>/<size>x<size>/<name>.png per 86, 108, 128, 172.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
BG = (46, 52, 64) # #2E3440
|
||||||
|
ACCENT = (136, 192, 208) # #88C0D0
|
||||||
|
PAGE = (229, 233, 240) # #E5E9F0
|
||||||
|
WAVE = (163, 190, 140) # #A3BE8C
|
||||||
|
|
||||||
|
|
||||||
|
def draw_icon(size):
|
||||||
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
m = size * 0.05
|
||||||
|
d.rounded_rectangle([m, m, size - m, size - m], radius=size * 0.16, fill=BG)
|
||||||
|
|
||||||
|
# bolla di chat a sinistra
|
||||||
|
bx0, by0 = size * 0.18, size * 0.26
|
||||||
|
bx1, by1 = size * 0.56, size * 0.62
|
||||||
|
d.rounded_rectangle([bx0, by0, bx1, by1], radius=size * 0.07, fill=PAGE)
|
||||||
|
d.polygon([(size * 0.30, by1 - size * 0.01),
|
||||||
|
(size * 0.20, size * 0.74),
|
||||||
|
(size * 0.40, by1)], fill=PAGE)
|
||||||
|
|
||||||
|
# onde sonore a destra (tre archi concentrici)
|
||||||
|
cx, cy = size * 0.66, size * 0.585
|
||||||
|
for i, rr in enumerate((0.085, 0.155, 0.225)):
|
||||||
|
r = size * rr
|
||||||
|
d.arc([cx - r, cy - r, cx + r, cy + r], 300, 60,
|
||||||
|
fill=ACCENT if i < 2 else WAVE,
|
||||||
|
width=max(2, int(size * 0.028)))
|
||||||
|
|
||||||
|
# puntino sorgente
|
||||||
|
pr = size * 0.035
|
||||||
|
d.ellipse([cx - pr, cy - pr, cx + pr, cy + pr], fill=ACCENT)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
out_dir = sys.argv[1] if len(sys.argv) > 1 else "icons"
|
||||||
|
name = sys.argv[2] if len(sys.argv) > 2 else "harbour-hermes"
|
||||||
|
for s in (86, 108, 128, 172):
|
||||||
|
d = os.path.join(out_dir, "%dx%d" % (s, s))
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
draw_icon(s).save(os.path.join(d, name + ".png"))
|
||||||
|
print("icone scritte in %s/ (86..172)" % out_dir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
#include "apiclient.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QHttpMultiPart>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QUrlQuery>
|
||||||
|
|
||||||
|
ApiClient::ApiClient(Settings *settings, QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_settings(settings)
|
||||||
|
, m_jar(new CookieJar(this))
|
||||||
|
, m_loggedIn(false)
|
||||||
|
, m_busy(false)
|
||||||
|
, m_streaming(false)
|
||||||
|
, m_transcribing(false)
|
||||||
|
, m_streamReply(nullptr)
|
||||||
|
, m_streamEndedEmitted(false)
|
||||||
|
{
|
||||||
|
// Cookie di sessione persistente (0600), nella zona config della sandbox.
|
||||||
|
const QString cfg = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
|
||||||
|
QDir().mkpath(cfg);
|
||||||
|
m_jar->setFilePath(cfg + QStringLiteral("/cookies.dat"));
|
||||||
|
m_jar->load();
|
||||||
|
m_nam.setCookieJar(m_jar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- helpers --
|
||||||
|
|
||||||
|
QUrl ApiClient::apiUrl(const QString &path) const
|
||||||
|
{
|
||||||
|
return QUrl(m_settings->baseUrl() + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
QNetworkRequest ApiClient::jsonRequest(const QString &path, bool post) const
|
||||||
|
{
|
||||||
|
Q_UNUSED(post)
|
||||||
|
QNetworkRequest req(apiUrl(path));
|
||||||
|
req.setHeader(QNetworkRequest::ContentTypeHeader,
|
||||||
|
QStringLiteral("application/json"));
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ApiClient::cacheDir(const QString &sub) const
|
||||||
|
{
|
||||||
|
const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||||
|
const QString dir = base + QStringLiteral("/") + sub;
|
||||||
|
QDir().mkpath(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ApiClient::errorFromReply(QNetworkReply *reply, const QByteArray &body) const
|
||||||
|
{
|
||||||
|
const QJsonDocument doc = QJsonDocument::fromJson(body);
|
||||||
|
if (doc.isObject()) {
|
||||||
|
const QString err = doc.object().value(QStringLiteral("error")).toString();
|
||||||
|
if (!err.isEmpty())
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
|
if (status == 401)
|
||||||
|
return QStringLiteral("Authentication required");
|
||||||
|
if (status > 0)
|
||||||
|
return QStringLiteral("HTTP %1").arg(status);
|
||||||
|
return reply->errorString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::setBusy(bool on)
|
||||||
|
{
|
||||||
|
if (on == m_busy)
|
||||||
|
return;
|
||||||
|
m_busy = on;
|
||||||
|
emit busyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::setStreaming(bool on)
|
||||||
|
{
|
||||||
|
if (on == m_streaming)
|
||||||
|
return;
|
||||||
|
m_streaming = on;
|
||||||
|
emit streamingChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::setTranscribing(bool on)
|
||||||
|
{
|
||||||
|
if (on == m_transcribing)
|
||||||
|
return;
|
||||||
|
m_transcribing = on;
|
||||||
|
emit transcribingChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::setLastError(const QString &error)
|
||||||
|
{
|
||||||
|
if (error == m_lastError)
|
||||||
|
return;
|
||||||
|
m_lastError = error;
|
||||||
|
emit lastErrorChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::setLoggedIn(bool on)
|
||||||
|
{
|
||||||
|
if (on == m_loggedIn)
|
||||||
|
return;
|
||||||
|
m_loggedIn = on;
|
||||||
|
emit loggedInChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::clearError()
|
||||||
|
{
|
||||||
|
setLastError(QString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- auth --
|
||||||
|
|
||||||
|
void ApiClient::checkAuth()
|
||||||
|
{
|
||||||
|
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/auth/status")));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||||
|
reply->deleteLater();
|
||||||
|
const QByteArray body = reply->readAll();
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
setLoggedIn(false);
|
||||||
|
emit authChecked(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QJsonObject obj = QJsonDocument::fromJson(body).object();
|
||||||
|
const bool authEnabled = obj.value(QStringLiteral("auth_enabled")).toBool();
|
||||||
|
const bool sessionValid = obj.value(QStringLiteral("logged_in")).toBool();
|
||||||
|
if (!authEnabled || sessionValid) {
|
||||||
|
setLoggedIn(true);
|
||||||
|
refreshSessions();
|
||||||
|
} else {
|
||||||
|
setLoggedIn(false);
|
||||||
|
}
|
||||||
|
emit authChecked(m_loggedIn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::login(const QString &password)
|
||||||
|
{
|
||||||
|
setLastError(QString());
|
||||||
|
setBusy(true);
|
||||||
|
QJsonObject body;
|
||||||
|
body.insert(QStringLiteral("password"), password);
|
||||||
|
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/auth/login")),
|
||||||
|
QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||||
|
reply->deleteLater();
|
||||||
|
setBusy(false);
|
||||||
|
const QByteArray data = reply->readAll();
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
setLastError(errorFromReply(reply, data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoggedIn(true);
|
||||||
|
refreshSessions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::logout()
|
||||||
|
{
|
||||||
|
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/auth/logout")), QByteArray());
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||||
|
reply->deleteLater();
|
||||||
|
});
|
||||||
|
m_jar->clear();
|
||||||
|
setLoggedIn(false);
|
||||||
|
m_sessions.clear();
|
||||||
|
emit sessionsChanged();
|
||||||
|
m_session.clear();
|
||||||
|
emit sessionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- sessioni --
|
||||||
|
|
||||||
|
void ApiClient::refreshSessions()
|
||||||
|
{
|
||||||
|
setBusy(true);
|
||||||
|
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/sessions")));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||||
|
reply->deleteLater();
|
||||||
|
setBusy(false);
|
||||||
|
const QByteArray body = reply->readAll();
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
setLastError(errorFromReply(reply, body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QJsonObject obj = QJsonDocument::fromJson(body).object();
|
||||||
|
const QJsonArray arr = obj.value(QStringLiteral("sessions")).toArray();
|
||||||
|
m_sessions = arr.toVariantList();
|
||||||
|
emit sessionsChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::openSession(const QString &id)
|
||||||
|
{
|
||||||
|
setLastError(QString());
|
||||||
|
setBusy(true);
|
||||||
|
QUrl url = apiUrl(QStringLiteral("/api/session"));
|
||||||
|
QUrlQuery q;
|
||||||
|
q.addQueryItem(QStringLiteral("session_id"), id);
|
||||||
|
url.setQuery(q);
|
||||||
|
QNetworkReply *reply = m_nam.get(QNetworkRequest(url));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||||
|
reply->deleteLater();
|
||||||
|
setBusy(false);
|
||||||
|
const QByteArray body = reply->readAll();
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
setLastError(errorFromReply(reply, body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QJsonObject obj = QJsonDocument::fromJson(body).object();
|
||||||
|
m_session = obj.value(QStringLiteral("session")).toObject().toVariantMap();
|
||||||
|
emit sessionChanged();
|
||||||
|
emit sessionOpened();
|
||||||
|
// Se la sessione ha gia' un run attivo (ripresa dopo chiusura app),
|
||||||
|
// riaggancia lo stream SSE.
|
||||||
|
const QString active = m_session.value(QStringLiteral("active_stream_id")).toString();
|
||||||
|
if (!active.isEmpty())
|
||||||
|
startStream(active);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::newSession()
|
||||||
|
{
|
||||||
|
setLastError(QString());
|
||||||
|
setBusy(true);
|
||||||
|
QJsonObject body;
|
||||||
|
const QString ws = m_settings->defaultWorkspace();
|
||||||
|
if (!ws.isEmpty())
|
||||||
|
body.insert(QStringLiteral("workspace"), ws);
|
||||||
|
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/session/new")),
|
||||||
|
QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||||
|
reply->deleteLater();
|
||||||
|
setBusy(false);
|
||||||
|
const QByteArray data = reply->readAll();
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
setLastError(errorFromReply(reply, data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QJsonObject obj = QJsonDocument::fromJson(data).object();
|
||||||
|
m_session = obj.value(QStringLiteral("session")).toObject().toVariantMap();
|
||||||
|
emit sessionChanged();
|
||||||
|
emit sessionOpened();
|
||||||
|
refreshSessions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- chat --
|
||||||
|
|
||||||
|
void ApiClient::startChat(const QString &sessionId, const QString &message)
|
||||||
|
{
|
||||||
|
setLastError(QString());
|
||||||
|
QJsonObject body;
|
||||||
|
body.insert(QStringLiteral("session_id"), sessionId);
|
||||||
|
body.insert(QStringLiteral("message"), message);
|
||||||
|
const QString model = m_session.value(QStringLiteral("model")).toString();
|
||||||
|
const QString provider = m_session.value(QStringLiteral("model_provider")).toString();
|
||||||
|
const QString workspace = m_session.value(QStringLiteral("workspace")).toString();
|
||||||
|
QString profile = m_session.value(QStringLiteral("profile")).toString();
|
||||||
|
if (profile.isEmpty())
|
||||||
|
profile = QStringLiteral("default");
|
||||||
|
body.insert(QStringLiteral("model"), model);
|
||||||
|
body.insert(QStringLiteral("model_provider"), provider);
|
||||||
|
body.insert(QStringLiteral("workspace"), workspace);
|
||||||
|
body.insert(QStringLiteral("profile"), profile);
|
||||||
|
|
||||||
|
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/chat/start")),
|
||||||
|
QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||||
|
handleStartReply(reply);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::handleStartReply(QNetworkReply *reply)
|
||||||
|
{
|
||||||
|
reply->deleteLater();
|
||||||
|
const QByteArray body = reply->readAll();
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
const QString err = errorFromReply(reply, body);
|
||||||
|
setLastError(err);
|
||||||
|
emit chatStartFailed(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QJsonObject obj = QJsonDocument::fromJson(body).object();
|
||||||
|
const QString streamId = obj.value(QStringLiteral("stream_id")).toString();
|
||||||
|
if (streamId.isEmpty()) {
|
||||||
|
const QString err = QStringLiteral("No stream_id in response");
|
||||||
|
setLastError(err);
|
||||||
|
emit chatStartFailed(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString sid = obj.value(QStringLiteral("session_id")).toString();
|
||||||
|
if (!sid.isEmpty())
|
||||||
|
m_session.insert(QStringLiteral("session_id"), sid);
|
||||||
|
emit chatStarted(streamId);
|
||||||
|
startStream(streamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::cancelRun()
|
||||||
|
{
|
||||||
|
if (m_streamId.isEmpty())
|
||||||
|
return;
|
||||||
|
QUrl url = apiUrl(QStringLiteral("/api/chat/cancel"));
|
||||||
|
QUrlQuery q;
|
||||||
|
q.addQueryItem(QStringLiteral("stream_id"), m_streamId);
|
||||||
|
url.setQuery(q);
|
||||||
|
QNetworkReply *reply = m_nam.get(QNetworkRequest(url));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||||
|
reply->deleteLater();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- SSE --
|
||||||
|
|
||||||
|
void ApiClient::startStream(const QString &streamId)
|
||||||
|
{
|
||||||
|
if (m_streamReply) {
|
||||||
|
m_streamReply->abort();
|
||||||
|
m_streamReply->deleteLater();
|
||||||
|
m_streamReply = nullptr;
|
||||||
|
}
|
||||||
|
m_streamId = streamId;
|
||||||
|
m_streamEndedEmitted = false;
|
||||||
|
m_parser.reset();
|
||||||
|
|
||||||
|
QUrl url = apiUrl(QStringLiteral("/api/chat/stream"));
|
||||||
|
QUrlQuery q;
|
||||||
|
q.addQueryItem(QStringLiteral("stream_id"), streamId);
|
||||||
|
url.setQuery(q);
|
||||||
|
QNetworkRequest req(url);
|
||||||
|
req.setRawHeader("Accept", "text/event-stream");
|
||||||
|
m_streamReply = m_nam.get(req);
|
||||||
|
setStreaming(true);
|
||||||
|
|
||||||
|
connect(m_streamReply, &QNetworkReply::readyRead, this, [this]() {
|
||||||
|
if (!m_streamReply)
|
||||||
|
return;
|
||||||
|
const QList<SseParser::Event> events = m_parser.feed(m_streamReply->readAll());
|
||||||
|
for (int i = 0; i < events.size(); ++i)
|
||||||
|
dispatchSseEvent(events.at(i));
|
||||||
|
});
|
||||||
|
connect(m_streamReply, &QNetworkReply::finished, this, [this]() {
|
||||||
|
QNetworkReply *reply = m_streamReply;
|
||||||
|
m_streamReply = nullptr;
|
||||||
|
if (reply) {
|
||||||
|
if (reply->error() != QNetworkReply::NoError
|
||||||
|
&& reply->error() != QNetworkReply::OperationCanceledError) {
|
||||||
|
const QString err = reply->errorString();
|
||||||
|
setLastError(err);
|
||||||
|
emit streamError(err);
|
||||||
|
}
|
||||||
|
reply->deleteLater();
|
||||||
|
}
|
||||||
|
emitStreamEndedOnce();
|
||||||
|
setStreaming(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::dispatchSseEvent(const SseParser::Event &ev)
|
||||||
|
{
|
||||||
|
const QJsonObject obj = QJsonDocument::fromJson(ev.data.toUtf8()).object();
|
||||||
|
if (ev.name == QLatin1String("token")) {
|
||||||
|
emit streamDelta(obj.value(QStringLiteral("text")).toString());
|
||||||
|
} else if (ev.name == QLatin1String("done")) {
|
||||||
|
const QVariantMap session = obj.value(QStringLiteral("session")).toObject().toVariantMap();
|
||||||
|
if (!session.isEmpty()) {
|
||||||
|
m_session = session;
|
||||||
|
emit sessionChanged();
|
||||||
|
}
|
||||||
|
emit streamDone(session);
|
||||||
|
} else if (ev.name == QLatin1String("title")) {
|
||||||
|
const QString title = obj.value(QStringLiteral("title")).toString();
|
||||||
|
if (!title.isEmpty()) {
|
||||||
|
m_session.insert(QStringLiteral("title"), title);
|
||||||
|
emit sessionChanged();
|
||||||
|
emit streamTitle(title);
|
||||||
|
}
|
||||||
|
} else if (ev.name == QLatin1String("stream_end")) {
|
||||||
|
emitStreamEndedOnce();
|
||||||
|
}
|
||||||
|
// Altri eventi noti (context_status, metering, title_status) e sconosciuti:
|
||||||
|
// ignorati intenzionalmente in questa versione.
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::emitStreamEndedOnce()
|
||||||
|
{
|
||||||
|
if (m_streamEndedEmitted)
|
||||||
|
return;
|
||||||
|
m_streamEndedEmitted = true;
|
||||||
|
emit streamEnded();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- voce --
|
||||||
|
|
||||||
|
void ApiClient::transcribeFile(const QString &path)
|
||||||
|
{
|
||||||
|
setLastError(QString());
|
||||||
|
setTranscribing(true);
|
||||||
|
|
||||||
|
QFile *file = new QFile(path);
|
||||||
|
if (!file->open(QIODevice::ReadOnly)) {
|
||||||
|
file->deleteLater();
|
||||||
|
setTranscribing(false);
|
||||||
|
const QString err = QStringLiteral("Cannot open audio file");
|
||||||
|
setLastError(err);
|
||||||
|
emit transcriptionFailed(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
|
||||||
|
QHttpPart filePart;
|
||||||
|
filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QStringLiteral("audio/ogg")));
|
||||||
|
filePart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||||
|
QVariant(QStringLiteral("form-data; name=\"file\"; filename=\"%1\"")
|
||||||
|
.arg(QFileInfo(path).fileName())));
|
||||||
|
filePart.setBodyDevice(file);
|
||||||
|
file->setParent(multiPart);
|
||||||
|
multiPart->append(filePart);
|
||||||
|
|
||||||
|
QNetworkRequest req(apiUrl(QStringLiteral("/api/transcribe")));
|
||||||
|
QNetworkReply *reply = m_nam.post(req, multiPart);
|
||||||
|
multiPart->setParent(reply);
|
||||||
|
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||||
|
reply->deleteLater();
|
||||||
|
setTranscribing(false);
|
||||||
|
const QByteArray body = reply->readAll();
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
const QString err = errorFromReply(reply, body);
|
||||||
|
setLastError(err);
|
||||||
|
emit transcriptionFailed(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QJsonObject obj = QJsonDocument::fromJson(body).object();
|
||||||
|
if (!obj.value(QStringLiteral("ok")).toBool()) {
|
||||||
|
QString err = obj.value(QStringLiteral("error")).toString();
|
||||||
|
if (err.isEmpty())
|
||||||
|
err = QStringLiteral("Transcription failed");
|
||||||
|
setLastError(err);
|
||||||
|
emit transcriptionFailed(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit transcriptionReady(obj.value(QStringLiteral("transcript")).toString().trimmed());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiClient::speak(const QString &text)
|
||||||
|
{
|
||||||
|
const QString trimmed = text.trimmed();
|
||||||
|
if (trimmed.isEmpty())
|
||||||
|
return;
|
||||||
|
// Il server accetta max 5000 caratteri per richiesta: tronca con avviso.
|
||||||
|
QString spoken = trimmed;
|
||||||
|
if (spoken.size() > 4900)
|
||||||
|
spoken = spoken.left(4900);
|
||||||
|
|
||||||
|
setLastError(QString());
|
||||||
|
QJsonObject body;
|
||||||
|
body.insert(QStringLiteral("text"), spoken);
|
||||||
|
body.insert(QStringLiteral("voice"), m_settings->ttsVoice());
|
||||||
|
body.insert(QStringLiteral("engine"), m_settings->ttsEngine());
|
||||||
|
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/tts")),
|
||||||
|
QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||||
|
reply->deleteLater();
|
||||||
|
const QByteArray body = reply->readAll();
|
||||||
|
if (reply->error() != QNetworkReply::NoError) {
|
||||||
|
const QString err = errorFromReply(reply, body);
|
||||||
|
setLastError(err);
|
||||||
|
emit ttsFailed(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QVariant contentType = reply->header(QNetworkRequest::ContentTypeHeader);
|
||||||
|
if (!contentType.toString().startsWith(QLatin1String("audio/"))) {
|
||||||
|
QString err = QStringLiteral("TTS returned non-audio response");
|
||||||
|
const QJsonObject obj = QJsonDocument::fromJson(body).object();
|
||||||
|
const QString jsonErr = obj.value(QStringLiteral("error")).toString();
|
||||||
|
if (!jsonErr.isEmpty())
|
||||||
|
err = jsonErr;
|
||||||
|
setLastError(err);
|
||||||
|
emit ttsFailed(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString file = cacheDir(QStringLiteral("tts"))
|
||||||
|
+ QStringLiteral("/reply-%1.mp3")
|
||||||
|
.arg(QDateTime::currentMSecsSinceEpoch());
|
||||||
|
QFile f(file);
|
||||||
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
|
const QString err = QStringLiteral("Cannot write audio file");
|
||||||
|
setLastError(err);
|
||||||
|
emit ttsFailed(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
f.write(body);
|
||||||
|
f.close();
|
||||||
|
emit ttsReady(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
#ifndef APICLIENT_H
|
||||||
|
#define APICLIENT_H
|
||||||
|
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QVariantList>
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
|
#include "cookiejar.h"
|
||||||
|
#include "settings.h"
|
||||||
|
#include "sseparser.h"
|
||||||
|
|
||||||
|
class QHttpMultiPart;
|
||||||
|
|
||||||
|
// Client HTTP per la Web UI di Hermes (hermes-webui).
|
||||||
|
//
|
||||||
|
// Protocollo (vedi docs/PROTOCOL.md, validato contro il server reale):
|
||||||
|
// POST /api/auth/login {password} -> cookie di sessione
|
||||||
|
// GET /api/auth/status -> {auth_enabled, logged_in}
|
||||||
|
// GET /api/sessions -> {sessions: [...]}
|
||||||
|
// GET /api/session?session_id=ID -> {session: {...messages}}
|
||||||
|
// POST /api/session/new {workspace?} -> {session: {...}}
|
||||||
|
// POST /api/chat/start {session_id, message, ...} -> {stream_id, ...}
|
||||||
|
// GET /api/chat/stream?stream_id=ID -> SSE (text/event-stream)
|
||||||
|
// GET /api/chat/cancel?stream_id=ID -> {ok, cancelled}
|
||||||
|
// POST /api/transcribe (multipart "file") -> {ok, transcript}
|
||||||
|
// POST /api/tts {text, voice, engine} -> audio/mpeg
|
||||||
|
//
|
||||||
|
// L'autenticazione e' a cookie: il jar e' persistente (CookieJar).
|
||||||
|
class ApiClient : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(bool loggedIn READ loggedIn NOTIFY loggedInChanged)
|
||||||
|
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||||
|
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
|
||||||
|
Q_PROPERTY(bool transcribing READ transcribing NOTIFY transcribingChanged)
|
||||||
|
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
|
||||||
|
Q_PROPERTY(QVariantList sessions READ sessions NOTIFY sessionsChanged)
|
||||||
|
Q_PROPERTY(QVariantMap session READ session NOTIFY sessionChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ApiClient(Settings *settings, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
bool loggedIn() const { return m_loggedIn; }
|
||||||
|
bool busy() const { return m_busy; }
|
||||||
|
bool streaming() const { return m_streaming; }
|
||||||
|
bool transcribing() const { return m_transcribing; }
|
||||||
|
QString lastError() const { return m_lastError; }
|
||||||
|
QVariantList sessions() const { return m_sessions; }
|
||||||
|
QVariantMap session() const { return m_session; }
|
||||||
|
|
||||||
|
// Avvio: verifica lo stato auth (cookie persistito o auth disabilitata).
|
||||||
|
Q_INVOKABLE void checkAuth();
|
||||||
|
Q_INVOKABLE void login(const QString &password);
|
||||||
|
Q_INVOKABLE void logout();
|
||||||
|
|
||||||
|
Q_INVOKABLE void refreshSessions();
|
||||||
|
Q_INVOKABLE void openSession(const QString &id);
|
||||||
|
Q_INVOKABLE void newSession();
|
||||||
|
|
||||||
|
// Avvia un turno e collega automaticamente lo stream SSE.
|
||||||
|
Q_INVOKABLE void startChat(const QString &sessionId, const QString &message);
|
||||||
|
Q_INVOKABLE void cancelRun();
|
||||||
|
|
||||||
|
// Voce: trascrizione di un file audio (multipart) e sintesi TTS.
|
||||||
|
Q_INVOKABLE void transcribeFile(const QString &path);
|
||||||
|
Q_INVOKABLE void speak(const QString &text);
|
||||||
|
|
||||||
|
Q_INVOKABLE void clearError();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void loggedInChanged();
|
||||||
|
void busyChanged();
|
||||||
|
void streamingChanged();
|
||||||
|
void transcribingChanged();
|
||||||
|
void lastErrorChanged();
|
||||||
|
void sessionsChanged();
|
||||||
|
void sessionChanged();
|
||||||
|
|
||||||
|
void authChecked(bool loggedIn);
|
||||||
|
void sessionOpened(); // openSession/newSession completata
|
||||||
|
void chatStarted(const QString &streamId);
|
||||||
|
void chatStartFailed(const QString &error);
|
||||||
|
void streamDelta(const QString &text);
|
||||||
|
void streamDone(const QVariantMap &session);
|
||||||
|
void streamTitle(const QString &title);
|
||||||
|
void streamEnded();
|
||||||
|
void streamError(const QString &error);
|
||||||
|
void transcriptionReady(const QString &text);
|
||||||
|
void transcriptionFailed(const QString &error);
|
||||||
|
void ttsReady(const QString &filePath);
|
||||||
|
void ttsFailed(const QString &error);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void setBusy(bool on);
|
||||||
|
void setStreaming(bool on);
|
||||||
|
void setTranscribing(bool on);
|
||||||
|
void setLastError(const QString &error);
|
||||||
|
void setLoggedIn(bool on);
|
||||||
|
|
||||||
|
QUrl apiUrl(const QString &path) const;
|
||||||
|
QNetworkRequest jsonRequest(const QString &path, bool post = false) const;
|
||||||
|
QString errorFromReply(QNetworkReply *reply, const QByteArray &body) const;
|
||||||
|
QString cacheDir(const QString &sub) const;
|
||||||
|
|
||||||
|
void startStream(const QString &streamId);
|
||||||
|
void dispatchSseEvent(const SseParser::Event &ev);
|
||||||
|
void emitStreamEndedOnce();
|
||||||
|
void handleStartReply(QNetworkReply *reply);
|
||||||
|
|
||||||
|
Settings *m_settings;
|
||||||
|
QNetworkAccessManager m_nam;
|
||||||
|
CookieJar *m_jar;
|
||||||
|
|
||||||
|
bool m_loggedIn;
|
||||||
|
bool m_busy;
|
||||||
|
bool m_streaming;
|
||||||
|
bool m_transcribing;
|
||||||
|
QString m_lastError;
|
||||||
|
|
||||||
|
QVariantList m_sessions;
|
||||||
|
QVariantMap m_session;
|
||||||
|
|
||||||
|
QString m_streamId;
|
||||||
|
QNetworkReply *m_streamReply;
|
||||||
|
SseParser m_parser;
|
||||||
|
bool m_streamEndedEmitted;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // APICLIENT_H
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
#include "chatmodel.h"
|
||||||
|
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
ChatModel::ChatModel(ApiClient *api, QObject *parent)
|
||||||
|
: QAbstractListModel(parent)
|
||||||
|
, m_api(api)
|
||||||
|
, m_busy(false)
|
||||||
|
, m_finalizedThisRun(false)
|
||||||
|
{
|
||||||
|
connect(m_api, &ApiClient::sessionOpened, this, &ChatModel::onSessionOpened);
|
||||||
|
connect(m_api, &ApiClient::chatStarted, this, &ChatModel::onChatStarted);
|
||||||
|
connect(m_api, &ApiClient::chatStartFailed, this, &ChatModel::onChatStartFailed);
|
||||||
|
connect(m_api, &ApiClient::streamDelta, this, &ChatModel::onStreamDelta);
|
||||||
|
connect(m_api, &ApiClient::streamDone, this, &ChatModel::onStreamDone);
|
||||||
|
connect(m_api, &ApiClient::streamEnded, this, &ChatModel::onStreamEnded);
|
||||||
|
connect(m_api, &ApiClient::streamError, this, &ChatModel::onStreamError);
|
||||||
|
}
|
||||||
|
|
||||||
|
int ChatModel::rowCount(const QModelIndex &parent) const
|
||||||
|
{
|
||||||
|
if (parent.isValid())
|
||||||
|
return 0;
|
||||||
|
return m_msgs.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant ChatModel::data(const QModelIndex &index, int role) const
|
||||||
|
{
|
||||||
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_msgs.size())
|
||||||
|
return QVariant();
|
||||||
|
const Msg &m = m_msgs.at(index.row());
|
||||||
|
switch (role) {
|
||||||
|
case RoleRole:
|
||||||
|
return m.role;
|
||||||
|
case ContentRole:
|
||||||
|
return m.content;
|
||||||
|
case StreamingRole:
|
||||||
|
return m.streaming;
|
||||||
|
case TimestampRole:
|
||||||
|
return m.ts;
|
||||||
|
default:
|
||||||
|
return QVariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<int, QByteArray> ChatModel::roleNames() const
|
||||||
|
{
|
||||||
|
QHash<int, QByteArray> roles;
|
||||||
|
roles.insert(RoleRole, "role");
|
||||||
|
roles.insert(ContentRole, "content");
|
||||||
|
roles.insert(StreamingRole, "streaming");
|
||||||
|
roles.insert(TimestampRole, "timestamp");
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ flusso chat --
|
||||||
|
|
||||||
|
void ChatModel::openSession(const QString &id)
|
||||||
|
{
|
||||||
|
m_sessionId = id;
|
||||||
|
m_title.clear();
|
||||||
|
setFromMessages(QVariantList());
|
||||||
|
emit sessionInfoChanged();
|
||||||
|
m_api->openSession(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::newSession()
|
||||||
|
{
|
||||||
|
m_title.clear();
|
||||||
|
setFromMessages(QVariantList());
|
||||||
|
emit sessionInfoChanged();
|
||||||
|
m_api->newSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::resync()
|
||||||
|
{
|
||||||
|
if (!m_sessionId.isEmpty())
|
||||||
|
m_api->openSession(m_sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::send(const QString &text)
|
||||||
|
{
|
||||||
|
const QString trimmed = text.trimmed();
|
||||||
|
if (trimmed.isEmpty() || m_sessionId.isEmpty() || m_busy)
|
||||||
|
return;
|
||||||
|
m_finalizedThisRun = false;
|
||||||
|
appendMessage(QStringLiteral("user"), trimmed, false);
|
||||||
|
beginAssistant();
|
||||||
|
setBusy(true);
|
||||||
|
m_api->startChat(m_sessionId, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::cancel()
|
||||||
|
{
|
||||||
|
m_api->cancelRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ChatModel::lastAssistantText() const
|
||||||
|
{
|
||||||
|
for (int i = m_msgs.size() - 1; i >= 0; --i) {
|
||||||
|
const Msg &m = m_msgs.at(i);
|
||||||
|
if (m.role == QLatin1String("assistant") && !m.content.trimmed().isEmpty())
|
||||||
|
return m.content;
|
||||||
|
}
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::onSessionOpened()
|
||||||
|
{
|
||||||
|
const QVariantMap s = m_api->session();
|
||||||
|
const QString sid = s.value(QStringLiteral("session_id")).toString();
|
||||||
|
if (!sid.isEmpty())
|
||||||
|
m_sessionId = sid;
|
||||||
|
m_title = s.value(QStringLiteral("title")).toString();
|
||||||
|
setFromMessages(s.value(QStringLiteral("messages")).toList());
|
||||||
|
emit sessionInfoChanged();
|
||||||
|
// Ripresa: la sessione aveva un run attivo e lo stream e' stato riagganciato.
|
||||||
|
if (m_api->streaming()) {
|
||||||
|
beginAssistant();
|
||||||
|
setBusy(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::onChatStarted(const QString &streamId)
|
||||||
|
{
|
||||||
|
Q_UNUSED(streamId)
|
||||||
|
if (m_msgs.isEmpty() || m_msgs.last().role != QLatin1String("assistant"))
|
||||||
|
beginAssistant();
|
||||||
|
setBusy(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::onChatStartFailed(const QString &error)
|
||||||
|
{
|
||||||
|
// Rimuove il placeholder assistente rimasto vuoto.
|
||||||
|
if (m_msgs.size() > 0 && m_msgs.last().role == QLatin1String("assistant")
|
||||||
|
&& m_msgs.last().content.isEmpty()) {
|
||||||
|
beginRemoveRows(QModelIndex(), m_msgs.size() - 1, m_msgs.size() - 1);
|
||||||
|
m_msgs.removeLast();
|
||||||
|
endRemoveRows();
|
||||||
|
emit rowCountChanged();
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
emit notice(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::onStreamDelta(const QString &text)
|
||||||
|
{
|
||||||
|
if (m_msgs.isEmpty() || m_msgs.last().role != QLatin1String("assistant"))
|
||||||
|
beginAssistant();
|
||||||
|
Msg &last = m_msgs.last();
|
||||||
|
if (!last.streaming) {
|
||||||
|
last.streaming = true;
|
||||||
|
}
|
||||||
|
last.content += text;
|
||||||
|
const QModelIndex idx = index(m_msgs.size() - 1, 0);
|
||||||
|
emit dataChanged(idx, idx, QVector<int>() << ContentRole << StreamingRole);
|
||||||
|
emit streamTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::onStreamDone(const QVariantMap &session)
|
||||||
|
{
|
||||||
|
m_finalizedThisRun = true;
|
||||||
|
m_title = session.value(QStringLiteral("title")).toString();
|
||||||
|
setFromMessages(session.value(QStringLiteral("messages")).toList());
|
||||||
|
emit sessionInfoChanged();
|
||||||
|
setBusy(false);
|
||||||
|
emit assistantFinished();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::onStreamEnded()
|
||||||
|
{
|
||||||
|
if (!m_msgs.isEmpty() && m_msgs.last().streaming) {
|
||||||
|
Msg &last = m_msgs.last();
|
||||||
|
last.streaming = false;
|
||||||
|
// Placeholder rimasto vuoto (run interrotto prima del primo token).
|
||||||
|
if (last.role == QLatin1String("assistant") && last.content.isEmpty()) {
|
||||||
|
beginRemoveRows(QModelIndex(), m_msgs.size() - 1, m_msgs.size() - 1);
|
||||||
|
m_msgs.removeLast();
|
||||||
|
endRemoveRows();
|
||||||
|
emit rowCountChanged();
|
||||||
|
} else {
|
||||||
|
const QModelIndex idx = index(m_msgs.size() - 1, 0);
|
||||||
|
emit dataChanged(idx, idx, QVector<int>() << StreamingRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
// Aggiorna la lista sessioni (titolo/ordinamento possono essere cambiati).
|
||||||
|
m_api->refreshSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::onStreamError(const QString &error)
|
||||||
|
{
|
||||||
|
emit notice(error);
|
||||||
|
// Lo stream puo' cadere per sospensione dell'app: risincronizza dallo stato
|
||||||
|
// del server tra un attimo (se nel frattempo non e' gia' ripartito qualcosa).
|
||||||
|
QTimer::singleShot(1200, this, [this]() {
|
||||||
|
if (!m_api->streaming() && !m_sessionId.isEmpty())
|
||||||
|
m_api->openSession(m_sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- modello ----
|
||||||
|
|
||||||
|
void ChatModel::setFromMessages(const QVariantList &messages)
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
m_msgs.clear();
|
||||||
|
for (int i = 0; i < messages.size(); ++i) {
|
||||||
|
const QVariantMap m = messages.at(i).toMap();
|
||||||
|
const QString role = m.value(QStringLiteral("role")).toString();
|
||||||
|
if (role != QLatin1String("user") && role != QLatin1String("assistant"))
|
||||||
|
continue;
|
||||||
|
const QString content = m.value(QStringLiteral("content")).toString();
|
||||||
|
// Messaggi assistente vuoti (solo chiamate a strumenti): non mostrati.
|
||||||
|
if (role == QLatin1String("assistant") && content.trimmed().isEmpty())
|
||||||
|
continue;
|
||||||
|
Msg msg;
|
||||||
|
msg.role = role;
|
||||||
|
msg.content = content;
|
||||||
|
msg.streaming = false;
|
||||||
|
msg.ts = m.value(QStringLiteral("timestamp")).toDouble();
|
||||||
|
m_msgs.append(msg);
|
||||||
|
}
|
||||||
|
endResetModel();
|
||||||
|
emit rowCountChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::appendMessage(const QString &role, const QString &content, bool streaming)
|
||||||
|
{
|
||||||
|
beginInsertRows(QModelIndex(), m_msgs.size(), m_msgs.size());
|
||||||
|
Msg msg;
|
||||||
|
msg.role = role;
|
||||||
|
msg.content = content;
|
||||||
|
msg.streaming = streaming;
|
||||||
|
msg.ts = 0;
|
||||||
|
m_msgs.append(msg);
|
||||||
|
endInsertRows();
|
||||||
|
emit rowCountChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::beginAssistant()
|
||||||
|
{
|
||||||
|
appendMessage(QStringLiteral("assistant"), QString(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatModel::setBusy(bool on)
|
||||||
|
{
|
||||||
|
if (on == m_busy)
|
||||||
|
return;
|
||||||
|
m_busy = on;
|
||||||
|
emit busyChanged();
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#ifndef CHATMODEL_H
|
||||||
|
#define CHATMODEL_H
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVariantList>
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
|
#include "apiclient.h"
|
||||||
|
|
||||||
|
// Modello della conversazione corrente + orchestrazione del flusso chat.
|
||||||
|
//
|
||||||
|
// Riceve gli eventi da ApiClient e mantiene la lista messaggi per la UI:
|
||||||
|
// - apertura/nuova sessione -> reset dal JSON della sessione
|
||||||
|
// - invio -> messaggio utente ottimistico + placeholder assistente
|
||||||
|
// - streaming -> append dei delta "token"
|
||||||
|
// - done -> risincronizzazione con i messaggi finali della sessione
|
||||||
|
class ChatModel : public QAbstractListModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionInfoChanged)
|
||||||
|
Q_PROPERTY(QString title READ title NOTIFY sessionInfoChanged)
|
||||||
|
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||||
|
Q_PROPERTY(bool canSend READ canSend NOTIFY busyChanged)
|
||||||
|
Q_PROPERTY(int count READ count NOTIFY rowCountChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum Roles {
|
||||||
|
RoleRole = Qt::UserRole + 1,
|
||||||
|
ContentRole,
|
||||||
|
StreamingRole,
|
||||||
|
TimestampRole
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit ChatModel(ApiClient *api, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role) const override;
|
||||||
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
QString sessionId() const { return m_sessionId; }
|
||||||
|
QString title() const { return m_title; }
|
||||||
|
bool busy() const { return m_busy; }
|
||||||
|
bool canSend() const { return !m_busy && !m_sessionId.isEmpty(); }
|
||||||
|
int count() const { return m_msgs.size(); }
|
||||||
|
|
||||||
|
Q_INVOKABLE void openSession(const QString &id);
|
||||||
|
Q_INVOKABLE void newSession();
|
||||||
|
Q_INVOKABLE void send(const QString &text);
|
||||||
|
Q_INVOKABLE void cancel();
|
||||||
|
Q_INVOKABLE void resync();
|
||||||
|
Q_INVOKABLE QString lastAssistantText() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void sessionInfoChanged();
|
||||||
|
void busyChanged();
|
||||||
|
void rowCountChanged();
|
||||||
|
void streamTick(); // ogni delta di streaming (per l'auto-scroll)
|
||||||
|
void assistantFinished(); // risposta completata (per auto-read TTS)
|
||||||
|
void notice(const QString &text); // avvisi non bloccanti per la UI
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Msg {
|
||||||
|
QString role;
|
||||||
|
QString content;
|
||||||
|
bool streaming;
|
||||||
|
double ts;
|
||||||
|
};
|
||||||
|
|
||||||
|
void setFromMessages(const QVariantList &messages);
|
||||||
|
void appendMessage(const QString &role, const QString &content, bool streaming);
|
||||||
|
void beginAssistant();
|
||||||
|
void setBusy(bool on);
|
||||||
|
|
||||||
|
void onSessionOpened();
|
||||||
|
void onChatStarted(const QString &streamId);
|
||||||
|
void onChatStartFailed(const QString &error);
|
||||||
|
void onStreamDelta(const QString &text);
|
||||||
|
void onStreamDone(const QVariantMap &session);
|
||||||
|
void onStreamEnded();
|
||||||
|
void onStreamError(const QString &error);
|
||||||
|
|
||||||
|
ApiClient *m_api;
|
||||||
|
QList<Msg> m_msgs;
|
||||||
|
QString m_sessionId;
|
||||||
|
QString m_title;
|
||||||
|
bool m_busy;
|
||||||
|
bool m_finalizedThisRun;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // CHATMODEL_H
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#include "cookiejar.h"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QNetworkCookie>
|
||||||
|
|
||||||
|
CookieJar::CookieJar(QObject *parent)
|
||||||
|
: QNetworkCookieJar(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void CookieJar::setFilePath(const QString &path)
|
||||||
|
{
|
||||||
|
m_path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString CookieJar::filePath() const
|
||||||
|
{
|
||||||
|
return m_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CookieJar::load()
|
||||||
|
{
|
||||||
|
if (m_path.isEmpty())
|
||||||
|
return false;
|
||||||
|
QFile f(m_path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly))
|
||||||
|
return false;
|
||||||
|
const QByteArray data = f.readAll();
|
||||||
|
f.close();
|
||||||
|
const QList<QNetworkCookie> cookies = QNetworkCookie::parseCookies(data);
|
||||||
|
if (cookies.isEmpty())
|
||||||
|
return false;
|
||||||
|
setAllCookies(cookies);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CookieJar::save() const
|
||||||
|
{
|
||||||
|
if (m_path.isEmpty())
|
||||||
|
return false;
|
||||||
|
const QFileInfo info(m_path);
|
||||||
|
QDir().mkpath(info.absolutePath());
|
||||||
|
QFile f(m_path);
|
||||||
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||||
|
return false;
|
||||||
|
// Permessi solo proprietario PRIMA di scrivere il contenuto.
|
||||||
|
f.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
||||||
|
const QList<QNetworkCookie> cookies = allCookies();
|
||||||
|
for (int i = 0; i < cookies.size(); ++i) {
|
||||||
|
f.write(cookies.at(i).toRawForm(QNetworkCookie::Full));
|
||||||
|
f.write("\n");
|
||||||
|
}
|
||||||
|
f.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CookieJar::clear()
|
||||||
|
{
|
||||||
|
setAllCookies(QList<QNetworkCookie>());
|
||||||
|
if (!m_path.isEmpty())
|
||||||
|
QFile::remove(m_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CookieJar::setCookiesFromUrl(const QList<QNetworkCookie> &cookieList, const QUrl &url)
|
||||||
|
{
|
||||||
|
const bool changed = QNetworkCookieJar::setCookiesFromUrl(cookieList, url);
|
||||||
|
if (changed)
|
||||||
|
save();
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#ifndef COOKIEJAR_H
|
||||||
|
#define COOKIEJAR_H
|
||||||
|
|
||||||
|
#include <QNetworkCookie>
|
||||||
|
#include <QNetworkCookieJar>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
// Cookie jar persistente su file (0600).
|
||||||
|
// Il cookie di sessione del WebUI di Hermes e' una credenziale: vive solo in
|
||||||
|
// ~/.config/harbour/hermes/cookies.dat con permessi del solo proprietario.
|
||||||
|
// Il salvataggio avviene automaticamente a ogni inserimento di cookie.
|
||||||
|
class CookieJar : public QNetworkCookieJar
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit CookieJar(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
void setFilePath(const QString &path);
|
||||||
|
QString filePath() const;
|
||||||
|
|
||||||
|
bool load();
|
||||||
|
bool save() const;
|
||||||
|
void clear();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool setCookiesFromUrl(const QList<QNetworkCookie> &cookieList, const QUrl &url) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_path;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // COOKIEJAR_H
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QQmlContext>
|
||||||
|
#include <QQuickView>
|
||||||
|
#include <QDebug>
|
||||||
|
#include <sailfishapp.h>
|
||||||
|
|
||||||
|
#include "apiclient.h"
|
||||||
|
#include "chatmodel.h"
|
||||||
|
#include "player.h"
|
||||||
|
#include "recorder.h"
|
||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
Q_DECL_EXPORT int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
QGuiApplication *app = SailfishApp::application(argc, argv);
|
||||||
|
|
||||||
|
// Identita' allineata al profilo X-Sailjail del desktop file:
|
||||||
|
// AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox).
|
||||||
|
app->setOrganizationName(QStringLiteral("harbour"));
|
||||||
|
app->setApplicationName(QStringLiteral("hermes"));
|
||||||
|
app->setApplicationVersion(QStringLiteral("0.1.0"));
|
||||||
|
|
||||||
|
qDebug() << "harbour-hermes v0.1.0 build" << __DATE__ << __TIME__;
|
||||||
|
|
||||||
|
Settings settings;
|
||||||
|
ApiClient api(&settings);
|
||||||
|
ChatModel chat(&api);
|
||||||
|
Recorder recorder;
|
||||||
|
Player player;
|
||||||
|
|
||||||
|
// Flusso voce: registrazione -> trascrizione; TTS pronto -> riproduzione.
|
||||||
|
QObject::connect(&recorder, &Recorder::finished, &api, &ApiClient::transcribeFile);
|
||||||
|
QObject::connect(&api, &ApiClient::ttsReady, &player, &Player::play);
|
||||||
|
|
||||||
|
QQuickView *view = SailfishApp::createView();
|
||||||
|
view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings);
|
||||||
|
view->rootContext()->setContextProperty(QStringLiteral("api"), &api);
|
||||||
|
view->rootContext()->setContextProperty(QStringLiteral("chat"), &chat);
|
||||||
|
view->rootContext()->setContextProperty(QStringLiteral("recorder"), &recorder);
|
||||||
|
view->rootContext()->setContextProperty(QStringLiteral("player"), &player);
|
||||||
|
view->rootContext()->setContextProperty(QStringLiteral("appVersion"),
|
||||||
|
QCoreApplication::applicationVersion());
|
||||||
|
|
||||||
|
view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-hermes.qml")));
|
||||||
|
view->show();
|
||||||
|
|
||||||
|
return app->exec();
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#include "player.h"
|
||||||
|
|
||||||
|
#include <QMediaContent>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
Player::Player(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
connect(&m_player, &QMediaPlayer::stateChanged, this,
|
||||||
|
[this](QMediaPlayer::State) { emit playingChanged(); });
|
||||||
|
connect(&m_player, &QMediaPlayer::mediaStatusChanged, this,
|
||||||
|
[this](QMediaPlayer::MediaStatus status) {
|
||||||
|
if (status == QMediaPlayer::EndOfMedia
|
||||||
|
|| status == QMediaPlayer::InvalidMedia) {
|
||||||
|
emit playingChanged();
|
||||||
|
emit finished();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Player::playing() const
|
||||||
|
{
|
||||||
|
return m_player.state() == QMediaPlayer::PlayingState;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Player::play(const QString &path)
|
||||||
|
{
|
||||||
|
stop();
|
||||||
|
m_player.setMedia(QMediaContent(QUrl::fromLocalFile(path)));
|
||||||
|
m_player.play();
|
||||||
|
emit playingChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Player::stop()
|
||||||
|
{
|
||||||
|
if (m_player.state() != QMediaPlayer::StoppedState)
|
||||||
|
m_player.stop();
|
||||||
|
emit playingChanged();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#ifndef PLAYER_H
|
||||||
|
#define PLAYER_H
|
||||||
|
|
||||||
|
#include <QMediaPlayer>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
// Riproduttore audio per le risposte TTS (QMediaPlayer, mp3 dal server).
|
||||||
|
// L'output passa da PulseAudio; il volume segue il sistema.
|
||||||
|
class Player : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(bool playing READ playing NOTIFY playingChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit Player(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
bool playing() const;
|
||||||
|
|
||||||
|
Q_INVOKABLE void play(const QString &path);
|
||||||
|
Q_INVOKABLE void stop();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void playingChanged();
|
||||||
|
void finished();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QMediaPlayer m_player;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // PLAYER_H
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#include "recorder.h"
|
||||||
|
|
||||||
|
#include <QAudioEncoderSettings>
|
||||||
|
#include <QAudioRecorder>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QVideoEncoderSettings>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Container preferito: OGG; ripiego WAV.
|
||||||
|
QString chooseContainer(const QStringList &containers)
|
||||||
|
{
|
||||||
|
if (containers.contains(QStringLiteral("audio/ogg")))
|
||||||
|
return QStringLiteral("audio/ogg");
|
||||||
|
if (containers.contains(QStringLiteral("audio/x-wav")))
|
||||||
|
return QStringLiteral("audio/x-wav");
|
||||||
|
return containers.isEmpty() ? QString() : containers.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codec preferito: Opus, poi Vorbis, poi PCM.
|
||||||
|
QString chooseCodec(const QStringList &codecs)
|
||||||
|
{
|
||||||
|
if (codecs.contains(QStringLiteral("audio/opus")))
|
||||||
|
return QStringLiteral("audio/opus");
|
||||||
|
if (codecs.contains(QStringLiteral("audio/vorbis")))
|
||||||
|
return QStringLiteral("audio/vorbis");
|
||||||
|
if (codecs.contains(QStringLiteral("audio/pcm")))
|
||||||
|
return QStringLiteral("audio/pcm");
|
||||||
|
return codecs.isEmpty() ? QString() : codecs.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Recorder::Recorder(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_rec(new QAudioRecorder(this))
|
||||||
|
, m_stopping(false)
|
||||||
|
, m_fallbackTried(false)
|
||||||
|
{
|
||||||
|
connect(m_rec,
|
||||||
|
static_cast<void (QAudioRecorder::*)(QMediaRecorder::State)>(&QAudioRecorder::stateChanged),
|
||||||
|
this, [this](QMediaRecorder::State) { onStateChanged(); });
|
||||||
|
connect(m_rec,
|
||||||
|
static_cast<void (QAudioRecorder::*)(QMediaRecorder::Error)>(&QAudioRecorder::error),
|
||||||
|
this, [this](QMediaRecorder::Error) { onError(); });
|
||||||
|
|
||||||
|
m_tick.setInterval(250);
|
||||||
|
connect(&m_tick, &QTimer::timeout, this, [this]() { emit durationChanged(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Recorder::recording() const
|
||||||
|
{
|
||||||
|
return m_rec->state() == QMediaRecorder::RecordingState;
|
||||||
|
}
|
||||||
|
|
||||||
|
int Recorder::durationMs() const
|
||||||
|
{
|
||||||
|
return static_cast<int>(m_rec->duration());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Recorder::start()
|
||||||
|
{
|
||||||
|
if (recording())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const QString container = chooseContainer(m_rec->supportedContainers());
|
||||||
|
const QString codec = chooseCodec(m_rec->supportedAudioCodecs());
|
||||||
|
if (container.isEmpty() || codec.isEmpty()) {
|
||||||
|
emit failed(QStringLiteral("No audio format available"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_fallbackTried = false;
|
||||||
|
return beginRecording(container, codec);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Recorder::stop()
|
||||||
|
{
|
||||||
|
if (!recording())
|
||||||
|
return;
|
||||||
|
m_stopping = true;
|
||||||
|
m_rec->stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Recorder::beginRecording(const QString &container, const QString &codec)
|
||||||
|
{
|
||||||
|
const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||||
|
const QString dir = base + QStringLiteral("/rec");
|
||||||
|
QDir().mkpath(dir);
|
||||||
|
const QString ext = container == QLatin1String("audio/x-wav") ? QStringLiteral(".wav")
|
||||||
|
: QStringLiteral(".ogg");
|
||||||
|
m_path = dir + QStringLiteral("/rec-%1%2")
|
||||||
|
.arg(QDateTime::currentMSecsSinceEpoch())
|
||||||
|
.arg(ext);
|
||||||
|
m_container = container;
|
||||||
|
|
||||||
|
QAudioEncoderSettings enc;
|
||||||
|
enc.setCodec(codec);
|
||||||
|
enc.setSampleRate(16000);
|
||||||
|
enc.setChannelCount(1);
|
||||||
|
enc.setQuality(QMultimedia::NormalQuality);
|
||||||
|
m_rec->setEncodingSettings(enc, QVideoEncoderSettings(), container);
|
||||||
|
m_rec->setOutputLocation(QUrl::fromLocalFile(m_path));
|
||||||
|
|
||||||
|
m_stopping = false;
|
||||||
|
m_rec->record();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Recorder::onStateChanged()
|
||||||
|
{
|
||||||
|
const bool rec = recording();
|
||||||
|
if (rec)
|
||||||
|
m_tick.start();
|
||||||
|
else
|
||||||
|
m_tick.stop();
|
||||||
|
emit recordingChanged();
|
||||||
|
emit durationChanged();
|
||||||
|
|
||||||
|
if (!rec && m_stopping) {
|
||||||
|
m_stopping = false;
|
||||||
|
if (m_path.isEmpty())
|
||||||
|
return;
|
||||||
|
emit finished(m_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Recorder::onError()
|
||||||
|
{
|
||||||
|
// Primo tentativo fallito (es. codec non disponibile nel target):
|
||||||
|
// un solo ritentativo con WAV/PCM, poi si arrende.
|
||||||
|
if (!m_fallbackTried && !m_path.isEmpty()) {
|
||||||
|
m_fallbackTried = true;
|
||||||
|
m_rec->stop();
|
||||||
|
if (beginRecording(QStringLiteral("audio/x-wav"), QStringLiteral("audio/pcm")))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_tick.stop();
|
||||||
|
emit recordingChanged();
|
||||||
|
emit failed(m_rec->errorString());
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#ifndef RECORDER_H
|
||||||
|
#define RECORDER_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
class QAudioRecorder;
|
||||||
|
|
||||||
|
// Registratore vocale (QAudioRecorder) con fallback di formato:
|
||||||
|
// container/codec preferiti OGG/Opus, poi OGG/Vorbis, poi WAV/PCM.
|
||||||
|
// Se il primo tentativo fallisce a runtime (codec assente nel target),
|
||||||
|
// riprova una volta con WAV/PCM.
|
||||||
|
// I file finiscono in <AppData>/rec/ (zona dati della sandbox SailJail).
|
||||||
|
// Il permesso Microphone e' dichiarato nel profilo X-Sailjail del desktop file.
|
||||||
|
class Recorder : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(bool recording READ recording NOTIFY recordingChanged)
|
||||||
|
Q_PROPERTY(int durationMs READ durationMs NOTIFY durationChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit Recorder(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
bool recording() const;
|
||||||
|
int durationMs() const;
|
||||||
|
|
||||||
|
Q_INVOKABLE bool start();
|
||||||
|
Q_INVOKABLE void stop();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void recordingChanged();
|
||||||
|
void durationChanged();
|
||||||
|
void finished(const QString &path); // file pronto per la trascrizione
|
||||||
|
void failed(const QString &error);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void onStateChanged();
|
||||||
|
void onError();
|
||||||
|
bool beginRecording(const QString &container, const QString &codec);
|
||||||
|
|
||||||
|
QAudioRecorder *m_rec;
|
||||||
|
QTimer m_tick;
|
||||||
|
QString m_path;
|
||||||
|
QString m_container;
|
||||||
|
bool m_stopping;
|
||||||
|
bool m_fallbackTried;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // RECORDER_H
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
// Default: l'istanza pubblica del WebUI usa questo host (HTTPS, cert valido).
|
||||||
|
// L'utente puo' cambiarlo dalle impostazioni (LAN, VPN, altro dominio).
|
||||||
|
static const char *kDefaultBaseUrl = "https://hermes.hackatoniclife.com";
|
||||||
|
static const char *kDefaultTtsVoice = "it-IT-ElsaNeural";
|
||||||
|
static const char *kDefaultTtsEngine = "edge";
|
||||||
|
|
||||||
|
Settings::Settings(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Settings::baseUrl() const
|
||||||
|
{
|
||||||
|
QString v = m_settings.value(QStringLiteral("baseUrl"), QString::fromLatin1(kDefaultBaseUrl)).toString();
|
||||||
|
// Normalizza: niente slash finale (componiamo gli URL con path assoluti).
|
||||||
|
while (v.endsWith(QLatin1Char('/')))
|
||||||
|
v.chop(1);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::setBaseUrl(const QString &url)
|
||||||
|
{
|
||||||
|
QString v = url.trimmed();
|
||||||
|
while (v.endsWith(QLatin1Char('/')))
|
||||||
|
v.chop(1);
|
||||||
|
if (v == baseUrl())
|
||||||
|
return;
|
||||||
|
m_settings.setValue(QStringLiteral("baseUrl"), v);
|
||||||
|
emit baseUrlChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Settings::ttsVoice() const
|
||||||
|
{
|
||||||
|
return m_settings.value(QStringLiteral("ttsVoice"), QString::fromLatin1(kDefaultTtsVoice)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::setTtsVoice(const QString &voice)
|
||||||
|
{
|
||||||
|
if (voice == ttsVoice())
|
||||||
|
return;
|
||||||
|
m_settings.setValue(QStringLiteral("ttsVoice"), voice);
|
||||||
|
emit ttsVoiceChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Settings::ttsEngine() const
|
||||||
|
{
|
||||||
|
return m_settings.value(QStringLiteral("ttsEngine"), QString::fromLatin1(kDefaultTtsEngine)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::setTtsEngine(const QString &engine)
|
||||||
|
{
|
||||||
|
if (engine == ttsEngine())
|
||||||
|
return;
|
||||||
|
m_settings.setValue(QStringLiteral("ttsEngine"), engine);
|
||||||
|
emit ttsEngineChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Settings::ttsAutoRead() const
|
||||||
|
{
|
||||||
|
return m_settings.value(QStringLiteral("ttsAutoRead"), false).toBool();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::setTtsAutoRead(bool on)
|
||||||
|
{
|
||||||
|
if (on == ttsAutoRead())
|
||||||
|
return;
|
||||||
|
m_settings.setValue(QStringLiteral("ttsAutoRead"), on);
|
||||||
|
emit ttsAutoReadChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Settings::defaultWorkspace() const
|
||||||
|
{
|
||||||
|
return m_settings.value(QStringLiteral("defaultWorkspace"), QString()).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::setDefaultWorkspace(const QString &ws)
|
||||||
|
{
|
||||||
|
if (ws == defaultWorkspace())
|
||||||
|
return;
|
||||||
|
m_settings.setValue(QStringLiteral("defaultWorkspace"), ws);
|
||||||
|
emit defaultWorkspaceChanged();
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#ifndef SETTINGS_H
|
||||||
|
#define SETTINGS_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
// Impostazioni persistenti dell'app.
|
||||||
|
// QSettings con OrganizationName "harbour" / ApplicationName "hermes" (impostati
|
||||||
|
// in main.cpp, allineati al profilo X-Sailjail del desktop file): file in
|
||||||
|
// ~/.config/harbour/hermes.conf (zona persistente della sandbox).
|
||||||
|
//
|
||||||
|
// La password NON viene mai salvata: l'autenticazione si basa sul cookie di
|
||||||
|
// sessione persistito in modo protetto (vedi CookieJar).
|
||||||
|
class Settings : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
|
||||||
|
Q_PROPERTY(QString ttsVoice READ ttsVoice WRITE setTtsVoice NOTIFY ttsVoiceChanged)
|
||||||
|
Q_PROPERTY(QString ttsEngine READ ttsEngine WRITE setTtsEngine NOTIFY ttsEngineChanged)
|
||||||
|
Q_PROPERTY(bool ttsAutoRead READ ttsAutoRead WRITE setTtsAutoRead NOTIFY ttsAutoReadChanged)
|
||||||
|
Q_PROPERTY(QString defaultWorkspace READ defaultWorkspace WRITE setDefaultWorkspace
|
||||||
|
NOTIFY defaultWorkspaceChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit Settings(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString baseUrl() const;
|
||||||
|
void setBaseUrl(const QString &url);
|
||||||
|
|
||||||
|
QString ttsVoice() const;
|
||||||
|
void setTtsVoice(const QString &voice);
|
||||||
|
|
||||||
|
QString ttsEngine() const;
|
||||||
|
void setTtsEngine(const QString &engine);
|
||||||
|
|
||||||
|
bool ttsAutoRead() const;
|
||||||
|
void setTtsAutoRead(bool on);
|
||||||
|
|
||||||
|
QString defaultWorkspace() const;
|
||||||
|
void setDefaultWorkspace(const QString &ws);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void baseUrlChanged();
|
||||||
|
void ttsVoiceChanged();
|
||||||
|
void ttsEngineChanged();
|
||||||
|
void ttsAutoReadChanged();
|
||||||
|
void defaultWorkspaceChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QSettings m_settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // SETTINGS_H
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#include "sseparser.h"
|
||||||
|
|
||||||
|
SseParser::SseParser()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<SseParser::Event> SseParser::feed(const QByteArray &chunk)
|
||||||
|
{
|
||||||
|
m_buffer += chunk;
|
||||||
|
// Normalizza le terminazioni di riga (il server usa \n, ma per sicurezza).
|
||||||
|
if (m_buffer.contains('\r'))
|
||||||
|
m_buffer.replace("\r\n", "\n");
|
||||||
|
|
||||||
|
QList<Event> events;
|
||||||
|
int idx = -1;
|
||||||
|
while ((idx = m_buffer.indexOf("\n\n")) >= 0) {
|
||||||
|
const QByteArray block = m_buffer.left(idx);
|
||||||
|
m_buffer.remove(0, idx + 2);
|
||||||
|
|
||||||
|
Event ev;
|
||||||
|
bool hasData = false;
|
||||||
|
const QList<QByteArray> lines = block.split('\n');
|
||||||
|
for (int i = 0; i < lines.size(); ++i) {
|
||||||
|
const QByteArray &line = lines.at(i);
|
||||||
|
if (line.isEmpty() || line.startsWith(':'))
|
||||||
|
continue; // riga vuota o commento/keepalive
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
ev.name = QString::fromUtf8(line.mid(6)).trimmed();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
QString d = QString::fromUtf8(line.mid(5));
|
||||||
|
if (d.startsWith(QLatin1Char(' ')))
|
||||||
|
d.remove(0, 1);
|
||||||
|
if (hasData)
|
||||||
|
ev.data += QLatin1Char('\n');
|
||||||
|
ev.data += d;
|
||||||
|
hasData = true;
|
||||||
|
} else if (line.startsWith("id:")) {
|
||||||
|
ev.id = QString::fromUtf8(line.mid(3)).trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Blocco di soli commenti/keepalive: niente da consegnare.
|
||||||
|
if (!hasData)
|
||||||
|
continue;
|
||||||
|
if (ev.name.isEmpty())
|
||||||
|
ev.name = QStringLiteral("message");
|
||||||
|
events.append(ev);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SseParser::reset()
|
||||||
|
{
|
||||||
|
m_buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray SseParser::pending() const
|
||||||
|
{
|
||||||
|
return m_buffer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#ifndef SSEPARSER_H
|
||||||
|
#define SSEPARSER_H
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
// Parser incrementale di Server-Sent Events (text/event-stream).
|
||||||
|
//
|
||||||
|
// Il WebUI di Hermes emette eventi nella forma:
|
||||||
|
// id: <stream_id>:<seq>
|
||||||
|
// event: token
|
||||||
|
// data: {"text": "..."}
|
||||||
|
// <riga vuota>
|
||||||
|
// piu' commenti di keepalive (": heartbeat") da ignorare.
|
||||||
|
//
|
||||||
|
// Logica pura, senza dipendenze da rete: alimentato con i chunk grezzi,
|
||||||
|
// restituisce gli eventi completi. Testabile in locale (tests/sse_test.cpp).
|
||||||
|
class SseParser
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
struct Event {
|
||||||
|
QString name; // campo "event:" (vuoto => "message" SSE standard)
|
||||||
|
QString data; // campi "data:" concatenati con "\n"
|
||||||
|
QString id; // campo "id:" (cursore per replay)
|
||||||
|
};
|
||||||
|
|
||||||
|
SseParser();
|
||||||
|
|
||||||
|
// Aggiunge un chunk e restituisce tutti gli eventi completati.
|
||||||
|
QList<Event> feed(const QByteArray &chunk);
|
||||||
|
|
||||||
|
void reset();
|
||||||
|
QByteArray pending() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QByteArray m_buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // SSEPARSER_H
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Test locale del core di harbour-hermes (compilato con Qt desktop).
|
||||||
|
//
|
||||||
|
// Uso:
|
||||||
|
// ./core-test -> solo unit test del parser SSE
|
||||||
|
// ./core-test --live -> integrazione completa contro l'istanza di prova
|
||||||
|
// del WebUI su http://127.0.0.1:8899
|
||||||
|
// (login -> nuova sessione -> turno -> TTS -> STT)
|
||||||
|
//
|
||||||
|
// Il test live legge la password di prova da /tmp/hermes-probe/pw e usa
|
||||||
|
// l'audio di prova /tmp/hermes-probe/out/prova.ogg.
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
#include "apiclient.h"
|
||||||
|
#include "chatmodel.h"
|
||||||
|
#include "settings.h"
|
||||||
|
#include "sseparser.h"
|
||||||
|
|
||||||
|
static int failures = 0;
|
||||||
|
|
||||||
|
#define CHECK(cond, msg) \
|
||||||
|
do { \
|
||||||
|
if (!(cond)) { \
|
||||||
|
qWarning() << "FAIL:" << msg; \
|
||||||
|
++failures; \
|
||||||
|
} else { \
|
||||||
|
qDebug() << "ok :" << msg; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static void testParser()
|
||||||
|
{
|
||||||
|
qDebug() << "== unit: parser SSE ==";
|
||||||
|
const QByteArray sample =
|
||||||
|
"id: s1:1\n"
|
||||||
|
"event: context_status\n"
|
||||||
|
"data: {\"a\": 1}\n"
|
||||||
|
"\n"
|
||||||
|
": heartbeat\n"
|
||||||
|
"\n"
|
||||||
|
"id: s1:2\n"
|
||||||
|
"event: token\n"
|
||||||
|
"data: {\"text\": \"p\"}\n"
|
||||||
|
"\n";
|
||||||
|
SseParser p;
|
||||||
|
const QList<SseParser::Event> evs = p.feed(sample);
|
||||||
|
CHECK(evs.size() == 2, "due eventi, keepalive ignorato");
|
||||||
|
if (evs.size() == 2) {
|
||||||
|
CHECK(evs.at(0).name == QLatin1String("context_status"), "primo evento context_status");
|
||||||
|
CHECK(evs.at(0).id == QLatin1String("s1:1"), "id del primo evento");
|
||||||
|
CHECK(evs.at(1).name == QLatin1String("token"), "secondo evento token");
|
||||||
|
CHECK(evs.at(1).data == QLatin1String("{\"text\": \"p\"}"), "data del token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk spezzati a meta' evento (caso reale della rete).
|
||||||
|
SseParser p2;
|
||||||
|
CHECK(p2.feed("event: token\ndata: {\"te").isEmpty(), "chunk parziale: nessun evento");
|
||||||
|
const QList<SseParser::Event> evs2 = p2.feed("xt\": \"ok\"}\n\n");
|
||||||
|
CHECK(evs2.size() == 1, "evento completato dal secondo chunk");
|
||||||
|
if (!evs2.isEmpty())
|
||||||
|
CHECK(evs2.at(0).data.contains(QLatin1String("ok")), "payload ricomposto");
|
||||||
|
|
||||||
|
// Evento con data su piu' righe.
|
||||||
|
SseParser p3;
|
||||||
|
const QList<SseParser::Event> evs3 = p3.feed("event: x\ndata: a\ndata: b\n\n");
|
||||||
|
CHECK(evs3.size() == 1 && evs3.at(0).data == QLatin1String("a\nb"),
|
||||||
|
"data multi-riga unito con newline");
|
||||||
|
}
|
||||||
|
|
||||||
|
static QString readFileTrim(const QString &path)
|
||||||
|
{
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly))
|
||||||
|
return QString();
|
||||||
|
return QString::fromUtf8(f.readAll()).trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
QCoreApplication app(argc, argv);
|
||||||
|
app.setOrganizationName(QStringLiteral("harbour-test"));
|
||||||
|
app.setApplicationName(QStringLiteral("hermes-test"));
|
||||||
|
|
||||||
|
testParser();
|
||||||
|
|
||||||
|
if (!app.arguments().contains(QStringLiteral("--live"))) {
|
||||||
|
qDebug() << (failures == 0 ? "UNIT: tutti i test passati" : "UNIT: FALLIMENTI");
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "== integrazione live (probe :8899) ==";
|
||||||
|
|
||||||
|
QTimer::singleShot(180000, &app, []() {
|
||||||
|
qWarning() << "TIMEOUT globale del test live";
|
||||||
|
qApp->exit(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
Settings settings;
|
||||||
|
settings.setBaseUrl(QStringLiteral("http://127.0.0.1:8899"));
|
||||||
|
ApiClient api(&settings);
|
||||||
|
ChatModel chat(&api);
|
||||||
|
|
||||||
|
const QString pw = readFileTrim(QStringLiteral("/tmp/hermes-probe/pw"));
|
||||||
|
if (pw.isEmpty()) {
|
||||||
|
qWarning() << "password del probe mancante in /tmp/hermes-probe/pw";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool chatSent = false;
|
||||||
|
bool chatDone = false;
|
||||||
|
bool ttsDone = false;
|
||||||
|
bool sttDone = false;
|
||||||
|
|
||||||
|
QObject::connect(&api, &ApiClient::loggedInChanged, [&]() {
|
||||||
|
if (api.loggedIn()) {
|
||||||
|
qDebug() << "LIVE: login ok";
|
||||||
|
chat.newSession();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
QObject::connect(&api, &ApiClient::sessionOpened, [&]() {
|
||||||
|
if (chatSent)
|
||||||
|
return;
|
||||||
|
chatSent = true;
|
||||||
|
qDebug() << "LIVE: sessione" << chat.sessionId();
|
||||||
|
chat.send(QStringLiteral("Rispondi solo con la parola pong. Non usare strumenti."));
|
||||||
|
});
|
||||||
|
QObject::connect(&chat, &ChatModel::assistantFinished, [&]() {
|
||||||
|
qDebug() << "LIVE: risposta =" << chat.lastAssistantText();
|
||||||
|
chatDone = chat.lastAssistantText().contains(QLatin1String("pong"));
|
||||||
|
api.speak(QStringLiteral("Questa e' la voce italiana del client Hermes per Sailfish."));
|
||||||
|
});
|
||||||
|
QObject::connect(&api, &ApiClient::ttsReady, [&](const QString &path) {
|
||||||
|
QFile f(path);
|
||||||
|
ttsDone = f.size() > 1000;
|
||||||
|
qDebug() << "LIVE: ttsReady" << path << "bytes:" << f.size();
|
||||||
|
api.transcribeFile(QStringLiteral("/tmp/hermes-probe/out/prova.ogg"));
|
||||||
|
});
|
||||||
|
QObject::connect(&api, &ApiClient::ttsFailed, [&](const QString &err) {
|
||||||
|
qWarning() << "LIVE: ttsFailed" << err;
|
||||||
|
api.transcribeFile(QStringLiteral("/tmp/hermes-probe/out/prova.ogg"));
|
||||||
|
});
|
||||||
|
QObject::connect(&api, &ApiClient::transcriptionReady, [&](const QString &text) {
|
||||||
|
qDebug() << "LIVE: trascrizione =" << text;
|
||||||
|
sttDone = !text.isEmpty();
|
||||||
|
QTimer::singleShot(200, qApp, [&]() {
|
||||||
|
const bool pass = chatDone && ttsDone && sttDone;
|
||||||
|
qDebug() << (pass ? "LIVE: TUTTI I PASSAGGI OK" : "LIVE: qualche passaggio e' fallito");
|
||||||
|
qApp->exit(pass ? 0 : 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
QObject::connect(&api, &ApiClient::transcriptionFailed, [&](const QString &err) {
|
||||||
|
qWarning() << "LIVE: transcriptionFailed" << err;
|
||||||
|
qApp->exit(1);
|
||||||
|
});
|
||||||
|
QObject::connect(&api, &ApiClient::chatStartFailed, [&](const QString &err) {
|
||||||
|
qWarning() << "LIVE: chatStartFailed" << err;
|
||||||
|
qApp->exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
api.login(pw);
|
||||||
|
qDebug() << "LIVE: login inviato";
|
||||||
|
return app.exec();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
TEMPLATE = app
|
||||||
|
TARGET = core-test
|
||||||
|
CONFIG += console c++11
|
||||||
|
QT += core network multimedia
|
||||||
|
|
||||||
|
# Workaround per la build locale su desktop senza libGL.so di sviluppo:
|
||||||
|
# se esiste lo stub dir con il symlink a libGL.so.1, usalo.
|
||||||
|
GLSTUB = /tmp/hh-test/gl
|
||||||
|
exists($$GLSTUB/libGL.so) {
|
||||||
|
LIBS += -L$$GLSTUB
|
||||||
|
}
|
||||||
|
|
||||||
|
SRC = $$PWD/../src
|
||||||
|
INCLUDEPATH += $$SRC
|
||||||
|
|
||||||
|
SOURCES += \
|
||||||
|
$$SRC/sseparser.cpp \
|
||||||
|
$$SRC/cookiejar.cpp \
|
||||||
|
$$SRC/settings.cpp \
|
||||||
|
$$SRC/apiclient.cpp \
|
||||||
|
$$SRC/chatmodel.cpp \
|
||||||
|
$$SRC/recorder.cpp \
|
||||||
|
$$SRC/player.cpp \
|
||||||
|
core_test.cpp
|
||||||
|
|
||||||
|
HEADERS += \
|
||||||
|
$$SRC/sseparser.h \
|
||||||
|
$$SRC/cookiejar.h \
|
||||||
|
$$SRC/settings.h \
|
||||||
|
$$SRC/apiclient.h \
|
||||||
|
$$SRC/chatmodel.h \
|
||||||
|
$$SRC/recorder.h \
|
||||||
|
$$SRC/player.h
|
||||||
Reference in New Issue
Block a user