Files
harbour-hermes/qml/pages/ChatPage.qml
T
kaneda 9c6a1242f7 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)
2026-09-12 07:11:20 +02:00

241 lines
7.4 KiB
QML

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)
}
}