better chat list view + anchor to bottom. sqlite db for chats + LIFO-ordered
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import ZShell.Components
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property ChatGeneration current
|
||||
required property TextEdit edit
|
||||
required property bool hovered
|
||||
readonly property bool isUser: message.role === ChatMessage.Role.User
|
||||
required property ChatMessage message
|
||||
|
||||
implicitHeight: root.current.content !== "" ? editButton.implicitHeight : 0
|
||||
implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small
|
||||
|
||||
ButtonRow {
|
||||
id: generationTools
|
||||
|
||||
enabled: visible
|
||||
visible: !root.isUser
|
||||
|
||||
IconButton {
|
||||
enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== 0
|
||||
icon: "chevron_left"
|
||||
type: IconButton.Tonal
|
||||
|
||||
onClicked: {
|
||||
if (!root.edit.readOnly)
|
||||
root.edit.readOnly = true;
|
||||
root.message.setActiveGeneration(root.message.activeGenerationIndex - 1);
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
enabled: false
|
||||
icon: `${root.message.activeGenerationIndex + 1}`
|
||||
label.font.family: Config.appearance.font.family.sans
|
||||
label.font.pointSize: Tokens.font.size.small
|
||||
type: IconButton.Text
|
||||
}
|
||||
|
||||
IconButton {
|
||||
enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== root.message.generationCount - 1
|
||||
icon: "chevron_right"
|
||||
type: IconButton.Tonal
|
||||
|
||||
onClicked: {
|
||||
if (!root.edit.readOnly)
|
||||
root.edit.readOnly = true;
|
||||
root.message.setActiveGeneration(root.message.activeGenerationIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ButtonRow {
|
||||
id: editTools
|
||||
|
||||
anchors.right: parent.right
|
||||
opacity: !root.current.streaming && root.hovered ? 1 : 0
|
||||
spacing: Tokens.spacing.small
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
icon: "refresh"
|
||||
inactiveColor: Colors.palette.m3secondary
|
||||
inactiveOnColor: Colors.palette.m3onSecondary
|
||||
scale: root.edit.readOnly ? 1 : 0
|
||||
visible: !root.isUser
|
||||
|
||||
Behavior on scale {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
root.message.retry();
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
icon: "content_copy"
|
||||
inactiveColor: Colors.palette.m3secondary
|
||||
inactiveOnColor: Colors.palette.m3onSecondary
|
||||
scale: root.edit.readOnly ? 1 : 0
|
||||
|
||||
Behavior on scale {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: Quickshell.clipboardText = root.current.content
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: editButton
|
||||
|
||||
icon: root.edit.readOnly ? "edit" : "check"
|
||||
inactiveColor: Colors.palette.m3tertiary
|
||||
inactiveOnColor: Colors.palette.m3onTertiary
|
||||
|
||||
onClicked: root.edit.readOnly = !root.edit.readOnly
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
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: qsTr("No messages yet")
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
MouseArea {
|
||||
id: root
|
||||
|
||||
readonly property ChatGeneration current: modelData.activeGeneration
|
||||
required property int index
|
||||
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
||||
required property ChatMessage modelData
|
||||
property bool reasoningExpanded: false
|
||||
property real savedContentY: -1
|
||||
|
||||
function handleReasoningToggle(expanded: bool): void {
|
||||
const view = root.ListView.view;
|
||||
if (!view)
|
||||
return;
|
||||
|
||||
if (expanded) {
|
||||
root.savedContentY = view.contentY;
|
||||
root.reasoningExpanded = true;
|
||||
} else {
|
||||
root.reasoningExpanded = false;
|
||||
if (root.savedContentY !== -1) {
|
||||
restoreAnim.to = root.savedContentY;
|
||||
restoreAnim.restart();
|
||||
root.savedContentY = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: root.isUser ? 0 : Tokens.spacing.extraSmall
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: root.isUser ? Tokens.spacing.extraSmall : 0
|
||||
hoverEnabled: true
|
||||
// Explicit, synchronous height — sum of children, no Layout polish involved.
|
||||
implicitHeight: reasoningLoader.implicitHeight + (reasoningLoader.active ? Tokens.spacing.medium : 0) + bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
|
||||
preventStealing: true
|
||||
|
||||
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
|
||||
|
||||
property: "contentY"
|
||||
target: root.ListView.view
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: reasoningLoader
|
||||
|
||||
readonly property bool shouldBeActive: root.current.reasoningActive || root.current.content === ""
|
||||
|
||||
active: !root.isUser
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Tokens.spacing.extraLarge
|
||||
anchors.top: parent.top
|
||||
|
||||
sourceComponent: Reasoning {
|
||||
current: root.current
|
||||
|
||||
onExpandedChanged: {
|
||||
root.handleReasoningToggle(expanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: bubble
|
||||
|
||||
anchors.left: root.isUser ? undefined : parent.left
|
||||
anchors.right: root.isUser ? parent.right : undefined
|
||||
anchors.top: reasoningLoader.active ? reasoningLoader.bottom : parent.top
|
||||
anchors.topMargin: reasoningLoader.active ? Tokens.spacing.medium : 0
|
||||
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
||||
implicitHeight: root.current.content !== "" ? msgText.contentHeight + Tokens.padding.medium * 2 : 0
|
||||
implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge)
|
||||
radius: Tokens.rounding.medium
|
||||
visible: !root.current.reasoningActive
|
||||
|
||||
TextEditBase {
|
||||
id: msgText
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
animateCursor: false
|
||||
color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
||||
cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
readOnly: true
|
||||
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
|
||||
text: root.current.content
|
||||
textFormat: CustomText.MarkdownText
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key == Qt.Key_Return) {
|
||||
if (!(event.modifiers & Qt.ShiftModifier)) {
|
||||
readOnly = true;
|
||||
event.accepted = true;
|
||||
}
|
||||
} else if (event.key == Qt.Key_Escape) {
|
||||
text = root.current.content;
|
||||
readOnly = true;
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
onEditingFinished: {
|
||||
const old = root.current.content;
|
||||
if (old !== text) {
|
||||
root.modelData.edit(text);
|
||||
|
||||
if (root.isUser)
|
||||
root.modelData.generate();
|
||||
}
|
||||
|
||||
readOnly = true;
|
||||
}
|
||||
onReadOnlyChanged: {
|
||||
if (readOnly) {
|
||||
animateCursor = false;
|
||||
root.forceActiveFocus();
|
||||
textFormat = CustomText.MarkdownText;
|
||||
} else {
|
||||
var raw = root.current.content;
|
||||
textFormat = CustomText.PlainText;
|
||||
text = raw;
|
||||
forceActiveFocus();
|
||||
cursorPosition = text.length;
|
||||
animateCursor = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Actions {
|
||||
id: actionsRow
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
|
||||
anchors.top: bubble.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
current: root.current
|
||||
edit: msgText
|
||||
hovered: root.containsMouse
|
||||
message: root.modelData
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property ChatGeneration current
|
||||
property bool expanded: false
|
||||
readonly property bool isReasoning: root.current.reasoningActive || root.current.content === ""
|
||||
|
||||
implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin
|
||||
|
||||
LoadingIndicator {
|
||||
id: spinnerReasoning
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitSize: collapsedText.implicitHeight
|
||||
opacity: root.isReasoning ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: expandBtn
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.verticalCenter: collapsedText.verticalCenter
|
||||
font.pointSize: Tokens.font.size.large
|
||||
icon: "keyboard_arrow_down"
|
||||
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||
opacity: root.isReasoning ? 0 : 1
|
||||
rotation: root.expanded ? 180 : 0
|
||||
type: IconButton.Text
|
||||
|
||||
Behavior on rotation {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: collapsedText
|
||||
|
||||
anchors.left: root.current.reasoningActive ? spinnerReasoning.right : 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: root.current.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((root.current.reasoningElapsedMs / 1000).toFixed(1))
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
id: expandedRect
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: collapsedText.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
color: Colors.palette.m3surfaceContainerLow
|
||||
implicitHeight: root.expanded ? expandedText.implicitHeight + expandedText.anchors.margins * 2 : -anchors.topMargin
|
||||
implicitWidth: expandedText.implicitWidth + expandedText.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 {
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: expandedText
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.medium
|
||||
color: Colors.palette.m3onSurface
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.current.reasoning
|
||||
textFormat: CustomText.MarkdownText
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user