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