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

- ApiClient HTTP/SSE: login, sessioni, chat/start, stream SSE, transcribe, tts
- ChatModel: streaming live + risincronizzazione dallo stato del server
- CookieJar persistente (0600, mai la password); Settings
- Recorder OGG/Opus (fallback WAV/PCM, retry); Player TTS (QMediaPlayer)
- UI Silica: splash, login, sessioni, chat (dettatura + read-aloud), settings, cover
- Sailjail Internet;Audio;Microphone; icone; spec RPM; docs PROTOCOL/BUILD
- tests/core_test: unit parser SSE + integrazione live (probe :8899)
This commit is contained in:
2026-09-12 07:11:20 +02:00
commit 9c6a1242f7
36 changed files with 2954 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
// Conversazione con Hermes: bolle, streaming live, composer con dettatura.
Page {
id: page
property string sessionId
property string noticeText: ""
// Altezza tastiera: alza il composer quando la tastiera e' aperta.
property real kbHeight: Qt.inputMethod.visible
? Math.max(0, page.height - Qt.inputMethod.keyboardRectangle.y)
: 0
allowedOrientations: Orientation.Portrait
SilicaListView {
id: list
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: composer.top
model: chat
spacing: Theme.paddingSmall
clip: true
header: Item {
width: list.width
height: headerCol.height
Column {
id: headerCol
width: parent.width
PageHeader {
title: chat.title.length > 0 ? chat.title : qsTr("Chat")
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
font.pixelSize: Theme.fontSizeExtraSmall
color: Theme.secondaryHighlightColor
visible: chat.busy || player.playing
text: chat.busy ? qsTr("Hermes is writing…")
: qsTr("Speaking…")
}
}
}
PullDownMenu {
MenuItem {
text: qsTr("Read last reply aloud")
onClicked: page.readLast()
}
MenuItem {
text: appSettings.ttsAutoRead ? qsTr("Auto-read: on") : qsTr("Auto-read: off")
onClicked: appSettings.ttsAutoRead = !appSettings.ttsAutoRead
}
MenuItem {
text: qsTr("Stop playback")
visible: player.playing
onClicked: player.stop()
}
MenuItem {
text: qsTr("Cancel run")
visible: chat.busy
onClicked: chat.cancel()
}
MenuItem {
text: qsTr("Resync")
onClicked: chat.resync()
}
}
delegate: Item {
id: row
width: list.width
height: bubble.height + Theme.paddingMedium
Rectangle {
id: bubble
property real maxW: list.width - 2 * Theme.horizontalPageMargin
width: Math.min(lbl.implicitWidth + 2 * Theme.paddingMedium, maxW)
height: lbl.height + 2 * Theme.paddingSmall
radius: Theme.paddingSmall
x: model.role === "user"
? list.width - width - Theme.horizontalPageMargin
: Theme.horizontalPageMargin
color: model.role === "user"
? Theme.rgba(Theme.highlightBackgroundColor, 0.35)
: Theme.rgba(Theme.secondaryHighlightColor, 0.18)
Label {
id: lbl
x: Theme.paddingMedium
y: Theme.paddingSmall
width: Math.min(implicitWidth, bubble.maxW - 2 * Theme.paddingMedium)
wrapMode: Text.Wrap
color: Theme.primaryColor
text: model.content + (model.streaming ? " ▍" : "")
}
}
}
}
Rectangle {
id: composer
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.bottomMargin: page.kbHeight
height: compCol.height + Theme.paddingMedium
color: Theme.highlightDimmerColor
Column {
id: compCol
width: parent.width
spacing: Theme.paddingSmall / 2
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
font.pixelSize: Theme.fontSizeExtraSmall
color: recorder.recording ? Theme.highlightColor
: Theme.secondaryHighlightColor
visible: recorder.recording || api.transcribing || page.noticeText.length > 0
truncationMode: TruncationMode.Fade
text: recorder.recording
? qsTr("Recording… %1 — tap Stop to transcribe").arg(page.formatMs(recorder.durationMs))
: api.transcribing
? qsTr("Transcribing…")
: page.noticeText
}
Row {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
spacing: Theme.paddingSmall
Button {
id: micBtn
text: recorder.recording ? qsTr("Stop") : qsTr("Mic")
highlighted: recorder.recording
enabled: !api.transcribing && !chat.busy || recorder.recording
onClicked: {
if (recorder.recording)
recorder.stop()
else {
player.stop()
recorder.start()
}
}
}
TextField {
id: input
width: parent.width - micBtn.width - sendBtn.width - 2 * parent.spacing
placeholderText: qsTr("Message…")
onAccepted: page.doSend()
}
Button {
id: sendBtn
text: qsTr("Send")
enabled: input.text.length > 0 && !chat.busy
onClicked: page.doSend()
}
}
}
}
Timer {
id: scrollDebounce
interval: 150
onTriggered: list.positionViewAtEnd()
}
Timer {
id: noticeClear
interval: 5000
onTriggered: page.noticeText = ""
}
Connections {
target: chat
onRowCountChanged: scrollDebounce.restart()
onStreamTick: scrollDebounce.restart()
onAssistantFinished: {
if (appSettings.ttsAutoRead)
page.readLast()
}
onNotice: page.showNotice(text)
}
Connections {
target: api
onTranscriptionReady: {
input.text = text
input.focus = true
}
onTranscriptionFailed: page.showNotice(error)
onTtsFailed: page.showNotice(error)
}
Component.onCompleted: {
if (sessionId.length > 0)
chat.openSession(sessionId)
}
function doSend() {
var t = input.text
if (t.trim().length === 0)
return
input.text = ""
chat.send(t)
scrollDebounce.restart()
}
function readLast() {
var t = chat.lastAssistantText()
if (t.length === 0) {
page.showNotice(qsTr("No reply to read yet"))
return
}
api.speak(t)
}
function showNotice(text) {
page.noticeText = text
noticeClear.restart()
}
function formatMs(ms) {
var s = Math.floor(ms / 1000)
var m = Math.floor(s / 60)
s = s % 60
return m + ":" + (s < 10 ? "0" + s : s)
}
}
+76
View File
@@ -0,0 +1,76 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
// Login alla Web UI di Hermes: URL del server + password.
// La password non viene salvata: dopo il login resta il cookie di sessione.
Page {
id: page
Column {
anchors.left: parent.left
anchors.right: parent.right
spacing: Theme.paddingLarge
PageHeader { title: qsTr("Sign in") }
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
wrapMode: Text.WordWrap
font.pixelSize: Theme.fontSizeSmall
color: Theme.secondaryColor
text: qsTr("Use the same password you use in the Hermes Web UI in your browser.")
}
TextField {
id: urlField
width: parent.width
label: qsTr("Server")
placeholderText: "https://hermes.example.com"
inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase
}
TextField {
id: passField
width: parent.width
label: qsTr("Password")
echoMode: TextInput.Password
inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText
onAccepted: page.doLogin()
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: api.busy ? qsTr("Signing in…") : qsTr("Sign in")
enabled: !api.busy && passField.text.length > 0
onClicked: page.doLogin()
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
wrapMode: Text.WordWrap
visible: api.lastError.length > 0
color: Theme.errorColor
text: api.lastError
}
}
Connections {
target: api
onLoggedInChanged: {
if (api.loggedIn)
pageStack.replace(Qt.resolvedUrl("SessionsPage.qml"))
}
}
Component.onCompleted: urlField.text = appSettings.baseUrl
function doLogin() {
if (passField.text.length === 0)
return
api.clearError()
appSettings.baseUrl = urlField.text
api.login(passField.text)
}
}
+115
View File
@@ -0,0 +1,115 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
// Lista delle sessioni della Web UI di Hermes (sidebar).
Page {
id: page
// true solo quando e' questa pagina a chiedere una nuova sessione:
// serve a distinguere la creazione dall'apertura di una sessione esistente.
property bool expectNewSession: false
SilicaListView {
id: list
anchors.fill: parent
model: api.sessions
spacing: Theme.paddingSmall
header: PageHeader { title: qsTr("Sessions") }
PullDownMenu {
MenuItem {
text: qsTr("New session")
onClicked: page.createSession()
}
MenuItem {
text: qsTr("Refresh")
onClicked: api.refreshSessions()
}
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
MenuItem {
text: qsTr("Sign out")
onClicked: api.logout()
}
}
delegate: ListItem {
id: item
contentHeight: col.height + Theme.paddingMedium
Column {
id: col
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.paddingSmall / 2
Label {
width: parent.width
truncationMode: TruncationMode.Fade
color: item.highlighted ? Theme.highlightColor : Theme.primaryColor
text: ("" + model.title).length > 0 ? model.title : model.session_id
}
Label {
width: parent.width
font.pixelSize: Theme.fontSizeExtraSmall
color: Theme.secondaryColor
text: page.describe(model)
}
}
onClicked: page.openSession(model.session_id)
}
ViewPlaceholder {
enabled: api.sessions.length === 0 && !api.busy
text: qsTr("No sessions")
hintText: qsTr("Pull down to create a new session")
}
VerticalScrollDecorator {}
}
BusyIndicator {
anchors.centerIn: parent
size: BusyIndicatorSize.Large
running: api.busy
}
Connections {
target: api
onLoggedInChanged: {
if (!api.loggedIn)
pageStack.replace(Qt.resolvedUrl("LoginPage.qml"))
}
onSessionOpened: {
if (page.expectNewSession) {
page.expectNewSession = false
pageStack.push(Qt.resolvedUrl("ChatPage.qml"),
{ sessionId: api.session.session_id })
}
}
}
Component.onCompleted: api.refreshSessions()
function openSession(id) {
expectNewSession = false
pageStack.push(Qt.resolvedUrl("ChatPage.qml"), { sessionId: id })
}
function createSession() {
expectNewSession = true
chat.newSession()
}
function describe(m) {
var n = m.message_count ? m.message_count : 0
var d = new Date((m.updated_at ? m.updated_at : 0) * 1000)
return n + qsTr(" messages") + " · " + Qt.formatDateTime(d, "dd/MM HH:mm")
}
}
+108
View File
@@ -0,0 +1,108 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
property var voiceNames: [
"it-IT-ElsaNeural",
"it-IT-DiegoNeural",
"it-IT-IsabellaNeural",
"it-IT-GiuseppeMultilingualNeural",
"en-US-AriaNeural"
]
property var engineNames: ["edge", "openai", "elevenlabs"]
SilicaFlickable {
anchors.fill: parent
contentHeight: col.height + Theme.paddingLarge
Column {
id: col
width: parent.width
spacing: Theme.paddingMedium
PageHeader { title: qsTr("Settings") }
SectionHeader { text: qsTr("Server") }
TextField {
id: urlField
width: parent.width
label: qsTr("Web UI address")
placeholderText: "https://hermes.example.com"
inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase
onTextChanged: {
if (text !== appSettings.baseUrl)
appSettings.baseUrl = text
}
}
SectionHeader { text: qsTr("Voice") }
ComboBox {
label: qsTr("Read-aloud voice")
currentIndex: Math.max(0, page.voiceNames.indexOf(appSettings.ttsVoice))
menu: ContextMenu {
Repeater {
model: page.voiceNames
MenuItem {
text: modelData
onClicked: appSettings.ttsVoice = modelData
}
}
}
}
ComboBox {
label: qsTr("TTS engine")
currentIndex: Math.max(0, page.engineNames.indexOf(appSettings.ttsEngine))
menu: ContextMenu {
Repeater {
model: page.engineNames
MenuItem {
text: modelData
onClicked: appSettings.ttsEngine = modelData
}
}
}
}
TextSwitch {
text: qsTr("Read replies aloud")
description: qsTr("Speak every completed reply automatically")
checked: appSettings.ttsAutoRead
onCheckedChanged: appSettings.ttsAutoRead = checked
}
SectionHeader { text: qsTr("Account") }
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Sign out")
onClicked: {
api.logout()
pageStack.replace(Qt.resolvedUrl("LoginPage.qml"))
}
}
SectionHeader { text: qsTr("About") }
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
wrapMode: Text.WordWrap
font.pixelSize: Theme.fontSizeSmall
color: Theme.secondaryColor
text: "harbour-hermes " + appVersion + "\n\n" +
qsTr("Native client for the Hermes Web UI (hermes-webui): " +
"sessions, live streaming chat, server-side voice " +
"dictation and read-aloud.")
}
}
VerticalScrollDecorator {}
}
Component.onCompleted: urlField.text = appSettings.baseUrl
}
+41
View File
@@ -0,0 +1,41 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
// Avvio: verifica lo stato dell'autenticazione (cookie persistito) e
// indirizza verso la lista sessioni o il login.
Page {
id: page
Column {
anchors.centerIn: parent
spacing: Theme.paddingLarge
width: parent.width - 2 * Theme.horizontalPageMargin
Label {
anchors.horizontalCenter: parent.horizontalCenter
text: "Hermes"
font.pixelSize: Theme.fontSizeHuge
color: Theme.highlightColor
}
BusyIndicator {
anchors.horizontalCenter: parent.horizontalCenter
size: BusyIndicatorSize.Large
running: true
}
}
Connections {
target: api
onAuthChecked: page.route(loggedIn)
}
Component.onCompleted: api.checkAuth()
function route(loggedIn) {
if (loggedIn)
pageStack.replace(Qt.resolvedUrl("SessionsPage.qml"))
else
pageStack.replace(Qt.resolvedUrl("LoginPage.qml"))
}
}