582 lines
12 KiB
QML
582 lines
12 KiB
QML
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import QtQuick.Layouts
|
|
import ZShell.Components
|
|
import ZShell.Config
|
|
import ZShell.Llm
|
|
import qs.Modules.Sidebar.Chat.Content
|
|
import qs.Components
|
|
import qs.Services
|
|
|
|
Item {
|
|
id: root
|
|
|
|
property ChatSession chatData
|
|
// qmlformat off
|
|
readonly property var keys: [
|
|
Qt.Key_A,
|
|
Qt.Key_B,
|
|
Qt.Key_C,
|
|
Qt.Key_D,
|
|
Qt.Key_E,
|
|
Qt.Key_F,
|
|
Qt.Key_G,
|
|
Qt.Key_H,
|
|
Qt.Key_I,
|
|
Qt.Key_J,
|
|
Qt.Key_K,
|
|
Qt.Key_L,
|
|
Qt.Key_M,
|
|
Qt.Key_N,
|
|
Qt.Key_O,
|
|
Qt.Key_P,
|
|
Qt.Key_Q,
|
|
Qt.Key_R,
|
|
Qt.Key_S,
|
|
Qt.Key_T,
|
|
Qt.Key_U,
|
|
Qt.Key_V,
|
|
Qt.Key_W,
|
|
Qt.Key_X,
|
|
Qt.Key_Y,
|
|
Qt.Key_Z,
|
|
|
|
Qt.Key_Agrave,
|
|
Qt.Key_Aacute,
|
|
Qt.Key_Acircumflex,
|
|
Qt.Key_Atilde,
|
|
Qt.Key_Adiaeresis,
|
|
Qt.Key_Aring,
|
|
Qt.Key_AE,
|
|
|
|
Qt.Key_Ccedilla,
|
|
|
|
Qt.Key_Egrave,
|
|
Qt.Key_Eacute,
|
|
Qt.Key_Ecircumflex,
|
|
Qt.Key_Ediaeresis,
|
|
|
|
Qt.Key_Igrave,
|
|
Qt.Key_Iacute,
|
|
Qt.Key_Icircumflex,
|
|
Qt.Key_Idiaeresis,
|
|
|
|
Qt.Key_ETH,
|
|
|
|
Qt.Key_Ntilde,
|
|
|
|
Qt.Key_Ograve,
|
|
Qt.Key_Oacute,
|
|
Qt.Key_Ocircumflex,
|
|
Qt.Key_Otilde,
|
|
Qt.Key_Odiaeresis,
|
|
Qt.Key_Ooblique,
|
|
|
|
Qt.Key_Ugrave,
|
|
Qt.Key_Uacute,
|
|
Qt.Key_Ucircumflex,
|
|
Qt.Key_Udiaeresis,
|
|
|
|
Qt.Key_Yacute,
|
|
Qt.Key_ydiaeresis,
|
|
|
|
Qt.Key_THORN,
|
|
Qt.Key_ssharp
|
|
]
|
|
// qmlformat on
|
|
|
|
signal requestClose
|
|
|
|
readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4
|
|
|
|
property var pendingAttachments: []
|
|
|
|
function attachFiles(): void {
|
|
Files.pickFiles().then(files => {
|
|
if (!files || files.length === 0)
|
|
return;
|
|
const added = chatData.addAttachments(files);
|
|
root.pendingAttachments = root.pendingAttachments.concat(added);
|
|
});
|
|
}
|
|
|
|
function send(text: string): void {
|
|
if (text.trim() === "" && root.pendingAttachments.length === 0)
|
|
return;
|
|
root.scrollToLatest();
|
|
chatData.sendMessage(text, root.pendingAttachments);
|
|
input.text = "";
|
|
root.pendingAttachments = [];
|
|
}
|
|
|
|
function scrollToLatest(): void {
|
|
if (!listLoader.item || root.atNewestEnd)
|
|
return;
|
|
scrollAnim.stop();
|
|
scrollAnim.start();
|
|
}
|
|
|
|
function focusInput(): void {
|
|
Qt.callLater(() => input.forceActiveFocus());
|
|
}
|
|
|
|
onChatDataChanged: {
|
|
if (chatData)
|
|
focusInput();
|
|
}
|
|
Keys.onPressed: e => {
|
|
if (root.keys.includes(e.key)) {
|
|
input.insert(input.length, e.text);
|
|
focusInput();
|
|
}
|
|
}
|
|
|
|
Anim {
|
|
id: scrollAnim
|
|
|
|
target: listLoader.item
|
|
property: "contentY"
|
|
to: 0
|
|
type: Anim.DefaultEffects
|
|
|
|
onFinished: if (target)
|
|
target.contentY = 0
|
|
}
|
|
|
|
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
|
|
|
|
enabled: !ChatState.isWindow
|
|
visible: enabled
|
|
|
|
onClicked: root.requestClose()
|
|
}
|
|
|
|
CustomText {
|
|
Layout.fillWidth: ChatState.isWindow ? false : true
|
|
Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter
|
|
elide: Text.ElideRight
|
|
font: Tokens.font.title.medium
|
|
text: qsTr(root.chatData?.title || ChatState.title)
|
|
}
|
|
}
|
|
|
|
Loader {
|
|
id: listLoader
|
|
|
|
anchors.bottom: input.top
|
|
anchors.bottomMargin: Tokens.spacing.medium
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: header.bottom
|
|
anchors.topMargin: Tokens.spacing.medium
|
|
active: root.chatData && root.chatData.loaded
|
|
|
|
sourceComponent: Flickable {
|
|
id: flick
|
|
|
|
anchors.fill: parent
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: list.layoutHeight
|
|
|
|
transform: Rotation {
|
|
origin.y: flick.height / 2
|
|
origin.x: flick.width / 2
|
|
angle: 180
|
|
}
|
|
|
|
WheelInverter {
|
|
target: flick
|
|
}
|
|
|
|
Anim {
|
|
id: restoreAnim
|
|
|
|
target: flick
|
|
type: Anim.DefaultEffects
|
|
property: "contentY"
|
|
}
|
|
|
|
ChatListView {
|
|
id: list
|
|
|
|
width: flick.width
|
|
height: flick.height
|
|
yAnimDuration: Tokens.anim.durations.expressiveDefaultEffects
|
|
|
|
asynchronous: true
|
|
cacheBuffer: 50
|
|
estimatedHeight: 100
|
|
readyDelay: 5
|
|
removeDuration: Tokens.anim.durations.normal
|
|
spacing: 0
|
|
|
|
model: root.chatData.messagesModel
|
|
|
|
useCustomViewport: true
|
|
viewport: {
|
|
tWatcher.transform;
|
|
return Qt.rect(0, flick.contentY, width, flick.height);
|
|
}
|
|
onViewportAdjustNeeded: delta => {
|
|
restoreAnim.stop();
|
|
flick.contentY += delta;
|
|
}
|
|
onViewportRestoreRequested: target => {
|
|
restoreAnim.to = target;
|
|
restoreAnim.start();
|
|
}
|
|
|
|
delegate: MessageDelegate {
|
|
id: message
|
|
|
|
rootParent: flick
|
|
chatList: list
|
|
ChatListView.preferredHeight: implicitHeight
|
|
ChatListView.trackViewport: true
|
|
ChatListView.visibleHeight: ChatListView.removing ? 0 : implicitHeight
|
|
opacity: ChatListView.removing || ChatListView.adding ? 0 : 1
|
|
transform: Rotation {
|
|
origin.y: message.height / 2
|
|
origin.x: message.width / 2
|
|
angle: 180
|
|
}
|
|
|
|
Behavior on y {
|
|
enabled: message.ChatListView.ready
|
|
|
|
Anim {
|
|
id: yAnim
|
|
type: Anim.DefaultEffects
|
|
}
|
|
}
|
|
|
|
Behavior on x {
|
|
Anim {
|
|
type: Anim.DefaultEffects
|
|
}
|
|
}
|
|
|
|
Behavior on opacity {
|
|
Anim {
|
|
type: Anim.DefaultEffects
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
CustomScrollBar.vertical: CustomScrollBar {
|
|
id: scrollBar
|
|
|
|
flickable: flick
|
|
parent: flick.parent
|
|
anchors.top: flick.top
|
|
anchors.right: flick.right
|
|
anchors.bottom: flick.bottom
|
|
inverted: true
|
|
|
|
transform: Rotation {
|
|
origin.y: flick.height / 2
|
|
origin.x: scrollBar.width / 2
|
|
angle: 180
|
|
|
|
axis {
|
|
y: 0
|
|
x: 1
|
|
z: 0
|
|
}
|
|
}
|
|
}
|
|
|
|
TransformWatcher {
|
|
id: tWatcher
|
|
|
|
a: flick.contentItem
|
|
b: list
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: if (root.chatData)
|
|
root.chatData.messagesModel
|
|
}
|
|
|
|
EmptyBackground {
|
|
id: emptyState
|
|
|
|
anchors.fill: parent
|
|
spacing: Tokens.spacing.small
|
|
visible: root.chatData.messageCount < 1
|
|
}
|
|
|
|
Item {
|
|
anchors.bottom: input.top
|
|
anchors.bottomMargin: Tokens.spacing.extraLarge
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
implicitHeight: scrollToBottom.implicitHeight
|
|
implicitWidth: scrollToBottom.implicitWidth
|
|
scale: root.atNewestEnd || !listLoader.item || listLoader.item.contentHeight <= listLoader.item.height ? 0 : 1
|
|
visible: scale > 0
|
|
|
|
Behavior on scale {
|
|
Anim {}
|
|
}
|
|
|
|
Elevation {
|
|
anchors.fill: parent
|
|
level: 2
|
|
radius: scrollToBottom.radius
|
|
}
|
|
|
|
IconButton {
|
|
id: scrollToBottom
|
|
|
|
anchors.centerIn: parent
|
|
font: Tokens.font.icon.large
|
|
icon: "arrow_downward"
|
|
isRound: true
|
|
padding: Tokens.padding.extraSmall
|
|
type: IconButton.Tonal
|
|
|
|
onClicked: {
|
|
root.scrollToLatest();
|
|
}
|
|
}
|
|
}
|
|
|
|
GridView {
|
|
id: attachments
|
|
|
|
readonly property int columns: ChatState.isWindow ? 5 : 3
|
|
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: input.top
|
|
anchors.bottomMargin: Tokens.spacing.medium
|
|
|
|
flow: GridView.LeftToRight
|
|
verticalLayoutDirection: GridView.BottomToTop
|
|
|
|
cellWidth: width / columns
|
|
cellHeight: cellWidth
|
|
|
|
implicitHeight: Math.ceil(count / columns) * cellHeight
|
|
|
|
displaced: Transition {
|
|
Anim {
|
|
property: "x"
|
|
}
|
|
Anim {
|
|
property: "y"
|
|
}
|
|
}
|
|
|
|
add: Transition {
|
|
id: addTrans
|
|
Anim {
|
|
property: "y"
|
|
from: addTrans.ViewTransition.destination.y + attachments.cellHeight / 4
|
|
}
|
|
Anim {
|
|
property: "opacity"
|
|
from: 0
|
|
to: 1
|
|
}
|
|
}
|
|
remove: Transition {
|
|
id: removeTrans
|
|
Anim {
|
|
property: "y"
|
|
to: removeTrans.ViewTransition.destination.y + attachments.cellHeight / 4
|
|
}
|
|
Anim {
|
|
property: "opacity"
|
|
from: 1
|
|
to: 0
|
|
}
|
|
}
|
|
|
|
model: ScriptModel {
|
|
objectProp: "path"
|
|
values: root.pendingAttachments
|
|
}
|
|
|
|
delegate: Item {
|
|
id: thumb
|
|
|
|
required property var modelData
|
|
function fileIcon(mime): string {
|
|
if (mime.startsWith("archive/") || mime === "application/zip")
|
|
return "folder_zip";
|
|
if (mime === "application/pdf")
|
|
return "picture_as_pdf";
|
|
if (mime.startsWith("audio/"))
|
|
return "audio_file";
|
|
if (mime.startsWith("video/"))
|
|
return "video_file";
|
|
if (mime.startsWith("text/"))
|
|
return "description";
|
|
return "insert_drive_file";
|
|
}
|
|
|
|
width: attachments.cellWidth
|
|
height: width
|
|
opacity: modelData ? 1 : 0
|
|
ClipRect {
|
|
anchors.fill: parent
|
|
anchors.margins: Tokens.spacing.extraSmall
|
|
|
|
radius: Tokens.rounding.medium
|
|
color: Colors.tPalette.m3surfaceContainer
|
|
|
|
FadeImage {
|
|
id: attImage
|
|
|
|
anchors.fill: parent
|
|
fillMode: FadeImage.PreserveAspectCrop
|
|
asynchronous: true
|
|
source: visible ? thumb.modelData?.path ?? "" : ""
|
|
visible: thumb.modelData?.isImage ?? false
|
|
}
|
|
|
|
MaterialIcon {
|
|
anchors.centerIn: parent
|
|
fontStyle: Tokens.font.icon.large
|
|
text: thumb.fileIcon(thumb.modelData?.mimeType ?? "")
|
|
visible: !thumb.modelData?.isImage ?? false
|
|
}
|
|
|
|
Loader {
|
|
id: attIndicatorLoader
|
|
|
|
anchors.centerIn: parent
|
|
|
|
opacity: 0
|
|
active: opacity > 0 && (thumb.modelData?.isImage ?? false)
|
|
|
|
sourceComponent: CustomRect {
|
|
implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
|
|
implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
|
|
|
|
color: Colors.palette.m3primaryContainer
|
|
radius: Tokens.rounding.full
|
|
|
|
LoadingIndicator {
|
|
id: attLoadingIndicator
|
|
|
|
anchors.centerIn: parent
|
|
containsIcon: true
|
|
implicitSize: thumb.implicitWidth * 0.4
|
|
}
|
|
}
|
|
|
|
Behavior on opacity {
|
|
Anim {
|
|
type: Anim.DefaultEffects
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: attLoadDebounceTimer
|
|
|
|
interval: 100
|
|
onTriggered: {
|
|
if (attImage.status !== Image.Ready)
|
|
attIndicatorLoader.opacity = 1;
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: hover
|
|
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: undefined
|
|
|
|
IconButton {
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.margins: Tokens.padding.medium
|
|
|
|
icon: "close"
|
|
visible: opacity > 0
|
|
enabled: thumb.opacity > 0
|
|
opacity: hover.containsMouse ? 1 : 0
|
|
|
|
Behavior on opacity {
|
|
Anim {
|
|
type: Anim.DefaultEffects
|
|
}
|
|
}
|
|
|
|
onClicked: root.pendingAttachments = root.pendingAttachments.filter(a => a !== thumb.modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ChatInput {
|
|
id: input
|
|
|
|
anchors.bottom: parent.bottom
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.margins: Tokens.padding.medium
|
|
bg.border.color: Colors.palette.m3outlineVariant
|
|
bg.color: Colors.tPalette.m3surfaceContainerLowest
|
|
fastGlow: LlmClient.busy
|
|
glow.opacity: input.activeFocus && (!LlmClient.busy || ChatState.isWindow)
|
|
font: Tokens.font.body.small
|
|
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
|
|
focus: true
|
|
placeholderText: qsTr("Send a message")
|
|
sendIcon.font: Tokens.font.icon.medium
|
|
sendIcon.icon: LlmClient.busy ? "stop" : "arrow_upward"
|
|
sendIcon.padding: Tokens.padding.extraSmall
|
|
sendIcon.opacity: text || LlmClient.busy ? 1.0 : 0.5
|
|
sendIcon.enabled: text || LlmClient.busy
|
|
|
|
Component.onCompleted: {
|
|
root.focusInput();
|
|
}
|
|
Keys.onPressed: e => {
|
|
if (e.key == Qt.Key_Return) {
|
|
if (!(e.modifiers & Qt.ShiftModifier)) {
|
|
root.send(text);
|
|
e.accepted = true;
|
|
}
|
|
}
|
|
|
|
if (e.key == Qt.Key_V && e.modifiers & Qt.ControlModifier) {
|
|
if (root.chatData.clipboardHasAttachment()) {
|
|
const att = root.chatData.pasteAttachments();
|
|
root.pendingAttachments = [...root.pendingAttachments, ...att];
|
|
e.accepted = true;
|
|
}
|
|
}
|
|
}
|
|
onButtonPressed: {
|
|
if (LlmClient.busy)
|
|
LlmClient.stop();
|
|
else
|
|
root.send(text);
|
|
}
|
|
}
|
|
}
|