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,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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user