C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Failing after 11s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Failing after 31s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m37s
Rust / fmt (pull_request) Successful in 27s
Rust / clippy (pull_request) Successful in 58s
352 lines
7.1 KiB
QML
352 lines
7.1 KiB
QML
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import ZShell.Components
|
|
import ZShell.Config
|
|
import ZShell.Llm
|
|
import qs.Modules.Notifications.Sidebar.Chat
|
|
import qs.Components
|
|
import qs.Services
|
|
|
|
MouseArea {
|
|
id: root
|
|
|
|
required property int index
|
|
required property Item rootParent
|
|
required property ChatMessage modelData
|
|
required property ChatListView chatList
|
|
|
|
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
|
readonly property var blocks: blockify(current.segments, current.error)
|
|
|
|
property ChatGeneration current: modelData.activeGeneration
|
|
property bool reasoningExpanded: false
|
|
property real savedOffset: NaN
|
|
property bool anchored: false
|
|
|
|
function handleReasoningToggle(expanded: bool, block: Item): void {
|
|
if (!root.rootParent)
|
|
return;
|
|
|
|
if (expanded) {
|
|
root.savedOffset = root.rootParent.contentY - root.y;
|
|
root.anchored = true;
|
|
chatList.anchorItem(block, ChatListView.TopEdge);
|
|
settleTimer.start();
|
|
} else {
|
|
settleTimer.stop();
|
|
root.anchored = false;
|
|
chatList.clearAnchor();
|
|
if (!isNaN(root.savedOffset)) {
|
|
chatList.requestRestore(root.y + root.savedOffset);
|
|
root.savedOffset = NaN;
|
|
}
|
|
}
|
|
}
|
|
|
|
function blockify(segs, errored) {
|
|
const blocks = [];
|
|
for (let i = 0; i < segs.length; i++) {
|
|
const seg = segs[i];
|
|
if (seg.type === LlmSegment.Type.Content) {
|
|
blocks.push({
|
|
kind: "content",
|
|
id: i,
|
|
segments: [seg]
|
|
});
|
|
} else {
|
|
const last = blocks.length ? blocks[blocks.length - 1] : null;
|
|
if (last && last.kind === "process") {
|
|
last.segments.push(seg);
|
|
} else {
|
|
blocks.push({
|
|
kind: "process",
|
|
id: i,
|
|
segments: [seg]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errored)
|
|
blocks.push({
|
|
kind: "error",
|
|
id: segs.length
|
|
});
|
|
|
|
return blocks;
|
|
}
|
|
|
|
hoverEnabled: true
|
|
preventStealing: false
|
|
implicitHeight: layout.implicitHeight + Tokens.padding.medium + (attachments.visible ? attachments.implicitHeight : 0)
|
|
onImplicitHeightChanged: if (root.anchored)
|
|
settleTimer.restart()
|
|
|
|
Timer {
|
|
id: settleTimer
|
|
|
|
interval: Tokens.anim.durations.expressiveDefaultEffects
|
|
repeat: false
|
|
|
|
onTriggered: {
|
|
if (root.anchored) {
|
|
root.anchored = false;
|
|
chatList.clearAnchor();
|
|
}
|
|
}
|
|
}
|
|
|
|
Behavior on current {
|
|
id: changeAnim
|
|
|
|
property var object: targetProperty
|
|
readonly property bool next: {
|
|
const cond = root.modelData.generations.indexOf(current) < root.modelData.generations.indexOf(changeAnim.targetValue);
|
|
return cond;
|
|
}
|
|
|
|
animation: SequentialAnimation {
|
|
ParallelAnimation {
|
|
Anim {
|
|
target: root
|
|
property: "x"
|
|
to: changeAnim.next ? root.width / 4 : -root.width / 4
|
|
from: 0
|
|
type: Anim.FastEffects
|
|
}
|
|
|
|
Anim {
|
|
target: root
|
|
property: "opacity"
|
|
from: 1
|
|
to: 0
|
|
type: Anim.FastEffects
|
|
}
|
|
}
|
|
|
|
PropertyAction {}
|
|
|
|
ParallelAnimation {
|
|
Anim {
|
|
target: root
|
|
property: "x"
|
|
from: changeAnim.next ? -root.width / 4 : root.width / 4
|
|
type: Anim.FastEffects
|
|
to: 0
|
|
}
|
|
|
|
Anim {
|
|
target: root
|
|
property: "opacity"
|
|
from: 0
|
|
type: Anim.FastEffects
|
|
to: 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
GridView {
|
|
id: attachments
|
|
|
|
readonly property int columns: ChatState.isWindow ? 5 : 3
|
|
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
model: ScriptModel {
|
|
objectProp: "path"
|
|
values: {
|
|
const list = [...root.modelData.attachments];
|
|
|
|
if (list.length > 0 && list.length < attachments.columns) {
|
|
while (list.length < attachments.columns)
|
|
list.unshift(null);
|
|
}
|
|
|
|
return list;
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
CustomClippingRect {
|
|
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
|
|
font.pointSize: Tokens.font.size.extraLarge
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Column {
|
|
id: layout
|
|
|
|
anchors.top: attachments.bottom
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
|
|
spacing: Tokens.spacing.medium
|
|
|
|
Repeater {
|
|
id: segmentRep
|
|
|
|
model: ScriptModel {
|
|
values: root.blocks
|
|
objectProp: "id"
|
|
}
|
|
delegate: DelegateChooser {
|
|
role: "kind"
|
|
|
|
DelegateChoice {
|
|
roleValue: "error"
|
|
|
|
delegate: ErrorBubble {
|
|
width: root.width
|
|
current: root.current
|
|
|
|
onDismiss: root.current.dismissError()
|
|
}
|
|
}
|
|
|
|
DelegateChoice {
|
|
roleValue: "process"
|
|
|
|
delegate: ProcessBlock {
|
|
width: root.width
|
|
blocks: root.blocks
|
|
current: root.current
|
|
rootParent: root.rootParent
|
|
|
|
onExpandedChanged: root.handleReasoningToggle(expanded, this)
|
|
}
|
|
}
|
|
|
|
DelegateChoice {
|
|
roleValue: "content"
|
|
|
|
delegate: ContentBubble {
|
|
required property var modelData
|
|
|
|
isUser: root.isUser
|
|
segment: modelData.segments[0]
|
|
width: root.width
|
|
repeater: segmentRep
|
|
message: root.modelData
|
|
current: root.current
|
|
hovered: root.containsMouse
|
|
|
|
onEdit: text => {
|
|
root.modelData.edit(text);
|
|
|
|
if (root.isUser)
|
|
root.modelData.generate();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|