- 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)
241 lines
7.4 KiB
QML
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)
|
|
}
|
|
}
|