- src/markdown.{h,cpp}: convertitore minimale, due funzioni:
* toRichText() -> HTML per QML Text.RichText: grassetti (**x**/__x__),
corsivi (*x*/_x_ con confini di parola per non toccare snake_case),
titoli (#), liste (-/1.), citazioni (>), codice (`x` e blocchi ```,
in monospace), link [t](u) -> solo testo, barrato ~~x~~; escape di
< & > contro injection nel rich text
* toSpeech() -> testo piatto per il TTS: via recinti di codice, marcatori
di titoli/liste/citazioni, enfasi, backtick; link/immagini -> testo;
tabelle | a | b | -> "a, b"; righe vuote compattate
- ChatModel: nuovo ruolo contentRich (calcolato in C++, testabile);
incluso nei dataChanged dello streaming
- ChatPage: le bolle dell'assistente usano contentRich con
textFormat: Text.RichText; i messaggi utente restano testo semplice
- ApiClient::speak(): il testo passa da Markdown::toSpeech (niente più
simboli di formattazione letti ad alta voce)
- core-test: 19 casi nuovi per il convertitore (tutti locali, senza device)
Verificato: build zero warning + unit test completi.
442 lines
14 KiB
QML
442 lines
14 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: ""
|
|
// 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
|
|
// Le risposte di Hermes sono markdown: si mostrano
|
|
// formattate (grassetti, corsivi, titoli, liste, codice).
|
|
textFormat: model.role === "user" ? Text.PlainText : Text.RichText
|
|
text: (model.role === "user" ? model.content : model.contentRich)
|
|
+ (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
|
|
}
|
|
var autoRead = appSettings.ttsAutoRead
|
|
if (autoRead === undefined || autoRead === null)
|
|
autoRead = false
|
|
if (autoRead)
|
|
page.readLast()
|
|
}
|
|
onNotice: page.showNotice(text)
|
|
}
|
|
|
|
Connections {
|
|
target: api
|
|
onTranscriptionReady: {
|
|
page.voiceErrors = 0
|
|
var t = (text === undefined || text === null) ? "" : String(text)
|
|
var autoSend = appSettings.dictationAutoSend
|
|
if (autoSend === undefined || autoSend === null)
|
|
autoSend = true
|
|
console.log("[hermes] trascrizione: " + t.length + " char, voiceMode="
|
|
+ page.voiceMode + ", autoSend=" + autoSend)
|
|
if (page.voiceMode) {
|
|
if (t.trim().length === 0) {
|
|
voiceRetry.restart()
|
|
return
|
|
}
|
|
page.voiceState = "sending"
|
|
page.sendText(t)
|
|
return
|
|
}
|
|
input.text = t
|
|
if (autoSend) {
|
|
console.log("[hermes] auto-invio dettatura")
|
|
page.sendText(t)
|
|
} else {
|
|
input.focus = true
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
|
|
// Invio del testo esplicito (non rilegge il campo: nessuna race con la UI).
|
|
function sendText(t) {
|
|
var s = String(t).trim()
|
|
if (s.length === 0)
|
|
return
|
|
input.text = ""
|
|
console.log("[hermes] sendText: " + s.length + " char, chat.busy=" + chat.busy)
|
|
chat.send(s)
|
|
scrollDebounce.restart()
|
|
}
|
|
|
|
function doSend() {
|
|
sendText(input.text)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|