shell: carry the message control row into the owned delegate
Vendor AiMessage exposed seven controls; the owned delegate had none, which
made patch 0008 a parity regression rather than a swap. Five are carried, two
are deliberately dropped:
regenerate — the conversation is forward-only and Ai.regenerate() is already
a no-op returning advice. A button whose only behaviour is to explain that
it does nothing is worse than no button.
edit — there is no in-place edit. The vendor's wrote to a local array the
server never sees, so the message read back was not the message held.
delete is kept but armed: removeMessage() splices two local arrays and leaves
the server transcript untouched, so it is a view filter wearing a delete icon.
The armed row says so in words and replaces the controls in place rather than
opening a modal, which on a layer-shell surface would fight for focus.
speak and copy are separated at the source. copy takes the whole content;
speak takes text segments only, so the synthesizer no longer reads reasoning
and tool payloads aloud. Legacy messages without segments fall back to full
content rather than to silence.
re-synthesize prefers Speech.resynthesize() when present and degrades to the
old stop+speak otherwise, because speak() opens with a cache check on the text
and re-synthesis is by definition the same text — the legacy path replays the
identical broken audio.
This commit is contained in:
parent
1951d2b69b
commit
df11bbab96
1 changed files with 183 additions and 0 deletions
|
|
@ -8,6 +8,7 @@ import qs.modules.ii.sidebarLeft.aiChat
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* One message, drawn from typed segments.
|
* One message, drawn from typed segments.
|
||||||
|
|
@ -55,6 +56,36 @@ Rectangle {
|
||||||
readonly property bool isAssistant: (root.messageData?.role ?? "") === "assistant"
|
readonly property bool isAssistant: (root.messageData?.role ?? "") === "assistant"
|
||||||
readonly property bool done: root.messageData?.done ?? false
|
readonly property bool done: root.messageData?.done ?? false
|
||||||
|
|
||||||
|
// Delete is armed rather than immediate — see the control row.
|
||||||
|
property bool deleteArmed: false
|
||||||
|
onMessageIndexChanged: root.deleteArmed = false
|
||||||
|
|
||||||
|
readonly property bool isSpeakingThis: Speech.speaking
|
||||||
|
&& Ai.speakingMessageIndex === root.messageIndex
|
||||||
|
|
||||||
|
// What `copy` puts on the clipboard: everything the message actually says.
|
||||||
|
readonly property string plainText: root.messageData?.content ?? ""
|
||||||
|
|
||||||
|
// What `speak` sends to TTS. NOT the same thing, deliberately.
|
||||||
|
//
|
||||||
|
// The vendor spoke messageData.content verbatim, which on a segmented
|
||||||
|
// message means the synthesizer reads reasoning and tool payloads aloud.
|
||||||
|
// Typed segments let us say what a voice should say: prose only. Code is
|
||||||
|
// excluded because reading a diff aloud is noise, not speech; `think` and
|
||||||
|
// `tool` are excluded because they are not addressed to anyone.
|
||||||
|
//
|
||||||
|
// Falls back to the whole content when a legacy message carries no
|
||||||
|
// segments, which is the pre-existing behaviour rather than silence.
|
||||||
|
readonly property string spokenText: {
|
||||||
|
const segments = root.messageData?.segments;
|
||||||
|
if (!segments || segments.length < 1) return root.plainText;
|
||||||
|
return segments
|
||||||
|
.filter(s => (s?.kind ?? "text") === "text")
|
||||||
|
.map(s => s?.text ?? "")
|
||||||
|
.join("\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
anchors.left: parent?.left
|
anchors.left: parent?.left
|
||||||
anchors.right: parent?.right
|
anchors.right: parent?.right
|
||||||
|
|
||||||
|
|
@ -106,6 +137,158 @@ Rectangle {
|
||||||
color: Appearance.colors.colOnSecondaryContainer
|
color: Appearance.colors.colOnSecondaryContainer
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Controls ──────────────────────────────────────────────
|
||||||
|
// Deliberately NOT carried over from the vendor row:
|
||||||
|
//
|
||||||
|
// regenerate — the conversation is forward-only. Ai.qml's
|
||||||
|
// regenerate() is already a no-op that returns advice.
|
||||||
|
// A button that only ever explains it does nothing is
|
||||||
|
// worse than no button. Audio re-synthesis is the real
|
||||||
|
// verb, and it lives on `replay` below.
|
||||||
|
// edit — there is no in-place edit. The vendor's saved to
|
||||||
|
// a local array the server never sees, so the message
|
||||||
|
// you read back was not the message the agent holds.
|
||||||
|
//
|
||||||
|
// `delete` is kept but gated, because it is a *view filter*
|
||||||
|
// wearing a delete icon: removeMessage() splices two local
|
||||||
|
// arrays and the server transcript is untouched. /resume
|
||||||
|
// brings it straight back.
|
||||||
|
ButtonGroup {
|
||||||
|
id: controlRow
|
||||||
|
spacing: 5
|
||||||
|
visible: !root.deleteArmed
|
||||||
|
|
||||||
|
AiMessageControlButton {
|
||||||
|
id: speakButton
|
||||||
|
// stop icon only while THIS message is the speaker
|
||||||
|
buttonIcon: root.isSpeakingThis ? "stop_circle" : "volume_up"
|
||||||
|
visible: Speech.enabled && root.isAssistant
|
||||||
|
enabled: visible
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
if (root.isSpeakingThis) {
|
||||||
|
Speech.stop()
|
||||||
|
} else {
|
||||||
|
Ai.speakingMessageIndex = root.messageIndex
|
||||||
|
Speech.speak(StringUtils.ttsClean(root.spokenText))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledToolTip {
|
||||||
|
text: root.isSpeakingThis ? Translation.tr("Stop") : Translation.tr("Speak")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AiMessageControlButton {
|
||||||
|
id: respeakButton
|
||||||
|
buttonIcon: "replay"
|
||||||
|
visible: Speech.enabled && root.isAssistant
|
||||||
|
enabled: visible
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
// Re-synthesize when the previous synth came out
|
||||||
|
// wrong. Does NOT re-run the agent.
|
||||||
|
//
|
||||||
|
// The legacy path below cannot actually do this:
|
||||||
|
// speak() opens with a cache check on the text,
|
||||||
|
// and re-synthesis is by definition the same
|
||||||
|
// text — so it replays the identical broken file.
|
||||||
|
// Speech.resynthesize() is the honest verb
|
||||||
|
// (cancel live job, bypass cache, re-request);
|
||||||
|
// until it lands we degrade rather than lie.
|
||||||
|
Ai.speakingMessageIndex = root.messageIndex
|
||||||
|
const text = StringUtils.ttsClean(root.spokenText)
|
||||||
|
if (typeof Speech.resynthesize === "function") {
|
||||||
|
Speech.resynthesize(text)
|
||||||
|
} else {
|
||||||
|
Speech.stop()
|
||||||
|
Speech.speak(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledToolTip {
|
||||||
|
text: Translation.tr("Re-synthesize audio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AiMessageControlButton {
|
||||||
|
id: copyButton
|
||||||
|
buttonIcon: activated ? "inventory" : "content_copy"
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
Quickshell.clipboardText = root.plainText
|
||||||
|
copyButton.activated = true
|
||||||
|
copyIconTimer.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: copyIconTimer
|
||||||
|
interval: 1500
|
||||||
|
repeat: false
|
||||||
|
onTriggered: copyButton.activated = false
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledToolTip {
|
||||||
|
text: Translation.tr("Copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AiMessageControlButton {
|
||||||
|
id: toggleMarkdownButton
|
||||||
|
activated: !root.renderMarkdown
|
||||||
|
buttonIcon: "code"
|
||||||
|
onClicked: root.renderMarkdown = !root.renderMarkdown
|
||||||
|
StyledToolTip {
|
||||||
|
text: Translation.tr("View Markdown source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AiMessageControlButton {
|
||||||
|
id: deleteButton
|
||||||
|
buttonIcon: "close"
|
||||||
|
onClicked: root.deleteArmed = true
|
||||||
|
StyledToolTip {
|
||||||
|
text: Translation.tr("Hide from view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Armed state replaces the row in place rather than opening a
|
||||||
|
// modal: this panel is a layer-shell surface and a grabbing
|
||||||
|
// popup here fights the compositor for focus. The words are
|
||||||
|
// the point, not the chrome.
|
||||||
|
RowLayout {
|
||||||
|
id: deleteConfirmRow
|
||||||
|
visible: root.deleteArmed
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
MaterialSymbol {
|
||||||
|
text: "visibility_off"
|
||||||
|
iconSize: Appearance.font.pixelSize.normal
|
||||||
|
color: Appearance.colors.colOnSecondaryContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: Translation.tr("Hides it here only — the transcript keeps it.")
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
color: Appearance.colors.colOnSecondaryContainer
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogButton {
|
||||||
|
buttonText: Translation.tr("Cancel")
|
||||||
|
onClicked: root.deleteArmed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogButton {
|
||||||
|
buttonText: Translation.tr("Hide")
|
||||||
|
onClicked: {
|
||||||
|
root.deleteArmed = false
|
||||||
|
Ai.removeMessage(root.messageIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue