340 lines
7.9 KiB
QML
340 lines
7.9 KiB
QML
pragma ComponentBehavior: Bound
|
|
|
|
import ZShell.Config
|
|
import ZShell.Llm
|
|
import Quickshell
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import qs.Components
|
|
import qs.Services
|
|
|
|
Item {
|
|
id: root
|
|
|
|
property ChatSession chatData
|
|
property bool following: true
|
|
readonly property int messageCount: chatData.messages.length
|
|
|
|
signal requestClose
|
|
|
|
function send(): void {
|
|
if (Chat.busy || input.text.trim() === "")
|
|
return;
|
|
following = true;
|
|
Chat.send(chatData.id, input.text);
|
|
input.text = "";
|
|
}
|
|
|
|
RowLayout {
|
|
id: header
|
|
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
spacing: Tokens.spacing.large
|
|
|
|
IconButton {
|
|
icon: "arrow_back"
|
|
inactiveColor: Colors.tPalette.m3surfaceContainerHigh
|
|
inactiveOnColor: Colors.palette.m3onSurfaceVariant
|
|
isRound: true
|
|
type: IconButton.Tonal
|
|
|
|
onClicked: root.requestClose()
|
|
}
|
|
|
|
CustomText {
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
font.pointSize: Tokens.font.size.larger
|
|
text: qsTr(root.chatData.title)
|
|
}
|
|
}
|
|
|
|
VerticalFadeListView {
|
|
id: list
|
|
|
|
readonly property real bottomThreshold: Tokens.spacing.extraSmall
|
|
property bool stickToBottom: true
|
|
|
|
function scrollToEnd(animated: bool): void {
|
|
const target = Math.max(0, list.contentHeight - list.height);
|
|
if (!animated) {
|
|
scrollAnim.stop();
|
|
list.contentY = target;
|
|
return;
|
|
}
|
|
scrollAnim.to = target;
|
|
scrollAnim.restart();
|
|
}
|
|
|
|
anchors.bottom: inputRow.top
|
|
anchors.bottomMargin: Tokens.spacing.medium
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: header.bottom
|
|
cacheBuffer: height * 20
|
|
clip: true
|
|
currentIndex: messages.values.length - 1
|
|
spacing: Tokens.spacing.medium
|
|
|
|
CustomScrollBar.vertical: CustomScrollBar {
|
|
flickable: list
|
|
}
|
|
delegate: ColumnLayout {
|
|
id: messageRow
|
|
|
|
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
|
required property var modelData
|
|
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: messageRow.isUser ? 0 : Tokens.spacing.extraSmall
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: messageRow.isUser ? Tokens.spacing.extraSmall : 0
|
|
|
|
Loader {
|
|
Layout.fillWidth: true
|
|
Layout.rightMargin: Tokens.spacing.extraLarge
|
|
active: !messageRow.isUser && messageRow.modelData.reasoning !== ""
|
|
|
|
sourceComponent: CustomRect {
|
|
id: reasoning
|
|
|
|
property bool expanded: false
|
|
|
|
color: Colors.palette.m3surfaceContainer
|
|
implicitHeight: expanded ? expandedText.implicitHeight : collapsedText.implicitHeight + Tokens.padding.medium * 2
|
|
radius: Tokens.rounding.medium
|
|
|
|
Behavior on implicitHeight {
|
|
Anim {
|
|
}
|
|
}
|
|
|
|
Loader {
|
|
id: spinnerReasoning
|
|
|
|
active: opacity > 0
|
|
anchors.left: parent.left
|
|
anchors.margins: Tokens.padding.medium
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
opacity: messageRow.modelData.reasoningActive ? 1 : 0
|
|
|
|
Behavior on opacity {
|
|
Anim {
|
|
}
|
|
}
|
|
sourceComponent: LoadingIndicator {
|
|
implicitSize: collapsedText.implicitHeight
|
|
}
|
|
}
|
|
|
|
MaterialIcon {
|
|
id: reasoningDone
|
|
|
|
anchors.left: parent.left
|
|
anchors.margins: Tokens.padding.medium
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
font.pointSize: Tokens.font.size.large
|
|
opacity: messageRow.modelData.reasoningActive || reasoning.expanded ? 0 : 1
|
|
text: "check"
|
|
|
|
Behavior on opacity {
|
|
Anim {
|
|
}
|
|
}
|
|
}
|
|
|
|
CustomText {
|
|
id: collapsedText
|
|
|
|
anchors.left: messageRow.modelData.reasoningActive ? spinnerReasoning.right : reasoningDone.right
|
|
anchors.margins: Tokens.padding.medium
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
opacity: reasoning.expanded ? 0 : 1
|
|
text: messageRow.modelData.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((messageRow.modelData.reasoningElapsedMs / 1000).toFixed(1))
|
|
visible: opacity > 0
|
|
|
|
Behavior on opacity {
|
|
Anim {
|
|
}
|
|
}
|
|
}
|
|
|
|
CustomText {
|
|
id: expandedText
|
|
|
|
anchors.left: parent.left
|
|
anchors.margins: Tokens.padding.medium
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
color: Colors.palette.m3onSurface
|
|
opacity: reasoning.expanded ? 1 : 0
|
|
text: messageRow.modelData.reasoning
|
|
visible: opacity > 0
|
|
wrapMode: Text.WordWrap
|
|
|
|
Behavior on opacity {
|
|
Anim {
|
|
}
|
|
}
|
|
}
|
|
|
|
StateLayer {
|
|
onClicked: reasoning.expanded = !reasoning.expanded
|
|
}
|
|
}
|
|
}
|
|
|
|
CustomRect {
|
|
Layout.alignment: messageRow.isUser ? Qt.AlignRight : Qt.AlignLeft
|
|
color: messageRow.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
|
implicitHeight: Math.max(msgText.implicitHeight + Tokens.padding.medium * 2, spinnerLoader.implicitHeight)
|
|
implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, messageRow.ListView.view.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge)
|
|
radius: Tokens.rounding.medium
|
|
visible: !messageRow.modelData.reasoningActive
|
|
|
|
CustomText {
|
|
id: msgText
|
|
|
|
anchors.left: parent.left
|
|
anchors.margins: Tokens.padding.medium
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
|
text: messageRow.modelData.content
|
|
textFormat: CustomText.MarkdownText
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Loader {
|
|
id: spinnerLoader
|
|
|
|
active: messageRow.modelData.streaming && msgText.text.length === 0
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 8
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
|
|
sourceComponent: Item {
|
|
implicitHeight: 18
|
|
implicitWidth: 18
|
|
|
|
LoadingIndicator {
|
|
anchors.fill: parent
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
model: ScriptModel {
|
|
id: messages
|
|
|
|
values: root.chatData.messages
|
|
}
|
|
|
|
Component.onCompleted: scrollToEnd(false)
|
|
onContentHeightChanged: {
|
|
if (list.stickToBottom)
|
|
list.scrollToEnd(false);
|
|
}
|
|
onMovementEnded: {
|
|
list.stickToBottom = (list.contentY >= list.contentHeight - list.height - list.bottomThreshold);
|
|
}
|
|
onMovementStarted: list.stickToBottom = false
|
|
|
|
Anim {
|
|
id: scrollAnim
|
|
|
|
property: "contentY"
|
|
target: list
|
|
type: Anim.DefaultEffects
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: emptyState
|
|
|
|
anchors.fill: parent
|
|
spacing: Tokens.spacing.small
|
|
visible: (root.messageCount === 0 && !Chat.busy) || !root.chatData
|
|
|
|
Item {
|
|
Layout.fillHeight: true
|
|
}
|
|
|
|
MaterialIcon {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
color: Colors.tPalette.m3outlineVariant
|
|
font.pointSize: 36
|
|
text: "chat"
|
|
}
|
|
|
|
CustomText {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
color: Colors.tPalette.m3onSurfaceVariant
|
|
text: !root.chatData ? qsTr("Open a previous chat or start a new one") : qsTr("No messages yet")
|
|
}
|
|
|
|
Item {
|
|
Layout.fillHeight: true
|
|
}
|
|
}
|
|
|
|
IconButton {
|
|
id: scrollToBottom
|
|
|
|
anchors.bottom: inputRow.top
|
|
anchors.bottomMargin: Tokens.spacing.extraLarge
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
font.pointSize: Tokens.font.size.large
|
|
icon: "arrow_downward"
|
|
isRound: true
|
|
padding: Tokens.padding.extraSmall
|
|
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 ? 1 : 0
|
|
type: IconButton.Filled
|
|
visible: scale > 0
|
|
|
|
Behavior on scale {
|
|
Anim {
|
|
}
|
|
}
|
|
|
|
onClicked: {
|
|
list.stickToBottom = true;
|
|
list.scrollToEnd(true);
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
id: inputRow
|
|
|
|
anchors.bottom: parent.bottom
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
spacing: Tokens.spacing.extraSmall
|
|
|
|
CustomTextField {
|
|
id: input
|
|
|
|
Layout.fillWidth: true
|
|
placeholderText: qsTr("Send a message...")
|
|
type: CustomTextField.Filled
|
|
|
|
onAccepted: root.send()
|
|
}
|
|
|
|
IconButton {
|
|
id: actionBtn
|
|
|
|
enabled: Chat.busy || input.text.trim() !== ""
|
|
font.pointSize: Tokens.font.size.normal * 2
|
|
icon: Chat.busy ? "stop" : "send"
|
|
isRound: true
|
|
type: IconButton.Filled
|
|
|
|
onClicked: Chat.busy ? Chat.stop() : root.send()
|
|
}
|
|
}
|
|
}
|