Feat: LLM Chat and Nightlight shader #155
@@ -1,7 +1,8 @@
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Components
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Modules.Notifications.Sidebar.Chat.Content
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
@@ -15,7 +16,7 @@ Item {
|
||||
signal requestClose
|
||||
|
||||
function scrollToBottom(): void {
|
||||
Qt.callLater(list.positionViewAtBeginning);
|
||||
Qt.callLater(() => listLoader.item?.positionViewAtBeginning());
|
||||
}
|
||||
|
||||
function send(text: string): void {
|
||||
@@ -26,8 +27,6 @@ Item {
|
||||
input.text = "";
|
||||
}
|
||||
|
||||
Component.onCompleted: input.focus = true
|
||||
|
||||
RowLayout {
|
||||
id: header
|
||||
|
||||
@@ -54,10 +53,8 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
VerticalFadeListView {
|
||||
id: list
|
||||
|
||||
property bool userScrolledUp: false
|
||||
Loader {
|
||||
id: listLoader
|
||||
|
||||
anchors.bottom: input.top
|
||||
anchors.bottomMargin: Tokens.spacing.medium
|
||||
@@ -65,58 +62,83 @@ Item {
|
||||
anchors.right: parent.right
|
||||
anchors.top: header.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
cacheBuffer: height * 20
|
||||
clip: true
|
||||
fadeAmount: 0.05
|
||||
fadeThreshold: Tokens.padding.medium
|
||||
model: root.chatData.messagesModel
|
||||
spacing: 0
|
||||
verticalLayoutDirection: VerticalFadeListView.BottomToTop
|
||||
active: root.chatData && root.chatData.loaded
|
||||
|
||||
add: Transition {
|
||||
Anim {
|
||||
from: list.width
|
||||
property: "x"
|
||||
to: 0
|
||||
sourceComponent: ListView {
|
||||
id: list
|
||||
|
||||
property bool userScrolledUp: false
|
||||
|
||||
function scrollToBottom(): void {
|
||||
Qt.callLater(() => positionViewAtBeginning());
|
||||
}
|
||||
}
|
||||
delegate: MessageDelegate {}
|
||||
displaced: Transition {
|
||||
Anim {
|
||||
property: "y"
|
||||
|
||||
cacheBuffer: height * 20
|
||||
clip: true
|
||||
// fadeAmount: 0.05
|
||||
// fadeThreshold: Tokens.padding.medium
|
||||
model: root.chatData.messagesModel
|
||||
spacing: 0
|
||||
rotation: 180
|
||||
|
||||
// add: Transition {
|
||||
// Anim {
|
||||
// from: 10
|
||||
// property: "y"
|
||||
// }
|
||||
// }
|
||||
delegate: MessageDelegate {
|
||||
rotation: 180
|
||||
}
|
||||
}
|
||||
move: Transition {
|
||||
|
||||
// displaced: Transition {
|
||||
// Anim {
|
||||
// property: "y"
|
||||
// }
|
||||
// }
|
||||
// move: Transition {
|
||||
// Anim {
|
||||
// property: "y"
|
||||
// }
|
||||
// }
|
||||
|
||||
Component.onCompleted: {
|
||||
positionViewAtBeginning();
|
||||
forceActiveFocus();
|
||||
}
|
||||
onAtYEndChanged: {
|
||||
if (atYEnd)
|
||||
userScrolledUp = false;
|
||||
}
|
||||
onContentHeightChanged: {
|
||||
if (!userScrolledUp && atYEnd)
|
||||
scrollToBottom();
|
||||
}
|
||||
onCountChanged: {
|
||||
if (!userScrolledUp)
|
||||
scrollToBottom();
|
||||
}
|
||||
onMovingChanged: {
|
||||
if (moving)
|
||||
userScrolledUp = !atYEnd;
|
||||
}
|
||||
|
||||
Anim {
|
||||
property: "y"
|
||||
id: scrollAnim
|
||||
|
||||
property: "contentY"
|
||||
target: list
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
WheelInverter {
|
||||
target: list
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: positionViewAtBeginning()
|
||||
onAtYEndChanged: {
|
||||
if (atYEnd)
|
||||
userScrolledUp = false;
|
||||
}
|
||||
onContentHeightChanged: {
|
||||
if (!userScrolledUp && atYEnd)
|
||||
root.scrollToBottom();
|
||||
}
|
||||
onCountChanged: {
|
||||
if (!userScrolledUp)
|
||||
root.scrollToBottom();
|
||||
}
|
||||
onMovingChanged: {
|
||||
if (moving)
|
||||
userScrolledUp = !atYEnd;
|
||||
}
|
||||
|
||||
Anim {
|
||||
id: scrollAnim
|
||||
|
||||
property: "contentY"
|
||||
target: list
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
// Trigger the load even before we're ready to show the list.
|
||||
Component.onCompleted: if (root.chatData)
|
||||
root.chatData.messagesModel
|
||||
}
|
||||
|
||||
EmptyBackground {
|
||||
@@ -133,7 +155,7 @@ Item {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
implicitHeight: scrollToBottom.implicitHeight
|
||||
implicitWidth: scrollToBottom.implicitWidth
|
||||
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0
|
||||
scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0
|
||||
visible: scale > 0
|
||||
|
||||
Behavior on scale {
|
||||
@@ -157,7 +179,7 @@ Item {
|
||||
type: IconButton.Tonal
|
||||
|
||||
onClicked: {
|
||||
list.positionViewAtBeginning();
|
||||
listLoader.item.positionViewAtBeginning();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ Item {
|
||||
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
|
||||
anchors.right: root.isUser ? parent.right : undefined
|
||||
|
||||
Behavior on implicitHeight {
|
||||
// enabled: root.segment.running
|
||||
|
||||
Anim {}
|
||||
}
|
||||
|
||||
// User messages stay a plain editable text field.
|
||||
TextEditBase {
|
||||
id: msgText
|
||||
@@ -103,12 +109,13 @@ Item {
|
||||
Actions {
|
||||
id: actionsRow
|
||||
|
||||
readonly property bool shouldBeActive: (root.segment.type === LlmSegment.Type.Content) && !root.segment.running && root.repeater.count === (root.index + 1)
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
|
||||
anchors.top: bubble.bottom
|
||||
visible: {
|
||||
return (root.segment.type === LlmSegment.Type.Content) && root.segment.status !== LlmSegment.Status.Running && root.repeater.count === (root.index + 1);
|
||||
}
|
||||
opacity: shouldBeActive ? 1 : 0
|
||||
visible: opacity > 0
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
edit: msgText
|
||||
hovered: root.hovered
|
||||
@@ -116,5 +123,9 @@ Item {
|
||||
segment: root.segment
|
||||
current: root.current
|
||||
message: root.message
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import qs.Services
|
||||
MouseArea {
|
||||
id: root
|
||||
|
||||
readonly property ChatGeneration current: modelData.activeGeneration
|
||||
property ChatGeneration current: modelData.activeGeneration
|
||||
required property int index
|
||||
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
||||
required property ChatMessage modelData
|
||||
@@ -60,22 +60,79 @@ MouseArea {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
preventStealing: false
|
||||
implicitHeight: layout.implicitHeight + Tokens.padding.medium
|
||||
|
||||
Binding {
|
||||
property: "contentY"
|
||||
restoreMode: Binding.RestoreNone
|
||||
target: root.ListView.view
|
||||
value: root.y - Tokens.padding.large * 2
|
||||
when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY)
|
||||
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 / 2 : root.width / 2
|
||||
from: 0
|
||||
}
|
||||
|
||||
Anim {
|
||||
target: root
|
||||
property: "opacity"
|
||||
from: 1
|
||||
to: 0
|
||||
}
|
||||
}
|
||||
|
||||
PropertyAction {}
|
||||
|
||||
ParallelAnimation {
|
||||
Anim {
|
||||
target: root
|
||||
property: "x"
|
||||
from: changeAnim.next ? root.width / 2 : -root.width / 2
|
||||
to: 0
|
||||
}
|
||||
|
||||
Anim {
|
||||
target: root
|
||||
property: "opacity"
|
||||
from: 0
|
||||
to: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Behavior on implicitHeight {
|
||||
// enabled: !root.isUser && root.current.streaming
|
||||
//
|
||||
// Anim {}
|
||||
// }
|
||||
|
||||
onCurrentChanged: {
|
||||
console.log(modelData.generations.indexOf(current));
|
||||
}
|
||||
|
||||
// Binding {
|
||||
// property: "contentY"
|
||||
// restoreMode: Binding.RestoreNone
|
||||
// target: root.ListView.view
|
||||
// value: root.y - Tokens.padding.large * 2
|
||||
// when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY)
|
||||
// }
|
||||
|
||||
Anim {
|
||||
id: restoreAnim
|
||||
|
||||
@@ -91,6 +148,20 @@ MouseArea {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
// onImplicitHeightChanged: console.log("LAYOUT:", layout.implicitHeight)
|
||||
|
||||
// add: Transition {
|
||||
// Anim {
|
||||
// from: 10
|
||||
// property: "y"
|
||||
// }
|
||||
// }
|
||||
// move: Transition {
|
||||
// Anim {
|
||||
// property: "y"
|
||||
// }
|
||||
// }
|
||||
|
||||
Repeater {
|
||||
id: segmentRep
|
||||
|
||||
@@ -105,12 +176,8 @@ MouseArea {
|
||||
roleValue: "process"
|
||||
|
||||
delegate: ProcessBlock {
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
segments: modelData.segments
|
||||
isActive: index === root.blocks.length - 1
|
||||
width: root.width
|
||||
blocks: root.blocks
|
||||
|
||||
onExpandedChanged: root.handleReasoningToggle(expanded)
|
||||
}
|
||||
|
||||
@@ -5,92 +5,106 @@ import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
CustomClippingRect {
|
||||
id: root
|
||||
|
||||
required property var segments
|
||||
required property bool isActive
|
||||
required property int index
|
||||
required property var modelData
|
||||
required property var blocks
|
||||
readonly property var segments: modelData.segments
|
||||
readonly property bool isActive: index === blocks.length - 1
|
||||
property bool expanded: false
|
||||
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1]
|
||||
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
|
||||
|
||||
implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin
|
||||
implicitHeight: expanded ? expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin * 2 : layout.implicitHeight + Tokens.spacing.small
|
||||
|
||||
LoadingIndicator {
|
||||
id: spinnerReasoning
|
||||
|
||||
anchors.centerIn: expandBtn
|
||||
implicitSize: collapsedText.implicitHeight
|
||||
opacity: root.isActive ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: expandBtn
|
||||
RowLayout {
|
||||
id: layout
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: collapsedText.verticalCenter
|
||||
font.pointSize: Tokens.font.size.large
|
||||
anchors.topMargin: 0
|
||||
icon: "keyboard_arrow_down"
|
||||
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||
opacity: root.isActive ? 0 : 1
|
||||
rotation: root.expanded ? 180 : 0
|
||||
type: IconButton.Text
|
||||
|
||||
Behavior on rotation {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: collapsedText
|
||||
|
||||
anchors.left: expandBtn.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 0
|
||||
color: Colors.palette.m3outline
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: {
|
||||
if (root.isActive) {
|
||||
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
|
||||
return qsTr("Using %1...").arg(root.lastSegment.name);
|
||||
return qsTr("Thinking...");
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
Item {
|
||||
implicitHeight: expandBtn.implicitHeight
|
||||
implicitWidth: expandBtn.implicitWidth
|
||||
|
||||
LoadingIndicator {
|
||||
id: spinnerReasoning
|
||||
|
||||
implicitSize: collapsedText.implicitHeight
|
||||
anchors.centerIn: parent
|
||||
opacity: root.isActive ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: expandBtn
|
||||
|
||||
font.pointSize: Tokens.font.size.large
|
||||
icon: "keyboard_arrow_down"
|
||||
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||
anchors.centerIn: parent
|
||||
opacity: root.isActive ? 0 : 1
|
||||
rotation: root.expanded ? 180 : 0
|
||||
type: IconButton.Text
|
||||
|
||||
Behavior on rotation {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1));
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
CustomText {
|
||||
id: collapsedText
|
||||
|
||||
color: Colors.palette.m3outline
|
||||
font.pointSize: Tokens.font.size.small
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: Tokens.spacing.medium
|
||||
text: {
|
||||
if (root.isActive) {
|
||||
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
|
||||
return qsTr("Using %1...").arg(root.lastSegment.name);
|
||||
return qsTr("Thinking...");
|
||||
}
|
||||
return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1));
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
CustomRect {
|
||||
id: expandedRect
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: collapsedText.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
anchors.top: layout.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.topMargin: Tokens.spacing.small
|
||||
anchors.bottomMargin: Tokens.spacing.small
|
||||
color: Colors.palette.m3surfaceContainerLow
|
||||
implicitHeight: root.expanded ? expandedContent.contentHeight + expandedContent.anchors.margins * 2 : 0
|
||||
implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2
|
||||
opacity: root.expanded ? 1 : 0
|
||||
radius: Tokens.rounding.medium
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
qml_module(ZShell-components
|
||||
URI ZShell.Components
|
||||
SOURCES
|
||||
lazylistview.hpp lazylistview.cpp
|
||||
SOURCES
|
||||
lazylistview.hpp lazylistview.cpp
|
||||
wavyline.hpp wavyline.cpp
|
||||
buttonrow.hpp buttonrow.cpp
|
||||
carouselview.hpp carouselview.cpp
|
||||
LIBRARIES
|
||||
Qt::Quick
|
||||
wheelinverter.hpp
|
||||
LIBRARIES
|
||||
Qt::Quick
|
||||
)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <QQuickItem>
|
||||
#include <QQuickWindow>
|
||||
#include <QWheelEvent>
|
||||
#include <QCoreApplication>
|
||||
#include <QPointer>
|
||||
#include <QDebug>
|
||||
|
||||
namespace ZShell::components {
|
||||
|
||||
class WheelInverter : public QQuickItem {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
|
||||
Q_PROPERTY(
|
||||
QQuickItem* target READ target WRITE setTarget NOTIFY targetChanged)
|
||||
|
||||
public:
|
||||
using QQuickItem::QQuickItem;
|
||||
|
||||
QQuickItem* target() const { return m_target; }
|
||||
|
||||
void setTarget(QQuickItem* target) {
|
||||
if (m_target == target) return;
|
||||
|
||||
if (m_target) m_target->removeEventFilter(this);
|
||||
|
||||
m_target = target;
|
||||
|
||||
if (m_target) m_target->installEventFilter(this);
|
||||
|
||||
emit targetChanged();
|
||||
}
|
||||
|
||||
protected:
|
||||
class InvertedWheelEvent : public QWheelEvent {
|
||||
public:
|
||||
using QWheelEvent::QWheelEvent;
|
||||
|
||||
bool invertedByWheelInverter = true;
|
||||
};
|
||||
|
||||
bool eventFilter(QObject* watched, QEvent* event) override {
|
||||
if (watched != m_target || event->type() != QEvent::Wheel)
|
||||
return QQuickItem::eventFilter(watched, event);
|
||||
|
||||
auto* wheel = static_cast<QWheelEvent*>(event);
|
||||
|
||||
const bool inverted = dynamic_cast<InvertedWheelEvent*>(wheel) !=
|
||||
nullptr;
|
||||
|
||||
if (inverted) return false;
|
||||
|
||||
auto* window = m_target ? m_target->window() : nullptr;
|
||||
if (!window) {
|
||||
qInfo() << "[WheelInverter] no window";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QPointF windowPos =
|
||||
window->mapFromGlobal(wheel->globalPosition());
|
||||
|
||||
auto* invertedEvent = new InvertedWheelEvent(
|
||||
windowPos,
|
||||
wheel->globalPosition(),
|
||||
-wheel->pixelDelta(),
|
||||
-wheel->angleDelta(),
|
||||
wheel->buttons(),
|
||||
wheel->modifiers(),
|
||||
wheel->phase(),
|
||||
wheel->inverted(),
|
||||
wheel->source(),
|
||||
wheel->pointingDevice());
|
||||
|
||||
invertedEvent->setTimestamp(wheel->timestamp());
|
||||
|
||||
QCoreApplication::postEvent(window, invertedEvent);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
signals:
|
||||
void targetChanged();
|
||||
|
||||
private:
|
||||
QPointer<QQuickItem> m_target;
|
||||
};
|
||||
|
||||
} // namespace ZShell::components
|
||||
@@ -23,6 +23,7 @@ class ChatSession : public QObject {
|
||||
Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT)
|
||||
Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged)
|
||||
Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged)
|
||||
Q_PROPERTY(bool loaded READ isLoaded NOTIFY loaded)
|
||||
Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged)
|
||||
Q_PROPERTY(
|
||||
ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT)
|
||||
|
||||
Reference in New Issue
Block a user