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: "" // Modalità dialogo (voce continua): ascolto → trascrizione → invio → // lettura della risposta → riascolto (vedi toggleVoiceMode()). property bool voiceMode: false property string voiceState: "idle" // listening|transcribing|sending|streaming|speaking property int voiceErrors: 0 // 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: page.voiceMode ? qsTr("Voice mode: on") : qsTr("Voice mode: off") onClicked: page.toggleVoiceMode() } MenuItem { text: qsTr("Read last reply aloud") onClicked: page.readLast() } MenuItem { text: appSettings.ttsAutoRead ? qsTr("Auto-read: on") : qsTr("Auto-read: off") onClicked: page.toggleAutoRead() } MenuItem { text: qsTr("Stop playback") visible: player.playing onClicked: page.stopPlayback() } MenuItem { text: qsTr("Cancel run") visible: chat.busy onClicked: page.cancelRun() } MenuItem { text: qsTr("Resync") onClicked: page.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.rgba(Theme.highlightBackgroundColor, 0.15) Column { id: compCol width: parent.width spacing: Theme.paddingSmall / 2 Label { id: statusLbl x: Theme.horizontalPageMargin width: parent.width - 2 * Theme.horizontalPageMargin font.pixelSize: Theme.fontSizeExtraSmall color: page.voiceMode ? Theme.highlightColor : (recorder.recording ? Theme.highlightColor : Theme.secondaryHighlightColor) visible: page.voiceMode || recorder.recording || api.transcribing || page.noticeText.length > 0 truncationMode: TruncationMode.Fade text: page.voiceMode ? page.voiceStatusText() : (recorder.recording ? qsTr("Recording… %1 — tap Stop to transcribe").arg(page.formatMs(recorder.durationMs)) : (api.transcribing ? qsTr("Transcribing…") : page.noticeText)) // In modalità dialogo: un tocco sul testo esce (MouseArea // interna al Label: nessuna interferenza con la Column). MouseArea { anchors.fill: parent enabled: page.voiceMode onClicked: page.exitVoiceMode() } } Row { x: Theme.horizontalPageMargin width: parent.width - 2 * Theme.horizontalPageMargin spacing: Theme.paddingSmall Button { id: micBtn text: recorder.recording ? qsTr("Stop") : qsTr("Mic") // Button non ha "highlighted": lo stato è nel testo (Mic/Stop) enabled: !page.voiceMode && (!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…") // Invio con EnterKey (vedi LoginPage) EnterKey.enabled: text.length > 0 EnterKey.onClicked: 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 = "" } Timer { id: voiceRetry interval: 1200 onTriggered: { if (page.voiceMode) page.startListening() } } Connections { target: chat onRowCountChanged: scrollDebounce.restart() onStreamTick: scrollDebounce.restart() onAssistantFinished: { if (page.voiceMode) { var t = chat.lastAssistantText() if (t.length > 0) { page.voiceState = "speaking" api.speak(t) } else { voiceRetry.restart() } return } if (appSettings.ttsAutoRead) page.readLast() } onNotice: page.showNotice(text) } Connections { target: api onTranscriptionReady: { page.voiceErrors = 0 if (page.voiceMode) { if (text.trim().length === 0) { voiceRetry.restart() return } input.text = text page.voiceState = "sending" page.doSend() return } input.text = text input.focus = true if (appSettings.dictationAutoSend) page.doSend() } onTranscriptionFailed: { page.showNotice(error) if (page.voiceMode) { page.voiceErrors += 1 if (page.voiceErrors >= 3) { page.exitVoiceMode() } else { voiceRetry.restart() } } } onTtsFailed: { if (page.voiceMode) voiceRetry.restart() else page.showNotice(error) } } Component.onCompleted: { if (sessionId.length > 0) chat.openSession(sessionId) } Component.onDestruction: { if (page.voiceMode) { page.voiceMode = false voiceRec.stop() player.stop() } } Connections { target: player onFinished: { if (page.voiceMode) page.startListening() } } Connections { target: voiceRec onListening: { if (page.voiceMode) page.voiceState = "listening" } onFinished: { if (!page.voiceMode) return if (wavPath.length === 0) return page.voiceState = "transcribing" api.transcribeFile(wavPath) } onFailed: { if (error === "no-speech") { if (page.voiceMode) voiceRetry.restart() return } page.showNotice(error) if (page.voiceMode) { page.voiceErrors += 1 if (page.voiceErrors >= 3) { page.exitVoiceMode() page.showNotice(qsTr("Voice mode off after repeated errors")) } else { voiceRetry.restart() } } } } function toggleVoiceMode() { if (page.voiceMode) page.exitVoiceMode() else page.enterVoiceMode() } function enterVoiceMode() { if (chat.busy || recorder.recording) { page.showNotice(qsTr("Wait for the current reply first")) return } player.stop() page.voiceErrors = 0 page.voiceMode = true page.voiceState = "listening" voiceRec.start() } function exitVoiceMode() { page.voiceMode = false page.voiceState = "idle" voiceRec.stop() player.stop() } function startListening() { if (!page.voiceMode) return page.voiceState = "listening" voiceRec.start() } function voiceStatusText() { if (page.voiceState === "listening") return qsTr("● Listening — speak, I stop at the pause (tap to exit)") if (page.voiceState === "transcribing") return qsTr("Transcribing…") if (page.voiceState === "sending") return qsTr("Sending…") if (page.voiceState === "streaming") return qsTr("Hermes is writing…") if (page.voiceState === "speaking") return qsTr("Speaking…") return qsTr("Voice mode on") } 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 toggleAutoRead() { appSettings.ttsAutoRead = !appSettings.ttsAutoRead } function stopPlayback() { player.stop() } function cancelRun() { chat.cancel() } function resync() { chat.resync() } 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) } }