Feat: LLM Chat and Nightlight shader #155

Open
zach wants to merge 21 commits from feat/llm-chat-tab into main
7 changed files with 341 additions and 135 deletions
Showing only changes of commit c33dbc147f - Show all commits
@@ -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 {}
}
+5 -4
View File
@@ -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
+1
View File
@@ -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)