11 Commits
Author SHA1 Message Date
zach 82ca5b5335 fix: properly convert vram mib -> kib & generic gpu vram reading 2026-09-13 22:25:58 +02:00
zach bd699e72a7 fix: polkit width 2026-09-04 19:43:13 +02:00
zach ed7ec326b1 fix: idlemonitors list value 2026-09-03 16:15:55 +02:00
zach 04869656aa Merge pull request 'changed old appearance vars for the lockscreen + warning hunting for the polkit' (#154) from fix/lockscreen-font into main
Reviewed-on: #154
Reviewed-by: AramJonghu <aramjonghu@tutamail.com>
Reviewed-by: zach <zach@brohn.se>
2026-08-31 00:14:06 +02:00
zach 2f6847ce0e Merge branch 'main' into fix/lockscreen-font
C++ / fmt (pull_request) Successful in 3s
JS/TS / fmt (pull_request) Successful in 14s
JS/TS / lint (pull_request) Successful in 20s
Python / static (pull_request) Successful in 58s
Rust / fmt (pull_request) Successful in 55s
C++ / build (pull_request) Successful in 2m31s
Rust / build (pull_request) Successful in 2m16s
Rust / clippy (pull_request) Successful in 1m33s
Python / verify (pull_request) Successful in 2m53s
C++ / clang-tidy (pull_request) Successful in 4m5s
2026-08-31 00:13:16 +02:00
zach e5e52802de test aur 2026-08-30 23:49:53 +02:00
zach 42f02f4dd2 test aur 2026-08-30 23:33:50 +02:00
zach 9df3a64815 fix: lowercase battery device state 2026-08-30 22:20:39 +02:00
Inorishio 9370284c6b changed old appearance vars for the lockscreen + warning hunting for the polkit
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Successful in 14s
JS/TS / lint (pull_request) Successful in 16s
Python / static (pull_request) Successful in 1m14s
Rust / fmt (pull_request) Successful in 1m8s
Rust / build (pull_request) Successful in 2m14s
C++ / build (pull_request) Successful in 2m36s
Rust / clippy (pull_request) Successful in 1m15s
Python / verify (pull_request) Successful in 3m19s
C++ / clang-tidy (pull_request) Successful in 4m1s
2026-08-30 16:35:20 +02:00
zach 5598f4f435 README.md 2026-08-23 17:01:27 +02:00
zach 7e02c87b91 README.md 2026-08-23 17:00:41 +02:00
90 changed files with 110 additions and 8461 deletions
+1 -2
View File
@@ -3,9 +3,8 @@ FunctionsSpacing=true
IndentWidth=4 IndentWidth=4
MaxColumnWidth=-1 MaxColumnWidth=-1
NewlineType=native NewlineType=native
GroupAttributesTogether=true NormalizeOrder=true
ObjectsSpacing=true ObjectsSpacing=true
SemicolonRule=always SemicolonRule=always
SingleLineEmptyObjects=true
SortImports=false SortImports=false
UseTabs=true UseTabs=true
+1 -4
View File
@@ -28,9 +28,6 @@ Flickable {
interval: 10 interval: 10
running: root.doneFakeFlick running: root.doneFakeFlick
onTriggered: { onTriggered: root.doneFakeFlick = false
root.doneFakeFlick = false;
root.returnToBounds();
}
} }
} }
+14 -17
View File
@@ -10,27 +10,24 @@ ListView {
maximumFlickVelocity: 3000 maximumFlickVelocity: 3000
rebound: Transition { rebound: Transition {
// onRunningChanged: { onRunningChanged: {
// if (!running && !root.doneFakeFlick) { if (!running && !root.doneFakeFlick) {
// root.doneFakeFlick = true; root.doneFakeFlick = true;
// root.flick(1, 1); root.flick(1, 1);
// root.flick(-1, -1); root.flick(-1, -1);
// Qt.callLater(() => root.cancelFlick()); Qt.callLater(() => root.cancelFlick());
// } }
// } }
Anim { Anim {
properties: "x,y" properties: "x,y"
type: Anim.DefaultEffects
} }
} }
// Timer { Timer {
// interval: 10 interval: 10
// running: root.doneFakeFlick running: root.doneFakeFlick
//
// onTriggered: { onTriggered: root.doneFakeFlick = false
// root.doneFakeFlick = false; }
// }
// }
} }
-99
View File
@@ -1,99 +0,0 @@
import QtQuick
import QtQuick.Templates
import ZShell.Config
import qs.Services
TextEdit {
id: root
property bool animateCursor: true
property alias cursor: cursor
color: Colors.palette.m3onSurface
cursorVisible: !readOnly
font.pointSize: Tokens.font.size.small
renderType: TextField.NativeRendering
selectedTextColor: color
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
verticalAlignment: TextInput.AlignVCenter
Behavior on color {
CAnim {
}
}
cursorDelegate: Item {
}
Behavior on selectionColor {
CAnim {
}
}
CustomRect {
id: cursor
property bool disableBlink
color: Colors.palette.m3primary
implicitHeight: root.cursorRectangle.height
implicitWidth: 1.5
radius: Tokens.rounding.largeIncreased
x: root.cursorRectangle.x
y: root.cursorRectangle.y
Behavior on opacity {
Anim {
type: Anim.StandardSmall
}
}
Behavior on x {
enabled: root.animateCursor
Anim {
duration: Tokens.anim.durations.expressiveFastEffects
easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1]
}
}
Behavior on y {
enabled: root.animateCursor
Anim {
duration: Tokens.anim.durations.expressiveFastEffects
easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1]
}
}
Connections {
function onCursorPositionChanged(): void {
if (root.activeFocus && root.cursorVisible) {
cursor.opacity = 1;
cursor.disableBlink = true;
enableBlink.restart();
}
}
target: root
}
Timer {
id: enableBlink
interval: 500
onTriggered: cursor.disableBlink = false
}
Timer {
interval: 500
repeat: true
running: root.activeFocus && root.cursorVisible && !cursor.disableBlink
triggeredOnStart: true
onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1
}
Binding {
cursor.opacity: 0
when: !root.activeFocus || !root.cursorVisible
}
}
}
+2 -3
View File
@@ -8,7 +8,6 @@ CustomListView {
property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1
property real fadeAmount: 0.1 property real fadeAmount: 0.1
property real fadeThreshold: 0.0
readonly property bool horizontal: orientation === ListView.Horizontal readonly property bool horizontal: orientation === ListView.Horizontal
property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1
@@ -29,11 +28,11 @@ CustomListView {
} }
function marginEnd(): real { function marginEnd(): real {
return horizontal ? rightMargin - fadeThreshold : bottomMargin - fadeThreshold; return horizontal ? rightMargin : bottomMargin;
} }
function marginStart(): real { function marginStart(): real {
return horizontal ? leftMargin - fadeThreshold : topMargin - fadeThreshold; return horizontal ? leftMargin : topMargin;
} }
function overshootStart(): real { function overshootStart(): real {
+1
View File
@@ -140,6 +140,7 @@ Item {
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.right: parent.right anchors.right: parent.right
popouts: popouts
sidebar: sidebar sidebar: sidebar
visibilities: root.visibilities visibilities: root.visibilities
} }
+6 -3
View File
@@ -63,10 +63,12 @@ CustomWindow {
name: "Bar" name: "Bar"
Behavior on fsTransitionProg { Behavior on fsTransitionProg {
Anim {} Anim {
}
} }
Behavior on surfaceColor { Behavior on surfaceColor {
CAnim {} CAnim {
}
} }
contentItem.Keys.onEscapePressed: { contentItem.Keys.onEscapePressed: {
@@ -291,7 +293,8 @@ CustomWindow {
y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0) y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0)
Behavior on extraExtent { Behavior on extraExtent {
Anim {} Anim {
}
} }
} }
+16 -10
View File
@@ -126,29 +126,33 @@ Item {
focus: true focus: true
opacity: 0 opacity: 0
Behavior on opacity {
Anim {
duration: 300
}
}
Behavior on rsx { Behavior on rsx {
enabled: !selectArea.pressed && root.mode === "select" enabled: !selectArea.pressed && root.mode === "select"
ExAnim {} ExAnim {
}
} }
Behavior on rsy { Behavior on rsy {
enabled: !selectArea.pressed && root.mode === "select" enabled: !selectArea.pressed && root.mode === "select"
ExAnim {} ExAnim {
}
} }
Behavior on sh { Behavior on sh {
enabled: !selectArea.pressed && root.mode === "select" enabled: !selectArea.pressed && root.mode === "select"
ExAnim {} ExAnim {
}
} }
Behavior on sw { Behavior on sw {
enabled: !selectArea.pressed && root.mode === "select" enabled: !selectArea.pressed && root.mode === "select"
ExAnim {} ExAnim {
}
Behavior on opacity {
Anim {
duration: 300
} }
} }
@@ -348,7 +352,8 @@ Item {
y: selectionRect.y - root.realBorderWidth y: selectionRect.y - root.realBorderWidth
Behavior on border.color { Behavior on border.color {
Anim {} Anim {
}
} }
} }
@@ -397,5 +402,6 @@ Item {
onUndoRequested: annotations.undo() onUndoRequested: annotations.undo()
} }
component ExAnim: Anim {} component ExAnim: Anim {
}
} }
@@ -40,7 +40,7 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
sourceComponent: BatteryIcon { sourceComponent: BatteryIcon {
devState: Battery.deviceStateString devState: Battery.deviceStateString.toLowerCase()
percentage: Battery.currentPerc percentage: Battery.currentPerc
} }
} }
+6 -6
View File
@@ -29,7 +29,7 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3secondary color: Colors.palette.m3secondary
font.bold: true font.bold: true
font.family: Appearance.font.family.clock font.family: Config.appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: Time.hourStr text: Time.hourStr
} }
@@ -38,7 +38,7 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3primary color: Colors.palette.m3primary
font.bold: true font.bold: true
font.family: Appearance.font.family.clock font.family: Config.appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: ":" text: ":"
} }
@@ -47,7 +47,7 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3secondary color: Colors.palette.m3secondary
font.bold: true font.bold: true
font.family: Appearance.font.family.clock font.family: Config.appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: Time.minuteStr text: Time.minuteStr
} }
@@ -58,7 +58,7 @@ ColumnLayout {
Layout.topMargin: -Tokens.padding.large * 2 Layout.topMargin: -Tokens.padding.large * 2
color: Colors.palette.m3tertiary color: Colors.palette.m3tertiary
font.bold: true font.bold: true
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale)
text: Time.format("dddd, d MMMM yyyy") text: Time.format("dddd, d MMMM yyyy")
} }
@@ -226,7 +226,7 @@ ColumnLayout {
anchors.right: parent.right anchors.right: parent.right
animateProp: "opacity" animateProp: "opacity"
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
horizontalAlignment: Qt.AlignHCenter horizontalAlignment: Qt.AlignHCenter
lineHeight: 1.2 lineHeight: 1.2
opacity: shouldBeVisible && !message.msg ? 1 : 0 opacity: shouldBeVisible && !message.msg ? 1 : 0
@@ -295,7 +295,7 @@ ColumnLayout {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
color: Colors.palette.m3error color: Colors.palette.m3error
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
horizontalAlignment: Qt.AlignHCenter horizontalAlignment: Qt.AlignHCenter
opacity: 0 opacity: 0
+5 -4
View File
@@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import QtQuick
import ZShell.Internal import ZShell.Internal
import ZShell.Config import ZShell.Config
import qs.Helpers import qs.Helpers
@@ -30,12 +31,12 @@ Scope {
Quickshell.execDetached(action); Quickshell.execDetached(action);
} }
LidWatcher { LidWatcher {
onAboutToSleep: root.lock.lock.locked = true onAboutToSleep: root.lock.lock.locked = true
} }
Variants { Variants {
model: Config.general.idle.timeouts model: Config.general.idle.timeouts.values
IdleMonitor { IdleMonitor {
required property var modelData required property var modelData
+1 -1
View File
@@ -41,7 +41,7 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
animate: true animate: true
color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.normal font.pointSize: Tokens.font.size.normal
opacity: root.buffer ? 0 : 1 opacity: root.buffer ? 0 : 1
text: { text: {
+2 -2
View File
@@ -24,7 +24,7 @@ ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
color: Colors.palette.m3outline color: Colors.palette.m3outline
elide: Text.ElideRight elide: Text.ElideRight
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.weight: 500 font.weight: 500
text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications") text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications")
} }
@@ -66,7 +66,7 @@ ColumnLayout {
CustomText { CustomText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: Colors.palette.m3outlineVariant color: Colors.palette.m3outlineVariant
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.large font.pointSize: Tokens.font.size.large
font.weight: 500 font.weight: 500
text: qsTr("No Notifications") text: qsTr("No Notifications")
@@ -1,183 +0,0 @@
import ZShell.Config
import ZShell.Llm
import QtQuick
import QtQuick.Layouts
import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Components
import qs.Services
Item {
id: root
property ChatSession chatData
property bool following: true
signal requestClose
function scrollToBottom(): void {
Qt.callLater(list.positionViewAtBeginning);
}
function send(text: string): void {
if (text.trim() === "")
return;
following = true;
chatData.sendMessage(text);
input.text = "";
}
Component.onCompleted: input.focus = true
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
onClicked: root.requestClose()
}
CustomText {
Layout.fillWidth: true
elide: Text.ElideRight
font.pointSize: Tokens.font.size.larger
text: qsTr(root.chatData.title)
}
}
VerticalFadeListView {
id: list
property bool userScrolledUp: false
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
cacheBuffer: height * 20
clip: true
fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel
spacing: 0
verticalLayoutDirection: VerticalFadeListView.BottomToTop
add: Transition {
Anim {
from: list.width
property: "x"
to: 0
}
}
delegate: MessageDelegate {}
displaced: Transition {
Anim {
property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
}
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
}
}
EmptyBackground {
id: emptyState
anchors.fill: parent
spacing: Tokens.spacing.small
visible: !root.chatData
}
Item {
anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.extraLarge
anchors.horizontalCenter: parent.horizontalCenter
implicitHeight: scrollToBottom.implicitHeight
implicitWidth: scrollToBottom.implicitWidth
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0
visible: scale > 0
Behavior on scale {
Anim {}
}
Elevation {
anchors.fill: parent
level: 2
radius: scrollToBottom.radius
}
IconButton {
id: scrollToBottom
anchors.centerIn: parent
font.pointSize: Tokens.font.size.large
icon: "arrow_downward"
isRound: true
padding: Tokens.padding.extraSmall
type: IconButton.Tonal
onClicked: {
list.positionViewAtBeginning();
}
}
}
ChatInput {
id: input
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
bg.border.color: Colors.palette.m3outlineVariant
bg.color: Colors.tPalette.m3surfaceContainerLowest
font.pointSize: Tokens.font.size.normal
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
placeholderText: qsTr("Send a message")
sendIcon.font.pointSize: Tokens.font.size.large
sendIcon.icon: "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall
onAccepted: root.send(text)
onSendPressed: root.send(text)
}
}
@@ -1,311 +0,0 @@
import QtQuick
import ZShell.Components
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
CustomClippingRect {
id: root
property bool expanded: false
required property ChatSession modelData
signal clicked(content: ChatSession)
signal remove(content: ChatSession)
color: Colors.tPalette.m3surfaceContainer
implicitHeight: {
let h = 0;
h += infoContainer.implicitHeight;
if (expanded)
h += body.implicitHeight + body.topMargin + actionRow.implicitHeight + actionRow.anchors.topMargin;
else
h += preview.implicitHeight + preview.topMargin;
h += Tokens.padding.small;
const icon = chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
if (h < icon)
return icon;
return h;
}
radius: Tokens.rounding.large
Behavior on implicitHeight {
enabled: titleText.readOnly
Anim {}
}
MaterialIcon {
id: chatIcon
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.large
anchors.top: parent.top
anchors.topMargin: Tokens.padding.large
font.pointSize: Tokens.font.size.extraLarge
text: root.modelData.icon || "check"
}
Item {
id: infoContainer
anchors.left: chatIcon.right
anchors.leftMargin: Tokens.spacing.medium
anchors.top: parent.top
anchors.right: expandBtn.left
anchors.rightMargin: Tokens.spacing.medium
implicitHeight: {
let h = 0;
h += title.implicitHeight + title.anchors.topMargin;
h += timestamp.implicitHeight + timestamp.anchors.topMargin;
if (root.expanded)
h += created.implicitHeight + created.anchors.topMargin;
return h;
}
CustomRect {
id: title
anchors.left: parent.left
anchors.topMargin: Tokens.padding.extraSmall
anchors.top: parent.top
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 1)
implicitHeight: titleText.implicitHeight + Tokens.padding.extraSmall * 2
radius: Tokens.rounding.full
implicitWidth: titleText.implicitWidth + Tokens.padding.large * 2
opacity: titleText.readOnly ? 0 : 1
Behavior on opacity {
Anim {}
}
}
TextEditBase {
id: titleText
anchors.left: parent.left
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
anchors.top: parent.top
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
font.pointSize: Tokens.font.size.normal
text: root.modelData.title
readOnly: true
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on anchors.topMargin {
Anim {}
}
onEditingFinished: root.modelData.title = text
onReadOnlyChanged: {
if (!readOnly) {
this.forceActiveFocus();
cursorPosition = text.length;
} else {
root.forceActiveFocus();
}
}
}
CustomText {
id: created
anchors.top: title.bottom
anchors.topMargin: Tokens.spacing.extraSmall
anchors.left: parent.left
anchors.right: parent.right
font.pointSize: Tokens.font.size.small
color: Colors.palette.m3onSurfaceVariant
text: qsTr("Created on: %1").arg(root.modelData.createdAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP"))
opacity: root.expanded ? 1 : 0
Behavior on opacity {
Anim {}
}
}
CustomText {
id: updated
anchors.top: created.bottom
anchors.topMargin: Tokens.spacing.small
anchors.left: parent.left
font.pointSize: Tokens.font.size.small
color: Colors.palette.m3onSurfaceVariant
text: qsTr("Updated on: ")
opacity: root.expanded ? 1 : 0
Behavior on opacity {
Anim {}
}
}
CustomText {
id: timestamp
anchors.left: parent.left
anchors.top: titleText.bottom
anchors.topMargin: Tokens.spacing.extraSmall
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
target: timestamp
anchors.left: updated.right
anchors.top: undefined
anchors.verticalCenter: updated.verticalCenter
}
}
transitions: Transition {
AnchorAnim {}
}
}
}
CustomText {
id: preview
readonly property int topMargin: Tokens.spacing.extraSmall
anchors.left: chatIcon.right
anchors.margins: Tokens.spacing.medium
anchors.right: parent.right
y: infoContainer.implicitHeight + topMargin
elide: Text.ElideRight
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
maximumLineCount: 1
opacity: !root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? ""
Behavior on y {
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
CustomText {
id: body
readonly property int topMargin: root.expanded ? Tokens.spacing.medium : Tokens.spacing.extraSmall
anchors.left: chatIcon.right
anchors.margins: Tokens.spacing.medium
anchors.right: parent.right
y: infoContainer.implicitHeight + topMargin
animate: true
color: Colors.palette.m3onSurfaceVariant
maximumLineCount: 5
opacity: root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? ""
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on y {
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
StateLayer {
id: sLayer
property int startY
onClicked: {
root.clicked(root.modelData);
}
}
ButtonRow {
id: actionRow
anchors.top: body.bottom
anchors.left: chatIcon.right
anchors.right: parent.right
anchors.topMargin: Tokens.spacing.small
anchors.leftMargin: Tokens.spacing.medium
anchors.rightMargin: Tokens.padding.medium
spacing: Tokens.spacing.medium
IconButton {
icon: "close"
fillWidth: true
shapeMorph: true
isRound: true
font.pointSize: Tokens.font.size.large
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
inactiveOnColor: Colors.palette.m3onSurfaceVariant
onClicked: root.remove(root.modelData)
}
IconButton {
icon: titleText.readOnly ? "edit" : "check"
label.animate: true
isToggle: true
fillWidth: true
shapeMorph: true
font.pointSize: Tokens.font.size.large
isRound: true
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
inactiveOnColor: Colors.palette.m3onSurfaceVariant
activeColor: Colors.palette.m3secondary
activeOnColor: Colors.palette.m3onSecondary
checked: !titleText.readOnly
onClicked: titleText.readOnly = !titleText.readOnly
}
}
CustomRect {
id: expandBtn
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Tokens.padding.small
implicitHeight: expandIcon.implicitHeight
implicitWidth: expandIcon.implicitHeight
radius: Tokens.rounding.full
color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 3)
StateLayer {
onClicked: root.expanded = !root.expanded
}
MaterialIcon {
id: expandIcon
anchors.centerIn: parent
anchors.verticalCenterOffset: root.expanded ? -1 : 1
rotation: root.expanded ? 180 : 0
text: "expand_more"
Behavior on anchors.verticalCenterOffset {
Anim {}
}
Behavior on rotation {
Anim {}
}
}
}
}
@@ -1,87 +0,0 @@
import QtQuick
import ZShell.Config
import qs.Components
import qs.Services
TextFieldBase {
id: root
readonly property alias bg: bg
readonly property alias sendIcon: sendIcon
signal sendPressed
bottomPadding: Tokens.padding.large
leftPadding: Tokens.padding.extraLarge
rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small
topPadding: Tokens.padding.large
wrapMode: TextFieldBase.Wrap
background: CustomRect {
id: bg
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainer
radius: Tokens.rounding.extraLarge
StateLayer {
id: stateLayer
cursorShape: Qt.IBeamCursor
enabled: !root.activeFocus
manualPressOverride: tapHandler.pressed
onClicked: root.focus = true
}
}
onPressed: {
if (stateLayer.enabled)
stateLayer.press(stateLayer.mouseX, stateLayer.mouseY);
}
CustomText {
id: placeholder
anchors.left: parent.left
anchors.leftMargin: root.leftPadding
anchors.verticalCenter: parent.verticalCenter
color: root.placeholderTextColor
font: root.font
opacity: root.text ? 0 : 1
text: root.placeholderText
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
IconButton {
id: sendIcon
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.small
anchors.verticalCenter: parent.verticalCenter
enabled: root.text
icon: "upward_arrow"
opacity: root.text ? 1 : 0
radius: Tokens.rounding.full
radiusMorph: false
stateLayer.hoverEnabled: enabled
type: IconButton.Text
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
onClicked: root.sendPressed()
}
TapHandler {
id: tapHandler
}
}
@@ -1,79 +0,0 @@
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import QtQuick.Layouts
import ZShell.Llm
import ZShell.Config
import qs.Services
import qs.Components
Item {
id: root
property alias list: list
property alias model: list.model
signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession)
signal newChatRequest
CustomText {
id: header
anchors.left: parent.left
anchors.margins: Tokens.padding.extraSmall
anchors.right: parent.right
anchors.top: parent.top
color: Colors.palette.m3outline
font.family: "CaskaydiaCove NF"
font.pointSize: 13
font.weight: 500
text: qsTr("%1 Chat%2").arg(list.count).arg(list.count > 1 ? "s" : "")
}
CustomListView {
id: list
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.medium
cacheBuffer: height * 2
clip: true
spacing: Tokens.spacing.medium
delegate: ChatDelegate {
id: chat
implicitWidth: ListView.view.width
onClicked: content => root.loadChatRequest(content)
onRemove: content => root.deleteChatRequest(content)
}
}
IconButton {
id: newChatBtn
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.small
anchors.right: parent.right
font.pointSize: Math.round(18 * 1.2)
icon: "add"
padding: 8
radius: Tokens.rounding.full
onClicked: {
root.newChatRequest();
}
Elevation {
anchors.fill: parent
level: newChatBtn.stateLayer.containsMouse ? 4 : 3
radius: parent.radius
z: -1
}
}
}
@@ -1,99 +0,0 @@
pragma ComponentBehavior: Bound
import ZShell.Config
import ZShell.Llm
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Helpers
import qs.Modules.Notifications.Sidebar
import qs.Components
import qs.Services
Item {
id: root
anchors.fill: parent
anchors.margins: Tokens.padding.small
Component.onCompleted: {
if (ChatState.inChat) {
stackLoader.item?.push(chatList);
stackLoader.item?.push(chatContent, {
"chatData": ChatState.chatSession
});
} else
stackLoader.item?.push(chatList);
}
Loader {
id: stackLoader
active: !ChatState.isWindow || root.width <= (ChatState.screen.width / 4)
anchors.fill: parent
sourceComponent: StackView {
id: stack
}
}
Loader {
id: sidebarViewLoader
active: ChatState.isWindow && root.width > (ChatState.screen.width / 4)
anchors.fill: parent
sourceComponent: SidebarView {}
}
// IconButton {
// anchors.right: parent.right
// icon: "close"
// visible: !ChatState.isWindow
//
// onClicked: {
// Detach.create();
// Visibilities.getForActive().sidebar = false;
// }
// }
Component {
id: chatList
ChatList {
model: ScriptModel {
values: Chat.chats.values
}
onDeleteChatRequest: chat => {
Chat.chats.remove(chat);
}
onLoadChatRequest: chat => {
stackLoader.item?.push(chatContent, {
"chatData": chat
});
ChatState.inChat = true;
ChatState.chatSession = chat;
}
onNewChatRequest: {
const data = Chat.chats.insert();
stackLoader.item?.push(chatContent, {
"chatData": data
});
ChatState.inChat = true;
ChatState.chatSession = data;
}
}
}
Component {
id: chatContent
ChatContent {
onRequestClose: {
stackLoader.item?.pop();
ChatState.inChat = false;
}
}
}
}
@@ -1,14 +0,0 @@
pragma Singleton
import QtQuick
import Quickshell
import ZShell.Llm
Singleton {
id: root
property ChatSession chatSession
property bool inChat: false
property bool isWindow: false
property ShellScreen screen
}
@@ -1,115 +0,0 @@
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
required property bool isUser
required property ChatMessage message
required property LlmSegment segment
implicitHeight: editButton.implicitHeight
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
visible: root.isUser
onClicked: {
root.edit.readOnly = !root.edit.readOnly;
}
}
}
}
@@ -1,157 +0,0 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
CustomClippingRect {
id: root
required property string language
required property string code
property bool copied: false
property color codeColor: Colors.palette.m3onSurfaceVariant
property color codeBackgroundColor: Colors.palette.m3surfaceContainerLow
property color codeHeaderColor: Colors.palette.m3outline
property color codeAccentColor: Colors.palette.m3primary
// Highlighter spans for the current code; refreshed when the code or
// its language changes.
property var codeSpans: []
function refresh() {
codeSpans = CodeHighlighter.highlight(root.code, root.language);
}
function roleColor(kind) {
switch (kind) {
case "comment":
return Colors.palette.m3outline;
case "string":
return Colors.palette.m3tertiary;
case "string.key":
return Colors.palette.m3secondary;
case "number":
case "constant":
return Colors.palette.m3tertiaryFixed;
case "keyword":
return root.codeAccentColor;
case "type":
return Colors.palette.m3secondary;
case "function":
case "method":
return Colors.palette.m3onSurface;
case "macro":
case "preproc":
return Colors.palette.m3secondaryContainer;
case "operator":
case "property":
return root.codeColor;
case "label":
case "attribute":
return Colors.palette.m3tertiaryFixedDim;
default:
return root.codeColor;
}
}
function escapeHtml(text) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Wraps the code in <font color> tags following the highlighter spans.
function highlightedHtml(code, spans) {
let out;
if (!spans.length) {
out = escapeHtml(code);
} else {
out = "";
let pos = 0;
for (let i = 0; i < spans.length; i++) {
const span = spans[i];
if (span.start > pos)
out += escapeHtml(code.slice(pos, span.start));
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(span.start, span.start + span.length)) + "</font>";
pos = span.start + span.length;
}
if (pos < code.length)
out += escapeHtml(code.slice(pos));
}
// RichText collapses HTML whitespace: break newlines, and protect
// line-leading indentation with nbsp (internal spaces stay normal
// so long lines can still wrap).
return out
.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, "&nbsp;"))
.replace(/\n/g, "<br>");
}
implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin
implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeText.implicitHeight + codeText.anchors.margins * 2
color: root.codeBackgroundColor
radius: Tokens.rounding.medium
onLanguageChanged: refresh()
onCodeChanged: refresh()
CustomText {
id: codeText
anchors.left: parent.left
anchors.top: headerRow.bottom
anchors.right: parent.right
anchors.margins: Tokens.padding.small
color: root.codeColor
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
textFormat: Text.RichText
text: root.highlightedHtml(root.code, root.codeSpans)
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
RowLayout {
id: headerRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Tokens.padding.small
anchors.leftMargin: Tokens.padding.small
anchors.rightMargin: Tokens.padding.small
spacing: Tokens.spacing.small
CustomText {
Layout.alignment: Qt.AlignVCenter
color: root.codeHeaderColor
font.pointSize: Tokens.font.size.small
text: root.language
visible: root.language.length > 0
}
Item {
Layout.fillWidth: true
}
IconButton {
icon: root.copied ? "check" : "content_copy"
inactiveColor: "transparent"
inactiveOnColor: root.codeHeaderColor
type: IconButton.Text
onClicked: {
Quickshell.clipboardText = root.code;
root.copied = true;
copyResetTimer.restart();
}
Timer {
id: copyResetTimer
interval: 1500
onTriggered: root.copied = false
}
}
}
}
@@ -1,120 +0,0 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Llm
import ZShell.Config
import qs.Components
import qs.Services
Item {
id: root
required property bool isUser
required property LlmSegment segment
required property bool hovered
required property Repeater repeater
required property int index
required property ChatMessage message
required property ChatGeneration current
// Widest the bubble may grow to; assistant bubbles always use it so
// the markdown blocks have a deterministic width to wrap against.
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
signal edit(text: string)
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
CustomRect {
id: bubble
radius: Tokens.rounding.medium
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + Tokens.padding.medium * 2
anchors.right: root.isUser ? parent.right : undefined
// User messages stay a plain editable text field.
TextEditBase {
id: msgText
visible: root.isUser
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.isUser ? root.segment.text : ""
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.segment.text;
readOnly = true;
event.accepted = true;
}
}
onEditingFinished: {
const old = root.segment.text;
if (old !== text)
root.edit(text);
readOnly = true;
}
onReadOnlyChanged: {
if (readOnly) {
animateCursor = false;
root.forceActiveFocus();
textFormat = CustomText.MarkdownText;
} else {
var raw = root.segment.text;
textFormat = CustomText.PlainText;
text = raw;
forceActiveFocus();
cursorPosition = text.length;
animateCursor = true;
}
}
}
// Assistant content: parsed markdown blocks.
MarkdownBlocks {
id: blocks
visible: !root.isUser
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
blocks: root.segment.markdown
}
}
Actions {
id: actionsRow
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);
}
anchors.topMargin: Tokens.spacing.medium
edit: msgText
hovered: root.hovered
isUser: root.isUser
segment: root.segment
current: root.current
message: root.message
}
}
@@ -1,32 +0,0 @@
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
}
}
@@ -1,88 +0,0 @@
import QtQuick
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
Column {
id: root
// Top-level markdown blocks (see MarkdownParser / LlmSegment.markdown).
required property var blocks
spacing: Tokens.spacing.small
Repeater {
id: blockRep
model: root.blocks
delegate: DelegateChooser {
role: "type"
DelegateChoice {
roleValue: LlmMarkdown.Type.Code
delegate: CodeBlockView {
required property var modelData
language: modelData.language
code: modelData.code
anchors.right: parent.right
anchors.left: parent.left
}
}
DelegateChoice {
roleValue: LlmMarkdown.Type.Math
delegate: MathBlockView {
required property var modelData
latex: modelData.latex
anchors.right: parent.right
anchors.left: parent.left
}
}
DelegateChoice {
roleValue: LlmMarkdown.Type.Heading
delegate: CustomText {
required property var modelData
color: Colors.palette.m3onSurface
anchors.right: parent.right
anchors.left: parent.left
text: modelData.text
textFormat: Text.MarkdownText
font.bold: true
font.pointSize: {
if (modelData.level <= 1)
return Tokens.font.size.larger;
if (modelData.level === 2)
return Tokens.font.size.normal;
return Tokens.font.size.smaller;
}
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
}
DelegateChoice {
roleValue: LlmMarkdown.Type.Text
delegate: CustomText {
required property var modelData
color: Colors.palette.m3onSurface
anchors.right: parent.right
anchors.left: parent.left
text: modelData.text
textFormat: Text.MarkdownText
font.pointSize: Tokens.font.size.smaller
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
}
}
}
}
@@ -1,50 +0,0 @@
import QtQuick
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
Item {
id: root
required property string latex
property color mathColor: Colors.palette.m3onSurface
property real fontSize: Tokens.font.size.large
implicitWidth: Math.max(fallbackText.implicitWidth, root.width)
implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
LlmMathText {
id: math
latex: root.latex
color: root.mathColor
fontPointSize: root.fontSize
devicePixelRatio: Screen.devicePixelRatio
}
Image {
id: equationImage
anchors.centerIn: parent
source: math.imageUrl
asynchronous: true
visible: math.ok
fillMode: Image.PreserveAspectFit
smooth: true
width: Math.min(math.width, root.width)
height: math.width > 0 ? width * (math.height / math.width) : 0
}
CustomText {
id: fallbackText
anchors.centerIn: parent
color: root.mathColor
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
text: root.latex
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
visible: !math.ok
}
}
@@ -1,144 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
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
readonly property var blocks: blockify(current.segments)
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;
}
}
}
function blockify(segs) {
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]
});
}
}
}
return blocks;
}
implicitWidth: ListView.view.width
hoverEnabled: true
preventStealing: true
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)
}
Anim {
id: restoreAnim
property: "contentY"
target: root.ListView.view
type: Anim.DefaultEffects
}
Column {
id: layout
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
Repeater {
id: segmentRep
model: ScriptModel {
values: root.blocks
objectProp: "id"
}
delegate: DelegateChooser {
role: "kind"
DelegateChoice {
roleValue: "process"
delegate: ProcessBlock {
required property int index
required property var modelData
segments: modelData.segments
isActive: index === root.blocks.length - 1
width: root.width
onExpandedChanged: root.handleReasoningToggle(expanded)
}
}
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();
}
}
}
}
}
}
}
@@ -1,149 +0,0 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
Item {
id: root
required property var segments
required property bool isActive
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
LoadingIndicator {
id: spinnerReasoning
anchors.centerIn: expandBtn
implicitSize: collapsedText.implicitHeight
opacity: root.isActive ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {}
}
}
IconButton {
id: expandBtn
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...");
}
return qsTr("Worked for %1s").arg((root.totalElapsedMs / 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 ? expandedContent.contentHeight + expandedContent.anchors.margins * 2 : 0
opacity: root.expanded ? 1 : 0
radius: Tokens.rounding.medium
visible: opacity > 0
Behavior on implicitHeight {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on opacity {
Anim {}
}
ListView {
id: expandedContent
anchors.fill: parent
anchors.margins: Tokens.padding.medium
model: root.segments
spacing: Tokens.spacing.medium
delegate: Item {
id: segment
required property LlmSegment modelData
required property int index
implicitWidth: ListView.view.width
implicitHeight: header.implicitHeight + content.implicitHeight + content.anchors.topMargin
RowLayout {
id: header
anchors.left: parent.left
anchors.right: parent.right
spacing: Tokens.spacing.small
MaterialIcon {
text: segment.modelData.type === LlmSegment.Type.Reasoning ? "cognition" : "build"
fill: 1
}
CustomText {
Layout.fillWidth: true
text: segment.modelData.type === LlmSegment.Type.Reasoning ? qsTr("Thought for %1s").arg((segment.modelData.elapsedMs / 1000).toFixed(1)) : qsTr("Used %1 for %2s").arg(segment.modelData.name).arg((segment.modelData.elapsedMs / 1000).toFixed(1))
}
}
CustomText {
id: content
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.small
anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.small
anchors.right: parent.right
color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website")
}
}
}
}
}
@@ -1,51 +0,0 @@
pragma Singleton
import Quickshell
import QtQuick
import ZShell.Config
import qs.Services
Singleton {
id: root
function create(parent: Item, props: var): void {
chatComp.createObject(parent ?? dummy, props);
ChatState.isWindow = true;
}
QtObject {
id: dummy
}
Component {
id: chatComp
FloatingWindow {
id: win
property var props
color: Colors.tPalette.m3surface
implicitHeight: chat.implicitHeight
implicitWidth: chat.implicitWidth
minimumSize.height: Config.sidebar.sizes.width
minimumSize.width: Config.sidebar.sizes.width
surfaceFormat.opaque: false
title: qsTr("ZShell - Chat")
Component.onCompleted: ChatState.screen = screen
onVisibleChanged: {
if (!visible) {
destroy();
ChatState.isWindow = false;
}
}
ChatPanel {
id: chat
anchors.fill: parent
}
}
}
}
@@ -1,91 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
Item {
id: root
property int breakpoint: 700
property alias conversationModel: sidebar.model
property var currentConversation: null // whatever model item is "open"
readonly property bool isWide: root.width >= root.breakpoint
property bool narrowShowsSidebar: true
function closeConversation() {
narrowShowsSidebar = true;
}
function openConversation(conv) {
currentConversation = conv;
narrowShowsSidebar = false; // in narrow mode, jump to the chat
}
ChatList {
id: sidebar
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: root.isWide ? undefined : parent.right
anchors.top: parent.top
implicitWidth: 20
list.currentIndex: -1
list.highlightFollowsCurrentItem: false
Behavior on anchors.right {
AnchorAnim {}
}
Behavior on implicitWidth {
Anim {}
}
list.highlight: CustomRect {
color: Colors.palette.m3primary
implicitHeight: sidebar.list.currentItem?.implicitHeight ?? 0
implicitWidth: sidebar.list.width
radius: Tokens.rounding.medium
x: sidebar.list.currentItem?.chat.x ?? 0
y: sidebar.list.currentItem?.y ?? 0
Behavior on y {
Anim {
duration: Tokens.anim.durations.small
easing: Tokens.anim.expressiveDefaultEffects
}
}
}
model: ScriptModel {
values: Chat.chats.values
}
anchors.onRightChanged: {
if (anchors.right === undefined)
implicitWidth = 20;
}
onLoadChatRequest: (chat, index) => {
sidebar.list.currentIndex = index;
root.openConversation(chat);
}
}
CustomClippingWrapperRect {
anchors.bottom: parent.bottom
anchors.left: sidebar.right
anchors.right: parent.right
anchors.top: parent.top
child: conversationView
}
ChatContent {
id: conversationView
anchors.fill: root
anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0
chatData: root.currentConversation
}
}
+6 -83
View File
@@ -1,11 +1,7 @@
pragma ComponentBehavior: Bound import qs.Components
import ZShell.Config import ZShell.Config
import ZShell.Llm
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import qs.Components
import qs.Modules.Notifications.Sidebar.Chat
import qs.Services import qs.Services
Item { Item {
@@ -14,94 +10,21 @@ Item {
required property Props props required property Props props
required property var visibilities required property var visibilities
Connections {
function onIsWindowChanged(): void {
if (ChatState.isWindow)
root.props.currentTab = 0;
}
target: ChatState
}
ColumnLayout { ColumnLayout {
id: layout id: layout
anchors.fill: parent anchors.fill: parent
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
Tabs { CustomRect {
Layout.fillWidth: true
Layout.preferredHeight: ChatState.isWindow ? 0 : implicitHeight
dashState: root.props
nonAnimWidth: layout.width
visible: height > 0
}
CustomClippingRect {
Layout.fillHeight: true Layout.fillHeight: true
Layout.fillWidth: true Layout.fillWidth: true
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small radius: Tokens.rounding.small
Item { NotifDock {
id: pages props: root.props
visibilities: root.visibilities
anchors.bottom: parent.bottom
anchors.top: parent.top
implicitWidth: parent.width
opacity: root.props.currentTab === 0 ? 1 : 0
visible: opacity > 0
x: root.props.currentTab === 0 ? 0 : -root.width
Behavior on opacity {
Anim {
}
}
Behavior on x {
Anim {
}
}
CustomRect {
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
NotifDock {
props: root.props
visibilities: root.visibilities
}
}
}
Item {
id: chatPage
anchors.bottom: parent.bottom
anchors.top: parent.top
implicitWidth: parent.width
opacity: root.props.currentTab === 1 ? 1 : 0
visible: opacity > 0
x: root.props.currentTab === 0 ? root.width : 0
z: 1
Behavior on opacity {
Anim {
}
}
Behavior on x {
Anim {
}
}
CustomRect {
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
ChatPanel {
anchors.fill: parent
}
}
} }
} }
-2
View File
@@ -1,8 +1,6 @@
import Quickshell import Quickshell
import ZShell.Llm
PersistentProperties { PersistentProperties {
property int currentTab: 0
property list<string> expandedNotifs: [] property list<string> expandedNotifs: []
reloadableId: "sidebar" reloadableId: "sidebar"
-142
View File
@@ -1,142 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import QtQuick.Templates
import Quickshell
import ZShell.Config
import qs.Components
import qs.Services
Item {
id: root
readonly property alias count: bar.count
required property PersistentProperties dashState
required property real nonAnimWidth
implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight
TabBar {
id: bar
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
background: null
currentIndex: root.dashState.currentTab
implicitHeight: contentHeight
contentItem: RowLayout {
spacing: 0
Repeater {
model: bar.contentModel
}
}
onCurrentIndexChanged: root.state.currentTab = currentIndex
Tab {
iconName: "notifications"
text: qsTr("Notifications")
}
Tab {
iconName: "chat"
text: qsTr("Chat")
}
}
Item {
id: indicator
anchors.top: bar.bottom
clip: true
implicitHeight: 3
implicitWidth: bar.currentItem.implicitWidth
x: {
const tab = bar.currentItem;
const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count;
return width * tab.TabBar.index + (width - tab.implicitWidth) / 2;
}
Behavior on implicitWidth {
Anim {
}
}
Behavior on x {
Anim {
}
}
CustomRect {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
color: Colors.palette.m3primary
implicitHeight: parent.implicitHeight * 2
radius: Tokens.rounding.full
}
}
CustomRect {
id: separator
anchors.left: parent.left
anchors.right: parent.right
anchors.top: indicator.bottom
color: Colors.palette.m3outlineVariant
implicitHeight: 1
}
component Tab: TabButton {
id: tab
readonly property bool current: TabBar.tabBar.currentItem === this
required property string iconName
Layout.fillWidth: true
Layout.preferredWidth: 1
background: null
implicitHeight: implicitContentHeight
implicitWidth: implicitContentWidth
contentItem: Item {
implicitHeight: icon.height + label.height
implicitWidth: Math.max(icon.width, label.width)
StateLayer {
color: tab.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
radius: Tokens.rounding.small
onClicked: root.dashState.currentTab = tab.TabBar.index
}
MaterialIcon {
id: icon
anchors.bottom: label.top
anchors.horizontalCenter: parent.horizontalCenter
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant
fill: tab.current ? 1 : 0
font.pointSize: 18
text: tab.iconName
Behavior on fill {
Anim {
}
}
}
CustomText {
id: label
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant
text: tab.text
}
}
}
}
@@ -12,11 +12,13 @@ import qs.Services
import qs.Helpers import qs.Helpers
import qs.Daemons import qs.Daemons
import qs.Modules.Settings import qs.Modules.Settings
import qs.Modules.Bar.Popouts as BarPopouts
CustomRect { CustomRect {
id: root id: root
readonly property bool needExtraRow: quickToggles.length > 6 readonly property bool needExtraRow: quickToggles.length > 6
required property BarPopouts.Wrapper popouts
readonly property var quickToggles: { readonly property var quickToggles: {
const seenIds = new Set(); const seenIds = new Set();
@@ -1,12 +1,14 @@
import Quickshell import Quickshell
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import qs.Modules.Bar.Popouts as BarPopouts
import qs.Modules.Notifications.Sidebar.Utils.Cards import qs.Modules.Notifications.Sidebar.Utils.Cards
import ZShell.Config import ZShell.Config
Item { Item {
id: root id: root
required property BarPopouts.Wrapper popouts
required property PersistentProperties props required property PersistentProperties props
required property var visibilities required property var visibilities
@@ -19,7 +21,8 @@ Item {
anchors.fill: parent anchors.fill: parent
spacing: 8 spacing: 8
IdleInhibit {} IdleInhibit {
}
Record { Record {
props: root.props props: root.props
@@ -29,6 +32,7 @@ Item {
Toggles { Toggles {
Layout.fillWidth: true Layout.fillWidth: true
popouts: root.popouts
visibilities: root.visibilities visibilities: root.visibilities
} }
} }
@@ -4,11 +4,13 @@ import QtQuick
import Quickshell import Quickshell
import ZShell.Config import ZShell.Config
import qs.Components import qs.Components
import qs.Modules.Bar.Popouts as BarPopouts
Item { Item {
id: root id: root
property real offsetScale: shouldBeActive ? 0 : 1 property real offsetScale: shouldBeActive ? 0 : 1
required property BarPopouts.Wrapper popouts
readonly property PersistentProperties props: PersistentProperties { readonly property PersistentProperties props: PersistentProperties {
property string recordingConfirmDelete property string recordingConfirmDelete
property bool recordingListExpanded: false property bool recordingListExpanded: false
@@ -16,12 +18,12 @@ Item {
reloadableId: "utilities" reloadableId: "utilities"
} }
readonly property bool shouldBeActive: visibilities.sidebar && !sidebar.chatActive readonly property bool shouldBeActive: visibilities.sidebar
required property Item sidebar required property Item sidebar
required property var visibilities required property var visibilities
anchors.bottomMargin: (-implicitHeight - 5) * offsetScale anchors.bottomMargin: (-implicitHeight - 5) * offsetScale
implicitHeight: content.implicitHeight + Tokens.padding.small * 2 implicitHeight: content.implicitHeight + 8 * 2
implicitWidth: sidebar.width * (1 - sidebar.offsetScale) implicitWidth: sidebar.width * (1 - sidebar.offsetScale)
opacity: 1 - offsetScale opacity: 1 - offsetScale
visible: offsetScale < 1 visible: offsetScale < 1
@@ -43,6 +45,7 @@ Item {
sourceComponent: Content { sourceComponent: Content {
implicitWidth: root.implicitWidth - 8 * 2 implicitWidth: root.implicitWidth - 8 * 2
popouts: root.popouts
props: root.props props: root.props
visibilities: root.visibilities visibilities: root.visibilities
} }
-16
View File
@@ -1,11 +1,7 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import qs.Components import qs.Components
import qs.Components.Toast
import ZShell
import ZShell.Config import ZShell.Config
import ZShell.Llm
import Quickshell
import QtQuick import QtQuick
Item { Item {
@@ -16,7 +12,6 @@ Item {
readonly property Props props: Props { readonly property Props props: Props {
} }
readonly property bool shouldBeActive: root.visibilities.sidebar && Config.sidebar.enabled readonly property bool shouldBeActive: root.visibilities.sidebar && Config.sidebar.enabled
readonly property bool chatActive: props.currentTab === 1
required property var visibilities required property var visibilities
anchors.rightMargin: (-implicitWidth - 5) * offsetScale anchors.rightMargin: (-implicitWidth - 5) * offsetScale
@@ -31,17 +26,6 @@ Item {
} }
} }
Connections {
target: Chat
function onErrorOccurred(message: string): void {
if (root.shouldBeActive && root.chatActive)
return;
Toaster.toast(qsTr("Chat"), message, "error_outline", Toast.Error);
}
}
Loader { Loader {
id: content id: content
+17 -20
View File
@@ -73,20 +73,21 @@ Scope {
// mask: Region { item: inputPanel } // mask: Region { item: inputPanel }
Rectangle { CustomRect {
id: inputPanel id: inputPanel
anchors.centerIn: parent anchors.centerIn: parent
color: Colors.tPalette.m3surface color: Colors.tPalette.m3surface
implicitHeight: layout.childrenRect.height + 28 implicitHeight: layout.implicitHeight + layout.anchors.margins * 2
implicitWidth: layout.childrenRect.width + 32 implicitWidth: Math.max(layout.implicitWidth + layout.anchors.margins * 2, 450)
opacity: 0 opacity: 0
radius: Tokens.rounding.small * 2 radius: Tokens.rounding.small * 2
ColumnLayout { ColumnLayout {
id: layout id: layout
anchors.centerIn: parent anchors.fill: parent
anchors.margins: Tokens.padding.medium
RowLayout { RowLayout {
id: contentRow id: contentRow
@@ -130,7 +131,7 @@ Scope {
Layout.preferredWidth: Math.min(600, contentWidth) Layout.preferredWidth: Math.min(600, contentWidth)
font.bold: true font.bold: true
font.pointSize: 16 font.pointSize: 16
text: polkitAgent.flow?.message text: polkitAgent.flow?.message ?? ""
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
} }
@@ -147,8 +148,8 @@ Scope {
TextField { TextField {
id: passInput id: passInput
Layout.fillWidth: true
Layout.preferredHeight: 40 Layout.preferredHeight: 40
Layout.preferredWidth: contentColumn.implicitWidth
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
echoMode: polkitAgent.flow?.responseVisible ? TextInput.Normal : TextInput.Password echoMode: polkitAgent.flow?.responseVisible ? TextInput.Normal : TextInput.Password
placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password" placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password"
@@ -168,7 +169,7 @@ Scope {
id: showPassCheckbox id: showPassCheckbox
Layout.alignment: Qt.AlignLeft Layout.alignment: Qt.AlignLeft
checked: polkitAgent.flow?.responseVisible checked: polkitAgent.flow?.responseVisible ?? false
text: "Show Password" text: "Show Password"
onCheckedChanged: { onCheckedChanged: {
@@ -189,7 +190,8 @@ Scope {
clip: true clip: true
color: Colors.tPalette.m3surfaceContainerLow color: Colors.tPalette.m3surfaceContainerLow
implicitHeight: 0 implicitHeight: 0
radius: 16 implicitWidth: textDetailsColumn.implicitWidth + textDetailsColumn.anchors.margins * 2
radius: Tokens.rounding.medium
visible: true visible: true
Behavior on open { Behavior on open {
@@ -197,7 +199,8 @@ Scope {
Anim { Anim {
property: "implicitHeight" property: "implicitHeight"
target: detailsPanel target: detailsPanel
to: !detailsPanel.open ? textDetailsColumn.childrenRect.height + 16 : 0 to: !detailsPanel.open ? textDetailsColumn.implicitHeight + Tokens.padding.small * 2 : 0
type: Anim.DefaultEffects
} }
Anim { Anim {
@@ -205,12 +208,6 @@ Scope {
target: textDetailsColumn target: textDetailsColumn
to: !detailsPanel.open ? 1 : 0 to: !detailsPanel.open ? 1 : 0
} }
Anim {
property: "scale"
target: textDetailsColumn
to: !detailsPanel.open ? 1 : 0.9
}
} }
} }
@@ -218,10 +215,9 @@ Scope {
id: textDetailsColumn id: textDetailsColumn
anchors.fill: parent anchors.fill: parent
anchors.margins: 8 anchors.margins: Tokens.padding.small
opacity: 0 opacity: 0
scale: 0.9 spacing: Tokens.spacing.small
spacing: 8
CustomText { CustomText {
text: `actionId: ${polkitAgent.flow?.actionId}` text: `actionId: ${polkitAgent.flow?.actionId}`
@@ -239,16 +235,17 @@ Scope {
Layout.preferredWidth: contentRow.implicitWidth Layout.preferredWidth: contentRow.implicitWidth
spacing: 8 spacing: 8
IconTextButton { IconButton {
id: detailsButton id: detailsButton
Layout.alignment: Qt.AlignLeft Layout.alignment: Qt.AlignLeft
horizontalPadding: Tokens.padding.medium
icon: "info" icon: "info"
inactiveColor: Colors.palette.m3surfaceContainer inactiveColor: Colors.palette.m3surfaceContainer
inactiveOnColor: Colors.palette.m3onSurface inactiveOnColor: Colors.palette.m3onSurface
isRound: true isRound: true
shapeMorph: true shapeMorph: true
text: "Details" verticalPadding: Tokens.padding.medium
onClicked: { onClicked: {
panelWindow.detailsOpen = !panelWindow.detailsOpen; panelWindow.detailsOpen = !panelWindow.detailsOpen;
-1
View File
@@ -79,4 +79,3 @@ add_subdirectory(Services)
add_subdirectory(Components) add_subdirectory(Components)
add_subdirectory(Blobs) add_subdirectory(Blobs)
add_subdirectory(Config) add_subdirectory(Config)
add_subdirectory(Llm)
-1
View File
@@ -16,7 +16,6 @@ qml_module(ZShell-config
dock.hpp dock.hpp
general.hpp general.hpp
launcher.hpp launcher.hpp
llm.hpp
lock.hpp lock.hpp
notifs.hpp notifs.hpp
osd.hpp osd.hpp
+1 -12
View File
@@ -9,7 +9,6 @@
#include "dock.hpp" #include "dock.hpp"
#include "general.hpp" #include "general.hpp"
#include "launcher.hpp" #include "launcher.hpp"
#include "llm.hpp"
#include "lock.hpp" #include "lock.hpp"
#include "notifs.hpp" #include "notifs.hpp"
#include "osd.hpp" #include "osd.hpp"
@@ -34,8 +33,6 @@
namespace ZShell::config { namespace ZShell::config {
Config* Config::s_instance = nullptr;
Config::Config(QObject* parent) Config::Config(QObject* parent)
: ConfigObject(parent) : ConfigObject(parent)
, m_appearance(new Appearance(this)) , m_appearance(new Appearance(this))
@@ -47,7 +44,6 @@ Config::Config(QObject* parent)
, m_dock(new Dock(this)) , m_dock(new Dock(this))
, m_general(new General(this)) , m_general(new General(this))
, m_launcher(new Launcher(this)) , m_launcher(new Launcher(this))
, m_llm(new Llm(this))
, m_lock(new Lock(this)) , m_lock(new Lock(this))
, m_notifs(new Notifs(this)) , m_notifs(new Notifs(this))
, m_osd(new Osd(this)) , m_osd(new Osd(this))
@@ -55,7 +51,6 @@ Config::Config(QObject* parent)
, m_services(new Services(this)) , m_services(new Services(this))
, m_sidebar(new Sidebar(this)) , m_sidebar(new Sidebar(this))
, m_utilities(new Utilities(this)) { , m_utilities(new Utilities(this)) {
s_instance = this;
connect(this, &ConfigObject::propertiesChanged, this, &Config::scheduleSave); connect(this, &ConfigObject::propertiesChanged, this, &Config::scheduleSave);
m_saveTimer.setSingleShot(true); m_saveTimer.setSingleShot(true);
@@ -86,14 +81,8 @@ Config::Config(QObject* parent)
m_firstLoadDone = true; m_firstLoadDone = true;
} }
Config* Config::instance() {
return s_instance;
}
Config* Config::create(QQmlEngine*, QJSEngine*) { Config* Config::create(QQmlEngine*, QJSEngine*) {
if (!s_instance) return new Config();
s_instance = new Config();
return s_instance;
} }
QString Config::filePath() const { QString Config::filePath() const {
-6
View File
@@ -24,7 +24,6 @@ class Dashboard;
class Dock; class Dock;
class General; class General;
class Launcher; class Launcher;
class Llm;
class Lock; class Lock;
class Notifs; class Notifs;
class Osd; class Osd;
@@ -47,7 +46,6 @@ class Config : public ConfigObject {
Q_MOC_INCLUDE("dock.hpp") Q_MOC_INCLUDE("dock.hpp")
Q_MOC_INCLUDE("general.hpp") Q_MOC_INCLUDE("general.hpp")
Q_MOC_INCLUDE("launcher.hpp") Q_MOC_INCLUDE("launcher.hpp")
Q_MOC_INCLUDE("llm.hpp")
Q_MOC_INCLUDE("lock.hpp") Q_MOC_INCLUDE("lock.hpp")
Q_MOC_INCLUDE("notifs.hpp") Q_MOC_INCLUDE("notifs.hpp")
Q_MOC_INCLUDE("osd.hpp") Q_MOC_INCLUDE("osd.hpp")
@@ -65,7 +63,6 @@ class Config : public ConfigObject {
CONFIG_SUBOBJECT(Dock, dock) CONFIG_SUBOBJECT(Dock, dock)
CONFIG_SUBOBJECT(General, general) CONFIG_SUBOBJECT(General, general)
CONFIG_SUBOBJECT(Launcher, launcher) CONFIG_SUBOBJECT(Launcher, launcher)
CONFIG_SUBOBJECT(Llm, llm)
CONFIG_SUBOBJECT(Lock, lock) CONFIG_SUBOBJECT(Lock, lock)
CONFIG_SUBOBJECT(Notifs, notifs) CONFIG_SUBOBJECT(Notifs, notifs)
CONFIG_SUBOBJECT(Osd, osd) CONFIG_SUBOBJECT(Osd, osd)
@@ -77,7 +74,6 @@ class Config : public ConfigObject {
public: public:
explicit Config(QObject* parent = nullptr); explicit Config(QObject* parent = nullptr);
static Config* create(QQmlEngine*, QJSEngine*); static Config* create(QQmlEngine*, QJSEngine*);
[[nodiscard]] static Config* instance();
Q_INVOKABLE void load(); Q_INVOKABLE void load();
Q_INVOKABLE void saveNow(); Q_INVOKABLE void saveNow();
@@ -107,8 +103,6 @@ class Config : public ConfigObject {
bool m_loading = false; bool m_loading = false;
bool m_firstLoadDone = false; bool m_firstLoadDone = false;
QFuture<void> m_loadFuture; QFuture<void> m_loadFuture;
static Config* s_instance;
}; };
} // namespace ZShell::config } // namespace ZShell::config
-22
View File
@@ -1,22 +0,0 @@
#pragma once
#include "configobject.hpp"
#include <qqmlintegration.h>
namespace ZShell::config {
class Llm : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
CFG_PROPERTY(QString, endpoint, "http://localhost:8080")
CFG_PROPERTY(QString, model, "")
CFG_PROPERTY(double, temperature, 0.7)
// Whether tools are offered to the model. Models without a tool-calling
// template should run with this off.
CFG_PROPERTY(bool, tools, true)
public:
explicit Llm(QObject* parent = nullptr) : ConfigObject(parent) {}
};
} // namespace ZShell::config
-76
View File
@@ -1,76 +0,0 @@
# cmark-gfm: markdown -> block structure (pkg-config; no CMake config
# package is installed).
pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm)
# tree-sitter runtime for code block highlighting (grammars are
# dlopen()'d at runtime and optional).
pkg_check_modules(TREE_SITTER REQUIRED IMPORTED_TARGET tree-sitter)
# JKQTMathText (JKQtPlotter) for LaTeX rendering. The config files live
# in a shared JKQTPlotter6 directory, not one named after the package.
find_path(JKQTPlotter6_CMAKE_DIR
NAMES JKQTMathText6Config.cmake
HINTS /usr/lib/cmake/JKQTPlotter6 /usr/local/lib/cmake/JKQTPlotter6
DOC "Directory containing the JKQtPlotter cmake package files")
find_package(JKQTMathText6 REQUIRED PATHS "${JKQTPlotter6_CMAKE_DIR}")
# Embed the vendored highlight queries as C++ string literals.
set(HIGHLIGHT_QUERY_LANGS c cpp python javascript typescript bash json rust go yaml toml sql)
set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp")
set(_hl_header "#pragma once\n\n// Vendored tree-sitter highlight queries (MIT; see the per-file\n// source headers in the highlight-queries/ directory).\nnamespace ZShell::llm::hq {\n")
foreach(_hl_lang IN LISTS HIGHLIGHT_QUERY_LANGS)
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_hl_lang}.scm" _hl_src)
string(APPEND _hl_header "inline constexpr const char* ${_hl_lang} = R\"ZSQUERY(${_hl_src})ZSQUERY\";\n")
endforeach()
string(APPEND _hl_header "}\n")
file(WRITE "${HIGHLIGHT_QUERIES_HPP}" "${_hl_header}")
# Embed the vendored Latin Modern fonts (GUST Font License; provenance
# in fonts/latinmodern/GUST-FONT-LICENSE.txt) as byte arrays.
set(LM_FONTS
lmroman10-regular
lmroman10-italic
lmroman10-bold
lmroman10-bolditalic
latinmodern-math)
set(LM_FONTS_HPP "${CMAKE_CURRENT_BINARY_DIR}/latinmodern-fonts.hpp")
set(_lm_header "#pragma once\n\n// Vendored Latin Modern fonts (GUST Font License; see\n// fonts/latinmodern/GUST-FONT-LICENSE.txt).\nnamespace ZShell::llm::lmfont {\n")
foreach(_lm_font IN LISTS LM_FONTS)
string(REPLACE "-" "_" _lm_sym "${_lm_font}")
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/fonts/latinmodern/${_lm_font}.otf" _lm_hex HEX)
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _lm_bytes "${_lm_hex}")
string(APPEND _lm_header "inline const unsigned char ${_lm_sym}[] = { ${_lm_bytes} };\n")
endforeach()
string(APPEND _lm_header "}\n")
file(WRITE "${LM_FONTS_HPP}" "${_lm_header}")
qml_module(ZShell-llm
URI ZShell.Llm
SOURCES
chat.hpp chat.cpp
chatstore.hpp chatstore.cpp
codehighlighter.hpp codehighlighter.cpp
generation.hpp generation.cpp
llmclient.hpp llmclient.cpp
markdownblock.hpp
markdownparser.hpp markdownparser.cpp
mathtext.hpp mathtext.cpp
message.hpp message.cpp
messagemodel.hpp messagemodel.cpp
segment.hpp segment.cpp
session.hpp session.cpp
tool.hpp tool.cpp
webfetchtool.hpp webfetchtool.cpp
LIBRARIES
Qt::Network
Qt::Sql
Qt::Gui
Qt::Widgets
ZShell-config
JKQTPlotter::JKQTMathText
PkgConfig::CMARK_GFM
PkgConfig::TREE_SITTER
)
target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Config)
-172
View File
@@ -1,172 +0,0 @@
#include "chat.hpp"
#include "config.hpp"
#include "llm.hpp"
#include "llmclient.hpp"
#include "webfetchtool.hpp"
#include <QDebug>
namespace ZShell::llm {
Chat::Chat(QObject* parent)
: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) {
if (!config::Config::instance())
new config::Config();
m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
const auto* llm = config::Config::instance()->llm();
m_client->setEndpoint(llm->endpoint());
m_client->setModel(llm->model());
m_client->setTemperature(llm->temperature());
m_client->setToolsEnabled(llm->tools());
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
m_client->setEndpoint(llm->endpoint());
});
connect(llm, &config::Llm::modelChanged, this, [this, llm]() {
m_client->setModel(llm->model());
});
connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() {
m_client->setTemperature(llm->temperature());
});
connect(llm, &config::Llm::toolsChanged, this, [this, llm]() {
m_client->setToolsEnabled(llm->tools());
});
connect(m_client, &LlmClient::busyChanged, this, [this]() {
// A fresh run supersedes the previous error.
if (m_client->busy() && !m_lastError.isEmpty()) {
m_lastError.clear();
Q_EMIT lastErrorChanged();
}
Q_EMIT busyChanged();
});
connect(
m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
connect(
m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect(
m_client,
&LlmClient::availableModelsChanged,
this,
&Chat::availableModelsChanged);
connect(
m_client,
&LlmClient::contextSizeChanged,
this,
&Chat::contextSizeChanged);
connect(
m_client,
&LlmClient::streamingChatIdChanged,
this,
&Chat::streamingChatIdChanged);
connect(
m_client,
&LlmClient::toolsEnabledChanged,
this,
&Chat::toolsEnabledChanged);
connect(
m_client,
&LlmClient::errorOccurred,
this,
[this](const QString& message) {
m_lastError = message;
Q_EMIT lastErrorChanged();
Q_EMIT errorOccurred(message);
});
connect(
m_store,
&ChatStore::sessionRemoved,
this,
[this](ChatSession* session) { m_client->sessionRemoved(session); });
connect(
m_client,
&LlmClient::titleSuggested,
this,
[this](ChatSession* session, const QString& title) {
qInfo() << "Chat: applying generated title" << session->id()
<< title << "(was" << session->title() << ")";
session->setTitle(title);
m_store->saveMeta(session);
});
connect(
m_client,
&LlmClient::iconSuggested,
this,
[this](ChatSession* session, const QString& icon) {
qInfo() << "Chat: applying generated icon" << session->id()
<< icon << "(was" << session->icon() << ")";
session->setIcon(icon);
m_store->saveMeta(session);
});
}
bool Chat::busy() const {
return m_client->busy();
}
QString Chat::endpoint() const {
return m_client->endpoint();
}
QString Chat::model() const {
return m_client->model();
}
QStringList Chat::availableModels() const {
return m_client->availableModels();
}
int Chat::contextSize() const {
return m_client->contextSize();
}
bool Chat::toolsEnabled() const {
return m_client->toolsEnabled();
}
void Chat::setToolsEnabled(bool value) {
m_client->setToolsEnabled(value);
if (auto* config = config::Config::instance())
config->llm()->set_tools(value);
}
QString Chat::streamingChatId() const {
return m_client->streamingChatId();
}
Chat* Chat::s_instance = nullptr;
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
if (!s_instance)
s_instance = new Chat();
return s_instance;
}
void Chat::stop() {
m_client->stop();
}
void Chat::dismissError() {
if (m_lastError.isEmpty())
return;
m_lastError.clear();
Q_EMIT lastErrorChanged();
}
void Chat::refreshModels() {
m_client->refreshModels();
}
void Chat::selectModel(const QString& id) {
if (id.isEmpty())
return;
m_client->setModel(id);
if (auto* config = config::Config::instance())
config->llm()->set_model(id);
}
} // namespace ZShell::llm
-75
View File
@@ -1,75 +0,0 @@
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include <QtQml>
#include "chatstore.hpp"
class QQmlEngine;
class QJSEngine;
namespace ZShell::llm {
class LlmClient;
// QML-facing facade. Persistence lives in ChatStore, network streaming in
// LlmClient; this class only wires them together and exposes the
// application-wide state.
class Chat : public QObject {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged)
Q_PROPERTY(QString model READ model NOTIFY modelChanged)
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged)
Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged)
Q_PROPERTY(bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY toolsEnabledChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged)
public:
explicit Chat(QObject* parent = nullptr);
[[nodiscard]] bool busy() const;
[[nodiscard]] QString endpoint() const;
[[nodiscard]] QString model() const;
[[nodiscard]] QStringList availableModels() const;
[[nodiscard]] int contextSize() const;
[[nodiscard]] bool toolsEnabled() const;
void setToolsEnabled(bool value);
[[nodiscard]] QString lastError() const { return m_lastError; }
[[nodiscard]] ChatStore* chats() const { return m_store; }
[[nodiscard]] QString streamingChatId() const;
Q_INVOKABLE void stop();
Q_INVOKABLE void dismissError();
Q_INVOKABLE void refreshModels();
Q_INVOKABLE void selectModel(const QString& id);
static Chat* create(QQmlEngine*, QJSEngine*);
Q_SIGNALS:
void busyChanged();
void endpointChanged();
void modelChanged();
void availableModelsChanged();
void contextSizeChanged();
void toolsEnabledChanged();
void errorOccurred(const QString& message);
void lastErrorChanged();
void streamingChatIdChanged();
private:
ChatStore* m_store = nullptr;
LlmClient* m_client = nullptr;
QString m_lastError;
static Chat* s_instance;
};
} // namespace ZShell::llm
-519
View File
@@ -1,519 +0,0 @@
#include "chatstore.hpp"
#include "llmclient.hpp"
#include "message.hpp"
#include "segment.hpp"
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QStandardPaths>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QUuid>
#include <algorithm>
namespace ZShell::llm {
namespace {
QString segmentTypeName(LlmSegment::Type type) {
switch (type) {
case LlmSegment::Type::Reasoning:
return QStringLiteral("reasoning");
case LlmSegment::Type::ToolCall:
return QStringLiteral("tool_call");
case LlmSegment::Type::Content:
return QStringLiteral("content");
}
return QStringLiteral("reasoning");
}
LlmSegment::Type segmentTypeFromName(const QString& name) {
if (name == QLatin1String("tool_call"))
return LlmSegment::Type::ToolCall;
if (name == QLatin1String("content"))
return LlmSegment::Type::Content;
return LlmSegment::Type::Reasoning;
}
// A null QString binds as SQL NULL, which violates the NOT NULL columns;
// DEFAULT only applies to omitted columns, not explicit NULLs.
QString sqlText(const QString& value) {
if (value.isNull())
return QStringLiteral("");
return value;
}
} // namespace
ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
openDb();
load();
}
ChatStore::~ChatStore() {
if (m_connectionName.isEmpty())
return;
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
db.close();
QSqlDatabase::removeDatabase(m_connectionName);
}
QSqlDatabase ChatStore::db() const {
return QSqlDatabase::database(m_connectionName);
}
void ChatStore::openDb() {
const QString path =
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) +
QStringLiteral("/zshell/chats.sqlite");
QDir().mkpath(QFileInfo(path).absolutePath());
QSqlDatabase db =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
db.setDatabaseName(path);
if (!db.open()) {
qWarning() << "ChatStore: failed to open database" << path << ":"
<< db.lastError().text();
return;
}
{
QSqlQuery pragma(db);
pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS sessions (\n"
" id TEXT PRIMARY KEY,\n"
" title TEXT NOT NULL DEFAULT '',\n"
" icon TEXT NOT NULL DEFAULT '',\n"
" created_at INTEGER NOT NULL,\n"
" updated_at INTEGER NOT NULL,\n"
" pinned INTEGER NOT NULL DEFAULT 0\n"
")"));
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS messages (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" session_id TEXT NOT NULL REFERENCES sessions (id) "
"ON DELETE CASCADE,\n"
" role TEXT NOT NULL,\n"
" timestamp INTEGER NOT NULL\n"
")"));
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS generations (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" message_id INTEGER NOT NULL REFERENCES messages "
"(id) ON DELETE CASCADE,\n"
" timestamp INTEGER NOT NULL,\n"
" is_active INTEGER NOT NULL DEFAULT 1\n"
")"));
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS segments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" generation_id INTEGER NOT NULL REFERENCES generations "
"(id) ON DELETE CASCADE,\n"
" type TEXT NOT NULL,\n"
" text TEXT NOT NULL DEFAULT '',\n"
" name TEXT NOT NULL DEFAULT '',\n"
" tool_call_id TEXT NOT NULL DEFAULT '',\n"
" arguments TEXT NOT NULL DEFAULT '',\n"
" result TEXT NOT NULL DEFAULT '',\n"
" status INTEGER NOT NULL DEFAULT 0,\n"
" elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
" timestamp INTEGER NOT NULL\n"
")"));
}
{
QSqlQuery query(db);
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_messages_session "
"ON messages (session_id)"));
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_generations_message "
"ON generations (message_id)"));
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_segments_generation "
"ON segments (generation_id)"));
}
}
int ChatStore::count() const {
return static_cast<int>(m_sessions.size());
}
QVariantList ChatStore::values() const {
QVariantList vals;
vals.reserve(m_sessions.size());
for (const auto* session : m_sessions)
vals.append(QVariant::fromValue(session));
return vals;
}
ChatSession* ChatStore::at(int index) const {
if (index < 0 || index >= m_sessions.size())
return nullptr;
return m_sessions.at(index);
}
ChatSession* ChatStore::insert(int index) {
const qint64 now = QDateTime::currentMSecsSinceEpoch();
const QString id = QUuid::createUuid().toString();
{
QSqlQuery query(db());
query.prepare(
"INSERT INTO sessions (id, title, created_at, updated_at) "
"VALUES (:id, '', :created_at, :updated_at)");
query.bindValue(":id", id);
query.bindValue(":created_at", now);
query.bindValue(":updated_at", now);
if (!query.exec())
qWarning() << "ChatStore: failed to insert session" << id << ":"
<< query.lastError().text();
}
auto* session = new ChatSession(id, this);
session->setMeta(QString(), now, now, 0);
const int pos = index >= 0 && index <= m_sessions.size() ? index : 0;
m_sessions.insert(pos, session);
Q_EMIT countChanged();
Q_EMIT valuesChanged();
return session;
}
void ChatStore::remove(int index) {
removeSession(at(index));
}
void ChatStore::remove(ChatSession* chat) {
removeSession(chat);
}
void ChatStore::removeSession(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
const QList<ChatSession*> before = m_sessions;
Q_EMIT sessionRemoved(session);
{
QSqlQuery query(db());
query.prepare("DELETE FROM sessions WHERE id = :id");
query.bindValue(":id", session->id());
query.exec();
}
m_sessions.removeOne(session);
session->deleteLater();
notify(before);
}
void ChatStore::move(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 ||
to >= m_sessions.size() || from == to)
return;
m_sessions.move(from, to);
Q_EMIT valuesChanged();
}
void ChatStore::clear() {
const QList<ChatSession*> sessions = m_sessions;
for (ChatSession* session : sessions)
removeSession(session);
}
ChatSession* ChatStore::sessionById(const QString& id) {
for (auto* session : m_sessions)
if (session->id() == id)
return session;
return nullptr;
}
void ChatStore::setLlmClient(LlmClient* client) {
m_llmClient = client;
}
void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
session->ensureLoaded();
if (!saveSession(session))
return;
sortAndNotify();
}
void ChatStore::saveMeta(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
QSqlQuery query(db());
query.prepare("UPDATE sessions SET title = :title, icon = :icon "
"WHERE id = :id");
query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":id", session->id());
if (!query.exec())
qWarning() << "ChatStore: failed to save meta for" << session->id()
<< ":" << query.lastError().text();
}
bool ChatStore::saveSession(ChatSession* session) {
const QString id = session->id();
QSqlDatabase handle = db();
if (!handle.transaction()) {
qWarning() << "ChatStore: failed to begin transaction:"
<< handle.lastError().text();
return false;
}
bool ok = true;
{
QSqlQuery query(handle);
query.prepare(
"UPDATE sessions SET title = :title, updated_at = :updated_at "
"WHERE id = :id");
query.bindValue(":title", sqlText(session->title()));
query.bindValue(":updated_at", session->updatedAtMs());
query.bindValue(":id", session->id());
ok = query.exec();
}
if (ok) {
QSqlQuery query(handle);
query.prepare("DELETE FROM messages WHERE session_id = :id");
query.bindValue(":id", session->id());
ok = query.exec();
}
if (ok) {
QSqlQuery messageInsert(handle);
ok = messageInsert.prepare(
"INSERT INTO messages (session_id, role, timestamp) "
"VALUES (:id, :role, :timestamp)");
QSqlQuery generationInsert(handle);
ok = ok && generationInsert.prepare(
"INSERT INTO generations (message_id, timestamp, is_active) "
"VALUES (:mid, :timestamp, :is_active)");
QSqlQuery segmentInsert(handle);
ok = ok && segmentInsert.prepare(
"INSERT INTO segments (generation_id, type, text, name, "
"tool_call_id, arguments, result, status, elapsed_ms, "
"timestamp) VALUES (:gid, :type, :text, :name, "
":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
":timestamp)");
// The model holds messages most recent first; the database keeps
// natural rowid order, so iterate from the oldest row up.
const auto* model = session->messagesModel();
for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
const auto* message = model->at(row);
messageInsert.bindValue(":id", session->id());
messageInsert.bindValue(
":role",
message->role() == ChatMessage::Role::User
? QStringLiteral("user")
: QStringLiteral("assistant"));
messageInsert.bindValue(":timestamp", message->timestamp());
if (!messageInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id
<< "message insert failed:"
<< messageInsert.lastError().text();
break;
}
const int messageId = messageInsert.lastInsertId().toInt();
for (int i = 0; ok && i < message->generationCount(); ++i) {
const auto* generation = message->generation(i);
generationInsert.bindValue(":mid", messageId);
generationInsert.bindValue(
":timestamp", generation->timestamp());
generationInsert.bindValue(
":is_active",
i == message->activeGenerationIndex() ? 1 : 0);
if (!generationInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id
<< "generation insert failed:"
<< generationInsert.lastError().text();
break;
}
const int generationId =
generationInsert.lastInsertId().toInt();
for (const auto* segment : generation->segments()) {
segmentInsert.bindValue(":gid", generationId);
segmentInsert.bindValue(
":type", segmentTypeName(segment->type()));
segmentInsert.bindValue(":text", sqlText(segment->text()));
segmentInsert.bindValue(":name", sqlText(segment->name()));
segmentInsert.bindValue(
":tool_call_id", sqlText(segment->toolCallId()));
segmentInsert.bindValue(
":arguments", sqlText(segment->arguments()));
segmentInsert.bindValue(":result", sqlText(segment->result()));
segmentInsert.bindValue(
":status", static_cast<int>(segment->status()));
segmentInsert.bindValue(
":elapsed_ms", segment->elapsedMs());
segmentInsert.bindValue(
":timestamp", segment->timestamp());
if (!segmentInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id
<< "segment insert failed:"
<< segmentInsert.lastError().text();
break;
}
}
}
}
}
if (!ok || !handle.commit()) {
qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back";
handle.rollback();
ok = false;
qWarning() << "ChatStore: failed to save session" << session->id() << ":"
<< handle.lastError().text();
}
return ok;
}
void ChatStore::loadMessagesInto(ChatSession* session) {
// Newest first so the model receives rows in display order.
QSqlQuery query(db());
query.prepare(
"SELECT id, role, timestamp FROM messages WHERE session_id = :id "
"ORDER BY rowid DESC");
query.bindValue(":id", session->id());
if (!query.exec()) {
qWarning() << "ChatStore: failed to load messages for" << session->id()
<< ":" << query.lastError().text();
return;
}
auto* model = session->messagesModel();
QList<ChatMessage*> messages;
while (query.next()) {
const int messageId = query.value(0).toInt();
auto* message = model->createMessage(
query.value(1).toString() == QLatin1String("user")
? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
query.value(2).toLongLong());
QSqlQuery generationQuery(db());
generationQuery.prepare(
"SELECT id, timestamp, is_active FROM generations "
"WHERE message_id = :mid ORDER BY rowid");
generationQuery.bindValue(":mid", messageId);
int activeIndex = 0;
if (generationQuery.exec()) {
int index = 0;
while (generationQuery.next()) {
auto* generation = message->addGeneration(
generationQuery.value(1).toLongLong());
QSqlQuery segmentQuery(db());
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, arguments, "
"result, status, elapsed_ms, timestamp FROM segments "
"WHERE generation_id = :gid ORDER BY rowid");
segmentQuery.bindValue(
":gid", generationQuery.value(0).toInt());
if (segmentQuery.exec()) {
while (segmentQuery.next()) {
auto* segment = new LlmSegment(
segmentTypeFromName(
segmentQuery.value(0).toString()),
segmentQuery.value(8).toLongLong(),
generation);
segment->setText(
segmentQuery.value(1).toString());
segment->setName(
segmentQuery.value(2).toString());
segment->setToolCallId(
segmentQuery.value(3).toString());
segment->appendArguments(
segmentQuery.value(4).toString());
segment->setResult(
segmentQuery.value(5).toString());
segment->setStatus(
static_cast<LlmSegment::Status>(
segmentQuery.value(6).toInt()));
segment->restore(
segmentQuery.value(7).toLongLong());
generation->addSegment(segment);
}
} else {
qWarning() << "ChatStore: failed to load segments for "
<< "generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
}
if (generationQuery.value(2).toInt() != 0)
activeIndex = index;
++index;
}
} else {
qWarning() << "ChatStore: failed to load generations for message"
<< messageId << ":"
<< generationQuery.lastError().text();
}
message->setActiveGeneration(activeIndex);
messages.append(message);
}
session->adoptMessages(messages);
}
void ChatStore::load() {
QSqlQuery query(db());
query.exec(
"SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, "
"(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt "
"FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC");
while (query.next()) {
auto* session = new ChatSession(query.value(0).toString(), this);
session->setMeta(
query.value(1).toString(),
query.value(3).toLongLong(),
query.value(4).toLongLong(),
query.value(6).toInt());
session->setIcon(query.value(2).toString());
session->setPinned(query.value(5).toInt() != 0);
m_sessions.append(session);
}
sortAndNotify();
}
void ChatStore::sortAndNotify() {
const QList<ChatSession*> before = m_sessions;
std::stable_sort(
m_sessions.begin(),
m_sessions.end(),
[](const ChatSession* a, const ChatSession* b) {
if (a->pinned() != b->pinned())
return a->pinned() > b->pinned();
return a->updatedAtMs() > b->updatedAtMs();
});
notify(before);
}
void ChatStore::notify(const QList<ChatSession*>& before) {
if (before.size() != m_sessions.size())
Q_EMIT countChanged();
bool same = before.size() == m_sessions.size();
for (int i = 0; same && i < m_sessions.size(); ++i)
if (before.at(i) != m_sessions.at(i))
same = false;
if (!same)
Q_EMIT valuesChanged();
}
} // namespace ZShell::llm
-63
View File
@@ -1,63 +0,0 @@
#pragma once
#include "session.hpp"
#include <QObject>
#include <QSqlDatabase>
#include <QString>
#include <QVariantList>
namespace ZShell::llm {
class LlmClient;
class ChatStore : public QObject {
Q_OBJECT
QML_ANONYMOUS
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged)
public:
explicit ChatStore(QObject* parent = nullptr);
~ChatStore() override;
[[nodiscard]] int count() const;
[[nodiscard]] QVariantList values() const;
[[nodiscard]] ChatSession* at(int index) const;
Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1);
Q_INVOKABLE void remove(int index);
Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat);
Q_INVOKABLE void move(int from, int to);
Q_INVOKABLE void clear();
[[nodiscard]] ChatSession* sessionById(const QString& id);
[[nodiscard]] LlmClient* llmClient() const { return m_llmClient; }
void setLlmClient(LlmClient* client);
void persist(ChatSession* session);
void saveMeta(ChatSession* session);
void loadMessagesInto(ChatSession* session);
Q_SIGNALS:
void countChanged();
void valuesChanged();
void sessionRemoved(ZShell::llm::ChatSession* session);
private:
void openDb();
void load();
bool saveSession(ChatSession* session);
void sortAndNotify();
void removeSession(ChatSession* session);
void notify(const QList<ChatSession*>& before);
QList<ChatSession*> m_sessions;
LlmClient* m_llmClient = nullptr;
QString m_connectionName;
[[nodiscard]] QSqlDatabase db() const;
};
} // namespace ZShell::llm
-384
View File
@@ -1,384 +0,0 @@
#include "codehighlighter.hpp"
#include "highlight-queries.hpp"
#include <tree_sitter/api.h>
#include <QHash>
#include <QMap>
#include <QVariantMap>
#include <dlfcn.h>
namespace ZShell::llm {
namespace hl {
// Role ids; 0 means "no color".
enum Role : uint8_t {
None = 0,
Comment,
String,
StringKey,
Number,
Constant,
Keyword,
Type,
Function,
Method,
Macro,
Preproc,
Operator,
Property,
Label,
Attribute,
};
const char* roleName(Role role) {
switch (role) {
case Comment: return "comment";
case String: return "string";
case StringKey: return "string.key";
case Number: return "number";
case Constant: return "constant";
case Keyword: return "keyword";
case Type: return "type";
case Function: return "function";
case Method: return "method";
case Macro: return "macro";
case Preproc: return "preproc";
case Operator: return "operator";
case Property: return "property";
case Label: return "label";
case Attribute: return "attribute";
default: return "";
}
}
using LanguageFn = const TSLanguage* (*)();
// Query sources, indexed by Grammar::queries.
struct QuerySources {
std::vector<std::string> sources;
int c, cpp, python, javascript, typescriptExtended, typescript, bash, json, rust, go, yaml, toml, sql, cppExtended;
};
const QuerySources& querySources() {
static const QuerySources sources = [] {
QuerySources qs;
// javascript + typescript concatenated: the TS grammar reuses the
// JS node names, so the JS query usually compiles against it and
// gives full coverage; the TS-only query is the fallback.
const std::string tsExtended =
std::string(hq::javascript) + "\n" + std::string(hq::typescript);
// Same for cpp: the C++ grammar is a superset of C, and the C++
// query only covers the C++-specific delta. Base C coverage
// (keywords, types, calls, strings, comments) comes from the C
// query.
const std::string cppExtended =
std::string(hq::c) + "\n" + std::string(hq::cpp);
qs.sources.reserve(14);
qs.sources.push_back(hq::c);
qs.sources.push_back(hq::cpp);
qs.sources.push_back(hq::python);
qs.sources.push_back(hq::javascript);
qs.sources.push_back(tsExtended);
qs.sources.push_back(hq::typescript);
qs.sources.push_back(hq::bash);
qs.sources.push_back(hq::json);
qs.sources.push_back(hq::rust);
qs.sources.push_back(hq::go);
qs.sources.push_back(hq::yaml);
qs.sources.push_back(hq::toml);
qs.sources.push_back(hq::sql);
qs.sources.push_back(cppExtended);
qs.c = 0;
qs.cpp = 1;
qs.python = 2;
qs.javascript = 3;
qs.typescriptExtended = 4;
qs.typescript = 5;
qs.bash = 6;
qs.json = 7;
qs.rust = 8;
qs.go = 9;
qs.yaml = 10;
qs.toml = 11;
qs.sql = 12;
qs.cppExtended = 13;
return qs;
}();
return sources;
}
const QHash<QString, CodeHighlighter::Grammar>& grammars() {
static const QHash<QString, CodeHighlighter::Grammar> grammars = [] {
const auto& qs = querySources();
QHash<QString, CodeHighlighter::Grammar> map;
map.insert("c", {"libtree-sitter-c.so", "tree_sitter_c", {qs.c}});
map.insert("cpp", {"libtree-sitter-cpp.so", "tree_sitter_cpp", {qs.cppExtended, qs.cpp}});
map.insert("python", {"libtree-sitter-python.so", "tree_sitter_python", {qs.python}});
map.insert("javascript", {"libtree-sitter-javascript.so", "tree_sitter_javascript", {qs.javascript}});
map.insert("typescript", {"libtree-sitter-typescript.so", "tree_sitter_typescript", {qs.typescriptExtended, qs.typescript}});
map.insert("tsx", {"libtree-sitter-tsx.so", "tree_sitter_tsx", {qs.typescriptExtended, qs.typescript}});
map.insert("bash", {"libtree-sitter-bash.so", "tree_sitter_bash", {qs.bash}});
map.insert("json", {"libtree-sitter-json.so", "tree_sitter_json", {qs.json}});
map.insert("rust", {"libtree-sitter-rust.so", "tree_sitter_rust", {qs.rust}});
map.insert("go", {"libtree-sitter-go.so", "tree_sitter_go", {qs.go}});
map.insert("yaml", {"libtree-sitter-yaml.so", "tree_sitter_yaml", {qs.yaml}});
map.insert("toml", {"libtree-sitter-toml.so", "tree_sitter_toml", {qs.toml}});
map.insert("sql", {"libtree-sitter-sql.so", "tree_sitter_sql", {qs.sql}});
return map;
}();
return grammars;
}
} // namespace hl
CodeHighlighter* CodeHighlighter::s_instance = nullptr;
const QHash<QString, QString>& CodeHighlighter::aliases() {
// Language tags as written in code fences (and common variants) to
// grammar id.
static const QHash<QString, QString> aliases = [] {
QHash<QString, QString> map;
map.insert("c", "c");
map.insert("h", "c");
map.insert("cpp", "cpp");
map.insert("c++", "cpp");
map.insert("cc", "cpp");
map.insert("cxx", "cpp");
map.insert("h++", "cpp");
map.insert("hpp", "cpp");
map.insert("hh", "cpp");
map.insert("python", "python");
map.insert("py", "python");
map.insert("javascript", "javascript");
map.insert("js", "javascript");
map.insert("jsx", "javascript");
map.insert("mjs", "javascript");
map.insert("cjs", "javascript");
map.insert("typescript", "typescript");
map.insert("ts", "typescript");
map.insert("mts", "typescript");
map.insert("cts", "typescript");
map.insert("tsx", "tsx");
map.insert("bash", "bash");
map.insert("sh", "bash");
map.insert("shell", "bash");
map.insert("shellscript", "bash");
map.insert("shell-session", "bash");
map.insert("zsh", "bash");
map.insert("console", "bash");
map.insert("json", "json");
map.insert("jsonc", "json");
map.insert("rust", "rust");
map.insert("rs", "rust");
map.insert("go", "go");
map.insert("golang", "go");
map.insert("yaml", "yaml");
map.insert("yml", "yaml");
map.insert("toml", "toml");
map.insert("sql", "sql");
map.insert("mysql", "sql");
map.insert("postgres", "sql");
map.insert("postgresql", "sql");
map.insert("sqlite", "sql");
map.insert("sqlite3", "sql");
return map;
}();
return aliases;
}
const std::vector<std::string>& CodeHighlighter::querySources() const {
return hl::querySources().sources;
}
uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
const QString n = QString::fromUtf8(name, length);
if (n == "comment")
return hl::Role::Comment;
if (n.startsWith("string"))
return n == "string.special.key" ? hl::Role::StringKey : hl::Role::String;
if (n == "escape" || n == "regexp")
return hl::Role::String;
if (n.startsWith("number"))
return hl::Role::Number;
if (n.startsWith("constant"))
return hl::Role::Constant;
if (n.startsWith("keyword"))
return hl::Role::Keyword;
if (n == "type" || n.startsWith("type."))
return hl::Role::Type;
if (n == "namespace" || n == "module")
return hl::Role::Type;
if (n.startsWith("function") || n == "constructor")
return hl::Role::Function;
if (n == "method" || n == "method.builtin")
return hl::Role::Method;
if (n.startsWith("macro"))
return hl::Role::Macro;
if (n.startsWith("preproc"))
return hl::Role::Preproc;
if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator."))
return hl::Role::Operator;
if (n == "property" || n == "field" || n.startsWith("property."))
return hl::Role::Property;
if (n == "label")
return hl::Role::Label;
if (n.startsWith("attribute") || n == "annotation")
return hl::Role::Attribute;
return hl::Role::None;
}
const char* CodeHighlighter::roleName(uint8_t role) {
return hl::roleName(static_cast<hl::Role>(role));
}
QVariantList CodeHighlighter::highlight(const QString& code, const QString& language) const {
QVariantList spans;
if (code.isEmpty())
return spans;
const QString id = aliases().value(language.trimmed().toLower());
if (id.isEmpty())
return spans;
const Grammar& grammar = hl::grammars().value(id);
// Guard against pathological blocks; highlighting is best-effort.
static constexpr size_t kMaxBytes = 512 * 1024;
const QByteArray utf8 = code.toUtf8();
if (static_cast<size_t>(utf8.size()) > kMaxBytes)
return spans;
State& state = m_states[id];
if (state.bad)
return spans;
if (!state.lang) {
// Missing library is retriable (it may be installed while the
// shell runs); ABI/query failures below are not.
state.lib = dlopen(grammar.lib.toUtf8().constData(), RTLD_NOW | RTLD_LOCAL);
if (!state.lib)
return spans;
auto* symbol = reinterpret_cast<hl::LanguageFn>(
dlsym(state.lib, grammar.symbol.toUtf8().constData()));
if (!symbol) {
dlclose(state.lib);
state.lib = nullptr;
return spans;
}
const TSLanguage* lang = symbol();
const uint32_t version = ts_language_abi_version(lang);
if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION ||
version > TREE_SITTER_LANGUAGE_VERSION) {
state.bad = true;
return spans;
}
state.lang = lang;
}
if (!state.query) {
// Candidates in priority order; first that compiles wins.
for (const int candidate : grammar.queries) {
const std::string& source =
querySources()[static_cast<size_t>(candidate)];
TSQueryError errorType = TSQueryErrorNone;
uint32_t errorOffset = 0;
TSQuery* query = ts_query_new(
static_cast<const TSLanguage*>(state.lang),
source.data(),
static_cast<uint32_t>(source.size()),
&errorOffset,
&errorType);
if (!query)
continue;
state.query = query;
break;
}
if (!state.query) {
state.bad = true;
return spans;
}
}
TSParser* parser = ts_parser_new();
ts_parser_set_language(parser, static_cast<const TSLanguage*>(state.lang));
TSTree* tree = ts_parser_parse_string(
parser, nullptr, utf8.constData(), static_cast<uint32_t>(utf8.size()));
if (!tree) {
ts_parser_delete(parser);
return spans;
}
TSQuery* query = static_cast<TSQuery*>(state.query);
TSQueryCursor* cursor = ts_query_cursor_new();
ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree));
// Per-byte winner table: captures arrive in document order and later
// captures overwrite earlier ones (tree-sitter highlight convention).
const uint32_t size = static_cast<uint32_t>(utf8.size());
std::vector<uint8_t> kinds(size, 0);
// QML slices the code by UTF-16 code unit, so spans must be in code
// units, not bytes. cu[b] = code units before byte b.
std::vector<uint32_t> cu(size + 1, 0);
for (uint32_t b = 0; b < size; ++b) {
cu[b + 1] = cu[b];
const unsigned char c = static_cast<unsigned char>(utf8[b]);
if (c < 0x80)
cu[b + 1] += 1;
else if (c < 0xC0)
; // continuation byte
else if (c < 0xF0)
cu[b + 1] += 1; // 2/3-byte lead -> BMP -> one unit
else
cu[b + 1] += 2; // 4-byte lead -> surrogate pair
}
TSQueryMatch match;
uint32_t captureIndex = 0;
while (ts_query_cursor_next_capture(cursor, &match, &captureIndex)) {
const TSQueryCapture& capture = match.captures[captureIndex];
uint32_t nameLength = 0;
const char* name = ts_query_capture_name_for_id(query, capture.index, &nameLength);
const uint8_t role = roleFor(name, nameLength);
if (role == 0)
continue;
const uint32_t start = ts_node_start_byte(capture.node);
const uint32_t end = ts_node_end_byte(capture.node);
if (end <= start || end > size)
continue;
std::fill(kinds.begin() + start, kinds.begin() + end, role);
}
uint32_t position = 0;
while (position < size) {
if (kinds[position] == 0) {
++position;
continue;
}
const uint8_t role = kinds[position];
const uint32_t start = position;
while (position < size && kinds[position] == role)
++position;
QVariantMap span;
span.insert("start", static_cast<int>(cu[start]));
span.insert("length", static_cast<int>(cu[position] - cu[start]));
span.insert("kind", roleName(role));
spans.append(span);
}
ts_query_cursor_delete(cursor);
ts_tree_delete(tree);
ts_parser_delete(parser);
return spans;
}
CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) {
if (!s_instance)
s_instance = new CodeHighlighter();
return s_instance;
}
} // namespace ZShell::llm
-69
View File
@@ -1,69 +0,0 @@
#pragma once
#include <QObject>
#include <QString>
#include <QVariantList>
#include <QtQml>
#include <cstdint>
#include <vector>
class QQmlEngine;
class QJSEngine;
namespace ZShell::llm {
// Syntax highlighting for LLM code blocks via tree-sitter.
//
// The tree-sitter runtime is linked. Grammar libraries
// (libtree-sitter-<lang>.so) are dlopen()'d lazily, so a missing
// grammar package degrades that language to plain text instead of
// breaking the build or the app. Highlight queries are embedded at
// build time (highlight-queries/*.scm, vendored from the grammar
// repos, MIT).
//
// The fence language the LLM wrote (```cpp, ```python, ...) is mapped
// to a grammar through an alias table.
//
// highlight() returns a list of span maps:
// { "start": int, "length": int, "kind": QString }
// where kind is a semantic role (keyword, string, comment, number,
// function, type, ...) that QML maps to theme colors. An empty list
// means "no highlighting" (unknown language or grammar not installed).
class CodeHighlighter : public QObject {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
public:
Q_INVOKABLE QVariantList highlight(const QString& code, const QString& language) const;
static CodeHighlighter* create(QQmlEngine*, QJSEngine*);
struct Grammar {
QString lib; // soname to dlopen
QString symbol; // tree_sitter_<lang>() entry point
// Candidate query sources, first wins. Lets typescript reuse
// the javascript query when it compiles against the grammar.
std::vector<int> queries; // indices into querySources()
};
private:
struct State {
bool bad = false; // permanent failure, do not retry
void* lib = nullptr;
const void* lang = nullptr; // const TSLanguage*
void* query = nullptr; // TSQuery*
};
[[nodiscard]] static const QHash<QString, QString>& aliases();
[[nodiscard]] const std::vector<std::string>& querySources() const;
// Maps a tree-sitter capture name to a role index (0 = unstyled).
[[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length);
[[nodiscard]] static const char* roleName(uint8_t role);
mutable QHash<QString, State> m_states;
static CodeHighlighter* s_instance;
};
} // namespace ZShell::llm
@@ -1,40 +0,0 @@
% This is a preliminary version (2006-09-30), barring acceptance from
% the LaTeX Project Team and other feedback, of the GUST Font License.
% (GUST is the Polish TeX Users Group, http://www.gust.org.pl)
%
% For the most recent version of this license see
% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt
% or
% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt
%
% This work may be distributed and/or modified under the conditions
% of the LaTeX Project Public License, either version 1.3c of this
% license or (at your option) any later version.
%
% Please also observe the following clause:
% 1) it is requested, but not legally required, that derived works be
% distributed only after changing the names of the fonts comprising this
% work and given in an accompanying "manifest", and that the
% files comprising the Work, as listed in the manifest, also be given
% new names. Any exceptions to this request are also given in the
% manifest.
%
% We recommend the manifest be given in a separate file named
% MANIFEST-<fontid>.txt, where <fontid> is some unique identification
% of the font family. If a separate "readme" file accompanies the Work,
% we recommend a name of the form README-<fontid>.txt.
%
% The latest version of the LaTeX Project Public License is in
% http://www.latex-project.org/lppl.txt and version 1.3c or later
% is part of all distributions of LaTeX version 2006/05/20 or later.
---
Provenance:
lmroman10-*.otf: Latin Modern v2.007 (GUST, 31-03-2026)
https://www.gust.org.pl/projects/e-foundry/latin-modern/download
(Latin_Modern-otf-2_007-31_03_2026.zip)
latinmodern-math.otf: Latin Modern Math v1.959 (GUST)
https://www.gust.org.pl/projects/e-foundry/lm-math/download
(latinmodern-math-1959.zip; same release as CTAN fonts/lm-math)
-250
View File
@@ -1,250 +0,0 @@
#include "generation.hpp"
#include <QDateTime>
namespace ZShell::llm {
ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent)
: QObject(parent), m_timestamp(timestamp) {
m_timer.setParent(this);
m_timer.setInterval(500);
m_timer.setTimerType(Qt::CoarseTimer);
connect(&m_timer, &QTimer::timeout, this, [this]() {
bool anyRunning = false;
for (auto* segment : m_segments) {
if (!segment->running())
continue;
anyRunning = true;
segment->elapsedMsChanged();
}
if (anyRunning)
Q_EMIT elapsedMsChanged();
if (!anyRunning && !m_streaming)
m_timer.stop();
});
}
QString ChatGeneration::content() const {
QStringList parts;
for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content ||
segment->text().isEmpty())
continue;
parts.append(segment->text());
}
return parts.join(QStringLiteral("\n\n"));
}
QString ChatGeneration::reasoning() const {
QStringList parts;
for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Reasoning ||
segment->text().isEmpty())
continue;
parts.append(segment->text());
}
return parts.join(QStringLiteral("\n\n"));
}
bool ChatGeneration::reasoningActive() const {
if (!m_streaming)
return false;
if (!content().isEmpty())
return false;
return !hasRunningTool();
}
qint64 ChatGeneration::reasoningElapsedMs() const {
qint64 total = 0;
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Reasoning)
total += segment->elapsedMs();
return total;
}
qint64 ChatGeneration::contentElapsedMs() const {
qint64 total = 0;
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Content)
total += segment->elapsedMs();
return total;
}
qint64 ChatGeneration::toolsElapsedMs() const {
qint64 total = 0;
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall)
total += segment->elapsedMs();
return total;
}
int ChatGeneration::toolCallCount() const {
int count = 0;
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall)
++count;
return count;
}
bool ChatGeneration::hasRunningTool() const {
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->running())
return true;
return false;
}
void ChatGeneration::updateReasoningActive() {
const bool active = reasoningActive();
if (m_reasoningActive == active)
return;
m_reasoningActive = active;
Q_EMIT reasoningActiveChanged();
}
void ChatGeneration::setContent(const QString& value) {
// Replaces the entire answer: the first content segment takes the
// new text, any later content bursts are cleared.
LlmSegment* first = nullptr;
for (auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content)
continue;
if (!first)
first = segment;
else
segment->setText(QString());
}
if (!first) {
// Do not materialize an empty content segment (e.g. the assistant
// placeholder created before the stream starts).
if (value.isEmpty())
return;
first = new LlmSegment(
LlmSegment::Type::Content,
QDateTime::currentMSecsSinceEpoch(),
this);
addSegment(first);
}
first->setText(value);
}
void ChatGeneration::appendContent(const QString& piece) {
if (piece.isEmpty())
return;
for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Reasoning &&
segment->running())
segment->close();
}
openContentSegment()->appendText(piece);
}
void ChatGeneration::appendReasoning(const QString& piece) {
if (piece.isEmpty())
return;
for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Content &&
segment->running())
segment->close();
}
openReasoningSegment()->appendText(piece);
}
void ChatGeneration::setStreaming(bool value) {
if (m_streaming == value)
return;
m_streaming = value;
Q_EMIT streamingChanged();
if (value) {
if (!m_timer.isActive())
m_timer.start();
} else {
closeOpenSegments();
m_timer.stop();
}
Q_EMIT elapsedMsChanged();
updateReasoningActive();
}
LlmSegment* ChatGeneration::openContentSegment() {
for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Content &&
segment->running())
return segment;
auto* segment = new LlmSegment(
LlmSegment::Type::Content,
QDateTime::currentMSecsSinceEpoch(),
this);
segment->begin();
addSegment(segment);
return segment;
}
LlmSegment* ChatGeneration::openReasoningSegment() {
for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Reasoning &&
segment->running())
return segment;
auto* segment = new LlmSegment(
LlmSegment::Type::Reasoning,
QDateTime::currentMSecsSinceEpoch(),
this);
segment->begin();
addSegment(segment);
return segment;
}
LlmSegment* ChatGeneration::beginToolCall(
const QString& name, const QString& toolCallId) {
closeOpenSegments();
auto* segment = new LlmSegment(
LlmSegment::Type::ToolCall,
QDateTime::currentMSecsSinceEpoch(),
this);
segment->setName(name);
segment->setToolCallId(toolCallId);
segment->setStatus(LlmSegment::Status::Running);
segment->begin();
addSegment(segment);
Q_EMIT toolStateChanged();
return segment;
}
void ChatGeneration::addSegment(LlmSegment* segment) {
if (!segment || m_segments.contains(segment))
return;
segment->setParent(this);
connect(
segment, &LlmSegment::textChanged, this, [this, segment]() {
if (segment->type() == LlmSegment::Type::Reasoning)
Q_EMIT reasoningChanged();
else if (segment->type() == LlmSegment::Type::Content)
Q_EMIT contentChanged();
updateReasoningActive();
});
connect(
segment, &LlmSegment::statusChanged, this, [this]() {
Q_EMIT toolStateChanged();
});
connect(
segment, &LlmSegment::resultChanged, this, [this]() {
Q_EMIT toolStateChanged();
});
connect(
segment, &LlmSegment::runningChanged, this, [this]() {
Q_EMIT elapsedMsChanged();
Q_EMIT toolStateChanged();
updateReasoningActive();
});
m_segments.append(segment);
Q_EMIT segmentsChanged();
}
void ChatGeneration::closeOpenSegments() {
for (auto* segment : m_segments)
if (segment->running())
segment->close();
updateReasoningActive();
}
} // namespace ZShell::llm
-94
View File
@@ -1,94 +0,0 @@
#pragma once
#include "segment.hpp"
#include <QList>
#include <QObject>
#include <QString>
#include <QTimer>
#include <QtQml>
namespace ZShell::llm {
// One attempt at answering a message: a chronologically ordered list
// of segments. Content bursts, reasoning bursts and tool calls all
// appear in the order the model produced them; a new content (or
// reasoning) segment starts whenever the model switches between them.
// Together with the attempt's aggregate state.
class ChatGeneration : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Chat generations are managed by ChatMessage")
Q_PROPERTY(QString content READ content WRITE setContent NOTIFY contentChanged)
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged)
Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged)
Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(
QList<ZShell::llm::LlmSegment*> segments READ segments
NOTIFY segmentsChanged)
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged)
public:
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
// All content bursts, joined (the model's full answer).
[[nodiscard]] QString content() const;
// Every reasoning burst, joined.
[[nodiscard]] QString reasoning() const;
// True while the model is thinking: streaming, no content yet, and
// no tool call in flight.
[[nodiscard]] bool reasoningActive() const;
[[nodiscard]] qint64 reasoningElapsedMs() const;
[[nodiscard]] qint64 contentElapsedMs() const;
[[nodiscard]] qint64 toolsElapsedMs() const;
[[nodiscard]] bool streaming() const { return m_streaming; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
[[nodiscard]] int toolCallCount() const;
[[nodiscard]] bool hasRunningTool() const;
void setContent(const QString& value);
void appendContent(const QString& piece);
void appendReasoning(const QString& piece);
void setStreaming(bool value);
// The in-flight content segment, or a fresh one. A new content
// segment starts whenever the model resumes writing after reasoning
// or a tool call.
[[nodiscard]] LlmSegment* openContentSegment();
// The in-flight reasoning segment, or a fresh one.
[[nodiscard]] LlmSegment* openReasoningSegment();
// Creates and appends a running tool-call segment.
[[nodiscard]] LlmSegment* beginToolCall(
const QString& name, const QString& toolCallId);
// Appends a segment created by the persistence layer.
void addSegment(LlmSegment* segment);
// Stops the clocks of every in-flight segment.
void closeOpenSegments();
Q_SIGNALS:
void contentChanged();
void reasoningChanged();
void reasoningActiveChanged();
void elapsedMsChanged();
void streamingChanged();
void toolStateChanged();
void segmentsChanged();
private:
void updateReasoningActive();
QTimer m_timer;
QList<LlmSegment*> m_segments;
bool m_reasoningActive = false;
bool m_streaming = false;
qint64 m_timestamp;
};
} // namespace ZShell::llm
@@ -1,59 +0,0 @@
; Vendored from bash (MIT License)
; Source: https://github.com/tree-sitter/tree-sitter-bash
[
(string)
(raw_string)
(heredoc_body)
(heredoc_start)
] @string
(command_name) @function
(variable_name) @property
[
"case"
"do"
"done"
"elif"
"else"
"esac"
"export"
"fi"
"for"
"function"
"if"
"in"
"select"
"then"
"unset"
"until"
"while"
] @keyword
(comment) @comment
(function_definition name: (word) @function)
(file_descriptor) @number
[
(command_substitution)
(process_substitution)
(expansion)
]@embedded
[
"$"
"&&"
">"
">>"
"<"
"|"
] @operator
(
(command (_) @constant)
(#match? @constant "^-")
)
@@ -1,84 +0,0 @@
; Vendored from c (MIT License)
; Source: https://github.com/tree-sitter/tree-sitter-c
(identifier) @variable
((identifier) @constant
(#match? @constant "^[A-Z][A-Z\\d_]*$"))
"break" @keyword
"case" @keyword
"const" @keyword
"continue" @keyword
"default" @keyword
"do" @keyword
"else" @keyword
"enum" @keyword
"extern" @keyword
"for" @keyword
"if" @keyword
"inline" @keyword
"return" @keyword
"sizeof" @keyword
"static" @keyword
"struct" @keyword
"switch" @keyword
"typedef" @keyword
"union" @keyword
"volatile" @keyword
"while" @keyword
"#define" @keyword
"#elif" @keyword
"#else" @keyword
"#endif" @keyword
"#if" @keyword
"#ifdef" @keyword
"#ifndef" @keyword
"#include" @keyword
(preproc_directive) @keyword
"--" @operator
"-" @operator
"-=" @operator
"->" @operator
"=" @operator
"!=" @operator
"*" @operator
"&" @operator
"&&" @operator
"+" @operator
"++" @operator
"+=" @operator
"<" @operator
"==" @operator
">" @operator
"||" @operator
"." @delimiter
";" @delimiter
(string_literal) @string
(system_lib_string) @string
(null) @constant
(number_literal) @number
(char_literal) @number
(field_identifier) @property
(statement_identifier) @label
(type_identifier) @type
(primitive_type) @type
(sized_type_specifier) @type
(call_expression
function: (identifier) @function)
(call_expression
function: (field_expression
field: (field_identifier) @function))
(function_declarator
declarator: (identifier) @function)
(preproc_function_def
name: (identifier) @function.special)
(comment) @comment
@@ -1,73 +0,0 @@
; Vendored from cpp (MIT License)
; Source: https://github.com/tree-sitter/tree-sitter-cpp (tag v0.23.4, matching the distro grammar)
; Functions
(call_expression
function: (qualified_identifier
name: (identifier) @function))
(template_function
name: (identifier) @function)
(template_method
name: (field_identifier) @function)
(template_function
name: (identifier) @function)
(function_declarator
declarator: (qualified_identifier
name: (identifier) @function))
(function_declarator
declarator: (field_identifier) @function)
; Types
((namespace_identifier) @type
(#match? @type "^[A-Z]"))
(auto) @type
; Constants
(this) @variable.builtin
(null "nullptr" @constant)
; Keywords
[
"catch"
"class"
"co_await"
"co_return"
"co_yield"
"constexpr"
"constinit"
"consteval"
"delete"
"explicit"
"final"
"friend"
"mutable"
"namespace"
"noexcept"
"new"
"override"
"private"
"protected"
"public"
"template"
"throw"
"try"
"typename"
"using"
"concept"
"requires"
"virtual"
] @keyword
; Strings
(raw_string_literal) @string
-126
View File
@@ -1,126 +0,0 @@
; Vendored from go (MIT License)
; Source: https://github.com/tree-sitter/tree-sitter-go
; Function calls
(call_expression
function: (identifier) @function)
(call_expression
function: (identifier) @function.builtin
(#match? @function.builtin "^(append|cap|close|complex|copy|delete|imag|len|make|new|panic|print|println|real|recover)$"))
(call_expression
function: (selector_expression
field: (field_identifier) @function.method))
; Function definitions
(function_declaration
name: (identifier) @function)
(method_declaration
name: (field_identifier) @function.method)
; Identifiers
(type_identifier) @type
(field_identifier) @property
(identifier) @variable
; Operators
[
"--"
"-"
"-="
":="
"!"
"!="
"..."
"*"
"*"
"*="
"/"
"/="
"&"
"&&"
"&="
"%"
"%="
"^"
"^="
"+"
"++"
"+="
"<-"
"<"
"<<"
"<<="
"<="
"="
"=="
">"
">="
">>"
">>="
"|"
"|="
"||"
"~"
] @operator
; Keywords
[
"break"
"case"
"chan"
"const"
"continue"
"default"
"defer"
"else"
"fallthrough"
"for"
"func"
"go"
"goto"
"if"
"import"
"interface"
"map"
"package"
"range"
"return"
"select"
"struct"
"switch"
"type"
"var"
] @keyword
; Literals
[
(interpreted_string_literal)
(raw_string_literal)
(rune_literal)
] @string
(escape_sequence) @escape
[
(int_literal)
(float_literal)
(imaginary_literal)
] @number
[
(true)
(false)
(nil)
(iota)
] @constant.builtin
(comment) @comment
@@ -1,207 +0,0 @@
; Vendored from javascript (MIT License)
; Source: https://github.com/tree-sitter/tree-sitter-javascript
; Variables
;----------
(identifier) @variable
; Properties
;-----------
(property_identifier) @property
; Function and method definitions
;--------------------------------
(function_expression
name: (identifier) @function)
(function_declaration
name: (identifier) @function)
(method_definition
name: (property_identifier) @function.method)
(pair
key: (property_identifier) @function.method
value: [(function_expression) (arrow_function)])
(assignment_expression
left: (member_expression
property: (property_identifier) @function.method)
right: [(function_expression) (arrow_function)])
(variable_declarator
name: (identifier) @function
value: [(function_expression) (arrow_function)])
(assignment_expression
left: (identifier) @function
right: [(function_expression) (arrow_function)])
; Function and method calls
;--------------------------
(call_expression
function: (identifier) @function)
(call_expression
function: (member_expression
property: (property_identifier) @function.method))
; Special identifiers
;--------------------
((identifier) @constructor
(#match? @constructor "^[A-Z]"))
([
(identifier)
(shorthand_property_identifier)
(shorthand_property_identifier_pattern)
] @constant
(#match? @constant "^[A-Z_][A-Z\\d_]+$"))
((identifier) @variable.builtin
(#match? @variable.builtin "^(arguments|module|console|window|document)$")
(#is-not? local))
((identifier) @function.builtin
(#eq? @function.builtin "require")
(#is-not? local))
; Literals
;---------
(this) @variable.builtin
(super) @variable.builtin
[
(true)
(false)
(null)
(undefined)
] @constant.builtin
(comment) @comment
[
(string)
(template_string)
] @string
(regex) @string.special
(number) @number
; Tokens
;-------
[
";"
(optional_chain)
"."
","
] @punctuation.delimiter
[
"-"
"--"
"-="
"+"
"++"
"+="
"*"
"*="
"**"
"**="
"/"
"/="
"%"
"%="
"<"
"<="
"<<"
"<<="
"="
"=="
"==="
"!"
"!="
"!=="
"=>"
">"
">="
">>"
">>="
">>>"
">>>="
"~"
"^"
"&"
"|"
"^="
"&="
"|="
"&&"
"||"
"??"
"&&="
"||="
"??="
] @operator
[
"("
")"
"["
"]"
"{"
"}"
] @punctuation.bracket
(template_substitution
"${" @punctuation.special
"}" @punctuation.special) @embedded
[
"as"
"async"
"await"
"break"
"case"
"catch"
"class"
"const"
"continue"
"debugger"
"default"
"delete"
"do"
"else"
"export"
"extends"
"finally"
"for"
"from"
"function"
"get"
"if"
"import"
"in"
"instanceof"
"let"
"new"
"of"
"return"
"set"
"static"
"switch"
"target"
"throw"
"try"
"typeof"
"var"
"void"
"while"
"with"
"yield"
] @keyword
@@ -1,19 +0,0 @@
; Vendored from json (MIT License)
; Source: https://github.com/tree-sitter/tree-sitter-json
(pair
key: (_) @string.special.key)
(string) @string
(number) @number
[
(null)
(true)
(false)
] @constant.builtin
(escape_sequence) @escape
(comment) @comment
@@ -1,140 +0,0 @@
; Vendored from python (MIT License)
; Source: https://github.com/tree-sitter/tree-sitter-python
; Identifier naming conventions
(identifier) @variable
((identifier) @constructor
(#match? @constructor "^[A-Z]"))
((identifier) @constant
(#match? @constant "^[A-Z][A-Z_]*$"))
; Function calls
(decorator) @function
(decorator
(identifier) @function)
(call
function: (attribute attribute: (identifier) @function.method))
(call
function: (identifier) @function)
; Builtin functions
((call
function: (identifier) @function.builtin)
(#match?
@function.builtin
"^(abs|all|any|ascii|bin|bool|breakpoint|bytearray|bytes|callable|chr|classmethod|compile|complex|delattr|dict|dir|divmod|enumerate|eval|exec|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|isinstance|issubclass|iter|len|list|locals|map|max|memoryview|min|next|object|oct|open|ord|pow|print|property|range|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|vars|zip|__import__)$"))
; Function definitions
(function_definition
name: (identifier) @function)
(attribute attribute: (identifier) @property)
(type (identifier) @type)
; Literals
[
(none)
(true)
(false)
] @constant.builtin
[
(integer)
(float)
] @number
(comment) @comment
(string) @string
(escape_sequence) @escape
(interpolation
"{" @punctuation.special
"}" @punctuation.special) @embedded
[
"-"
"-="
"!="
"*"
"**"
"**="
"*="
"/"
"//"
"//="
"/="
"&"
"&="
"%"
"%="
"^"
"^="
"+"
"->"
"+="
"<"
"<<"
"<<="
"<="
"<>"
"="
":="
"=="
">"
">="
">>"
">>="
"|"
"|="
"~"
"@="
"and"
"in"
"is"
"not"
"or"
"is not"
"not in"
] @operator
[
"as"
"assert"
"async"
"await"
"break"
"class"
"continue"
"def"
"del"
"elif"
"else"
"except"
"exec"
"finally"
"for"
"from"
"global"
"if"
"import"
"lambda"
"nonlocal"
"pass"
"print"
"raise"
"return"
"try"
"while"
"with"
"yield"
"match"
"case"
] @keyword
@@ -1,164 +0,0 @@
; Vendored from rust (MIT License)
; Source: https://github.com/tree-sitter/tree-sitter-rust
; Identifiers
(type_identifier) @type
(primitive_type) @type.builtin
(field_identifier) @property
; Identifier conventions
; Assume all-caps names are constants
((identifier) @constant
(#match? @constant "^[A-Z][A-Z\\d_]+$'"))
; Assume uppercase names are enum constructors
((identifier) @constructor
(#match? @constructor "^[A-Z]"))
; Assume that uppercase names in paths are types
((scoped_identifier
path: (identifier) @type)
(#match? @type "^[A-Z]"))
((scoped_identifier
path: (scoped_identifier
name: (identifier) @type))
(#match? @type "^[A-Z]"))
((scoped_type_identifier
path: (identifier) @type)
(#match? @type "^[A-Z]"))
((scoped_type_identifier
path: (scoped_identifier
name: (identifier) @type))
(#match? @type "^[A-Z]"))
; Assume all qualified names in struct patterns are enum constructors. (They're
; either that, or struct names; highlighting both as constructors seems to be
; the less glaring choice of error, visually.)
(struct_pattern
type: (scoped_type_identifier
name: (type_identifier) @constructor))
; Function calls
(call_expression
function: (identifier) @function)
(call_expression
function: (field_expression
field: (field_identifier) @function.method))
(call_expression
function: (scoped_identifier
"::"
name: (identifier) @function))
(generic_function
function: (identifier) @function)
(generic_function
function: (scoped_identifier
name: (identifier) @function))
(generic_function
function: (field_expression
field: (field_identifier) @function.method))
(macro_invocation
macro: (identifier) @function.macro
"!" @function.macro)
; Function definitions
(function_item (identifier) @function)
(function_signature_item (identifier) @function)
(line_comment) @comment
(block_comment) @comment
(line_comment (doc_comment)) @comment.documentation
(block_comment (doc_comment)) @comment.documentation
"(" @punctuation.bracket
")" @punctuation.bracket
"[" @punctuation.bracket
"]" @punctuation.bracket
"{" @punctuation.bracket
"}" @punctuation.bracket
(type_arguments
"<" @punctuation.bracket
">" @punctuation.bracket)
(type_parameters
"<" @punctuation.bracket
">" @punctuation.bracket)
"::" @punctuation.delimiter
":" @punctuation.delimiter
"." @punctuation.delimiter
"," @punctuation.delimiter
";" @punctuation.delimiter
(parameter (identifier) @variable.parameter)
(lifetime (identifier) @label)
"as" @keyword
"async" @keyword
"await" @keyword
"break" @keyword
"const" @keyword
"continue" @keyword
"default" @keyword
"dyn" @keyword
"else" @keyword
"enum" @keyword
"extern" @keyword
"fn" @keyword
"for" @keyword
"gen" @keyword
"if" @keyword
"impl" @keyword
"in" @keyword
"let" @keyword
"loop" @keyword
"macro_rules!" @keyword
"match" @keyword
"mod" @keyword
"move" @keyword
"pub" @keyword
"raw" @keyword
"ref" @keyword
"return" @keyword
"static" @keyword
"struct" @keyword
"trait" @keyword
"type" @keyword
"union" @keyword
"unsafe" @keyword
"use" @keyword
"where" @keyword
"while" @keyword
"yield" @keyword
(crate) @keyword
(mutable_specifier) @keyword
(use_list (self) @keyword)
(scoped_use_list (self) @keyword)
(scoped_identifier (self) @keyword)
(super) @keyword
(self) @variable.builtin
(char_literal) @string
(string_literal) @string
(raw_string_literal) @string
(boolean_literal) @constant.builtin
(integer_literal) @constant.builtin
(float_literal) @constant.builtin
(escape_sequence) @escape
(attribute_item) @attribute
(inner_attribute_item) @attribute
"*" @operator
"&" @operator
"'" @operator
@@ -1,463 +0,0 @@
; Vendored from sql (MIT License)
; Source: https://github.com/DerekStride/tree-sitter-sql
(object_reference
name: (identifier) @type)
(invocation
(object_reference
name: (identifier) @function.call))
[
(keyword_gist)
(keyword_btree)
(keyword_hash)
(keyword_spgist)
(keyword_gin)
(keyword_brin)
(keyword_array)
(keyword_object_id)
] @function.call
(relation
alias: (identifier) @variable)
(field
name: (identifier) @field)
(term
alias: (identifier) @variable)
((term
value: (cast
name: (keyword_cast) @function.call
parameter: [(literal)]?)))
(literal) @string
(comment) @comment @spell
(marginalia) @comment
((literal) @number
(#match? @number "^[-+]?%d+$"))
((literal) @float
(#match? @float "^[-+]?%d*\.%d*$"))
(parameter) @parameter
[
(keyword_true)
(keyword_false)
] @boolean
[
(keyword_asc)
(keyword_desc)
(keyword_terminated)
(keyword_escaped)
(keyword_unsigned)
(keyword_nulls)
(keyword_last)
(keyword_delimited)
(keyword_replication)
(keyword_auto_increment)
(keyword_default)
(keyword_collate)
(keyword_concurrently)
(keyword_engine)
(keyword_always)
(keyword_generated)
(keyword_preceding)
(keyword_following)
(keyword_first)
(keyword_current_timestamp)
(keyword_immutable)
(keyword_atomic)
(keyword_parallel)
(keyword_leakproof)
(keyword_safe)
(keyword_cost)
(keyword_strict)
] @attribute
[
(keyword_materialized)
(keyword_recursive)
(keyword_temp)
(keyword_temporary)
(keyword_unlogged)
(keyword_external)
(keyword_parquet)
(keyword_csv)
(keyword_rcfile)
(keyword_textfile)
(keyword_orc)
(keyword_avro)
(keyword_jsonfile)
(keyword_sequencefile)
(keyword_volatile)
] @storageclass
[
(keyword_case)
(keyword_when)
(keyword_then)
(keyword_else)
] @conditional
[
(keyword_select)
(keyword_from)
(keyword_where)
(keyword_index)
(keyword_join)
(keyword_primary)
(keyword_delete)
(keyword_create)
(keyword_show)
(keyword_unload)
(keyword_insert)
(keyword_merge)
(keyword_distinct)
(keyword_replace)
(keyword_update)
(keyword_into)
(keyword_overwrite)
(keyword_matched)
(keyword_values)
(keyword_value)
(keyword_attribute)
(keyword_set)
(keyword_left)
(keyword_right)
(keyword_outer)
(keyword_inner)
(keyword_full)
(keyword_order)
(keyword_partition)
(keyword_group)
(keyword_with)
(keyword_without)
(keyword_as)
(keyword_having)
(keyword_limit)
(keyword_offset)
(keyword_table)
(keyword_tables)
(keyword_key)
(keyword_references)
(keyword_foreign)
(keyword_constraint)
(keyword_force)
(keyword_use)
(keyword_include)
(keyword_for)
(keyword_if)
(keyword_exists)
(keyword_column)
(keyword_columns)
(keyword_cross)
(keyword_lateral)
(keyword_natural)
(keyword_alter)
(keyword_drop)
(keyword_add)
(keyword_view)
(keyword_end)
(keyword_is)
(keyword_using)
(keyword_between)
(keyword_window)
(keyword_no)
(keyword_data)
(keyword_type)
(keyword_rename)
(keyword_refresh)
(keyword_to)
(keyword_schema)
(keyword_owner)
(keyword_authorization)
(keyword_all)
(keyword_any)
(keyword_some)
(keyword_returning)
(keyword_begin)
(keyword_commit)
(keyword_rollback)
(keyword_transaction)
(keyword_only)
(keyword_like)
(keyword_rlike)
(keyword_similar)
(keyword_over)
(keyword_change)
(keyword_modify)
(keyword_after)
(keyword_before)
(keyword_range)
(keyword_rows)
(keyword_groups)
(keyword_exclude)
(keyword_current)
(keyword_ties)
(keyword_others)
(keyword_zerofill)
(keyword_format)
(keyword_fields)
(keyword_row)
(keyword_sort)
(keyword_compute)
(keyword_comment)
(keyword_location)
(keyword_cached)
(keyword_uncached)
(keyword_lines)
(keyword_stored)
(keyword_virtual)
(keyword_partitioned)
(keyword_analyze)
(keyword_explain)
(keyword_verbose)
(keyword_truncate)
(keyword_rewrite)
(keyword_optimize)
(keyword_vacuum)
(keyword_cache)
(keyword_language)
(keyword_called)
(keyword_conflict)
(keyword_declare)
(keyword_filter)
(keyword_function)
(keyword_input)
(keyword_name)
(keyword_oid)
(keyword_oids)
(keyword_precision)
(keyword_regclass)
(keyword_regnamespace)
(keyword_regproc)
(keyword_regtype)
(keyword_restricted)
(keyword_return)
(keyword_returns)
(keyword_separator)
(keyword_setof)
(keyword_stable)
(keyword_support)
(keyword_tblproperties)
(keyword_trigger)
(keyword_unsafe)
(keyword_admin)
(keyword_connection)
(keyword_cycle)
(keyword_database)
(keyword_encrypted)
(keyword_increment)
(keyword_logged)
(keyword_none)
(keyword_owned)
(keyword_password)
(keyword_reset)
(keyword_role)
(keyword_current_role)
(keyword_sequence)
(keyword_start)
(keyword_restart)
(keyword_tablespace)
(keyword_split)
(keyword_tablets)
(keyword_until)
(keyword_user)
(keyword_current_user)
(keyword_session_user)
(keyword_valid)
(keyword_action)
(keyword_definer)
(keyword_invoker)
(keyword_enable)
(keyword_disable)
(keyword_security)
(keyword_policy)
(keyword_permissive)
(keyword_restrictive)
(keyword_public)
(keyword_extension)
(keyword_version)
(keyword_out)
(keyword_inout)
(keyword_variadic)
(keyword_ordinality)
(keyword_session)
(keyword_isolation)
(keyword_level)
(keyword_serializable)
(keyword_repeatable)
(keyword_read)
(keyword_write)
(keyword_committed)
(keyword_uncommitted)
(keyword_deferrable)
(keyword_names)
(keyword_zone)
(keyword_immediate)
(keyword_deferred)
(keyword_constraints)
(keyword_snapshot)
(keyword_characteristics)
(keyword_off)
(keyword_follows)
(keyword_precedes)
(keyword_each)
(keyword_instead)
(keyword_of)
(keyword_initially)
(keyword_old)
(keyword_new)
(keyword_referencing)
(keyword_statement)
(keyword_execute)
(keyword_procedure)
(keyword_copy)
(keyword_delimiter)
(keyword_encoding)
(keyword_escape)
(keyword_force_not_null)
(keyword_force_null)
(keyword_force_quote)
(keyword_freeze)
(keyword_header)
(keyword_match)
(keyword_program)
(keyword_quote)
(keyword_stdin)
(keyword_extended)
(keyword_main)
(keyword_plain)
(keyword_storage)
(keyword_compression)
(keyword_duplicate)
(keyword_while)
] @keyword
[
(keyword_restrict)
(keyword_unbounded)
(keyword_unique)
(keyword_cascade)
(keyword_delayed)
(keyword_high_priority)
(keyword_low_priority)
(keyword_ignore)
(keyword_nothing)
(keyword_check)
(keyword_option)
(keyword_local)
(keyword_cascaded)
(keyword_wait)
(keyword_nowait)
(keyword_metadata)
(keyword_incremental)
(keyword_bin_pack)
(keyword_noscan)
(keyword_stats)
(keyword_statistics)
(keyword_maxvalue)
(keyword_minvalue)
] @type.qualifier
[
(keyword_int)
(keyword_null)
(keyword_boolean)
(keyword_binary)
(keyword_varbinary)
(keyword_image)
(keyword_bit)
(keyword_inet)
(keyword_character)
(keyword_smallserial)
(keyword_serial)
(keyword_bigserial)
(keyword_smallint)
(keyword_mediumint)
(keyword_bigint)
(keyword_tinyint)
(keyword_decimal)
(keyword_float)
(keyword_double)
(keyword_numeric)
(keyword_real)
(double)
(keyword_money)
(keyword_smallmoney)
(keyword_char)
(keyword_nchar)
(keyword_varchar)
(keyword_nvarchar)
(keyword_varying)
(keyword_text)
(keyword_string)
(keyword_uuid)
(keyword_json)
(keyword_jsonb)
(keyword_xml)
(keyword_bytea)
(keyword_enum)
(keyword_date)
(keyword_datetime)
(keyword_time)
(keyword_datetime2)
(keyword_datetimeoffset)
(keyword_smalldatetime)
(keyword_timestamp)
(keyword_timestamptz)
(keyword_geometry)
(keyword_geography)
(keyword_box2d)
(keyword_box3d)
(keyword_interval)
] @type.builtin
[
(keyword_in)
(keyword_and)
(keyword_or)
(keyword_not)
(keyword_by)
(keyword_on)
(keyword_do)
(keyword_union)
(keyword_except)
(keyword_intersect)
] @keyword.operator
[
"+"
"-"
"*"
"/"
"%"
"^"
":="
"="
"<"
"<="
"!="
">="
">"
"<>"
(op_other)
(op_unary_other)
] @operator
[
"("
")"
] @punctuation.bracket
[
";"
","
"."
] @punctuation.delimiter
@@ -1,36 +0,0 @@
; Vendored from toml (MIT License)
; Source: https://github.com/tree-sitter/tree-sitter-toml
; Properties
;-----------
(bare_key) @property
(quoted_key) @string
; Literals
;---------
(boolean) @constant.builtin
(comment) @comment
(string) @string
(integer) @number
(float) @number
(offset_date_time) @string.special
(local_date_time) @string.special
(local_date) @string.special
(local_time) @string.special
; Punctuation
;------------
"." @punctuation.delimiter
"," @punctuation.delimiter
"=" @operator
"[" @punctuation.bracket
"]" @punctuation.bracket
"[[" @punctuation.bracket
"]]" @punctuation.bracket
"{" @punctuation.bracket
"}" @punctuation.bracket
@@ -1,38 +0,0 @@
; Vendored from typescript (MIT License)
; Source: https://github.com/tree-sitter/tree-sitter-typescript
; Types
(type_identifier) @type
(predefined_type) @type.builtin
((identifier) @type
(#match? @type "^[A-Z]"))
(type_arguments
"<" @punctuation.bracket
">" @punctuation.bracket)
; Variables
(required_parameter (identifier) @variable.parameter)
(optional_parameter (identifier) @variable.parameter)
; Keywords
[ "abstract"
"declare"
"enum"
"export"
"implements"
"interface"
"keyof"
"namespace"
"private"
"protected"
"public"
"type"
"readonly"
"override"
"satisfies"
] @keyword
@@ -1,82 +0,0 @@
; Vendored from yaml (MIT License)
; Source: https://github.com/tree-sitter-grammars/tree-sitter-yaml
(boolean_scalar) @boolean
(null_scalar) @constant.builtin
[
(double_quote_scalar)
(single_quote_scalar)
(block_scalar)
(string_scalar)
] @string
[
(integer_scalar)
(float_scalar)
] @number
(comment) @comment
[
(anchor_name)
(alias_name)
] @label
(tag) @type
[
(yaml_directive)
(tag_directive)
(reserved_directive)
] @attribute
(block_mapping_pair
key: (flow_node
[
(double_quote_scalar)
(single_quote_scalar)
] @property))
(block_mapping_pair
key: (flow_node
(plain_scalar
(string_scalar) @property)))
(flow_mapping
(_
key: (flow_node
[
(double_quote_scalar)
(single_quote_scalar)
] @property)))
(flow_mapping
(_
key: (flow_node
(plain_scalar
(string_scalar) @property))))
[
","
"-"
":"
">"
"?"
"|"
] @punctuation.delimiter
[
"["
"]"
"{"
"}"
] @punctuation.bracket
[
"*"
"&"
"---"
"..."
] @punctuation.special
-858
View File
@@ -1,858 +0,0 @@
#include "llmclient.hpp"
#include "generation.hpp"
#include "message.hpp"
#include "messagemodel.hpp"
#include "segment.hpp"
#include "session.hpp"
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSet>
#include <QUrl>
namespace ZShell::llm {
QString LlmClient::completionsPath(
const QString& endpoint, const QString& subpath) {
QString base = endpoint.trimmed();
while (base.endsWith('/'))
base.chop(1);
if (!base.endsWith("/v1"))
base += "/v1";
return base + subpath;
}
QString LlmClient::serverErrorMessage(
const QByteArray& body, const QString& fallback) {
const QJsonDocument doc = QJsonDocument::fromJson(body);
if (doc.isObject()) {
const QJsonObject obj = doc.object();
if (obj.contains("error")) {
const QJsonValue errorValue = obj["error"];
if (errorValue.isObject()) {
const QString message =
errorValue.toObject()["message"].toString();
if (!message.isEmpty())
return message;
} else if (!errorValue.toString().isEmpty()) {
return errorValue.toString();
}
}
}
return fallback.isEmpty()
? QStringLiteral("Request to LLM server failed")
: fallback;
}
void LlmClient::setBusy(bool value) {
if (m_busy == value)
return;
m_busy = value;
Q_EMIT busyChanged();
}
void LlmClient::setStreamingChatId(const QString& id) {
if (m_streamingChatId == id)
return;
m_streamingChatId = id;
Q_EMIT streamingChatIdChanged();
}
LlmClient::LlmClient(QObject* parent) : QObject(parent) {
m_tools = new ToolRegistry(this);
connect(
m_tools,
&ToolRegistry::enabledChanged,
this,
&LlmClient::toolsEnabledChanged);
probeContextSize();
}
LlmClient::~LlmClient() {
if (m_reply)
m_reply->abort();
m_tools->cancelAll();
endStream();
}
void LlmClient::setEndpoint(const QString& value) {
if (m_endpoint == value)
return;
m_endpoint = value;
Q_EMIT endpointChanged();
probeContextSize();
if (m_model.isEmpty())
refreshModels();
}
void LlmClient::setModel(const QString& value) {
if (m_model == value)
return;
m_model = value;
Q_EMIT modelChanged();
if (m_model.isEmpty())
refreshModels();
}
void LlmClient::setTemperature(double value) {
m_temperature = value;
}
void LlmClient::startGeneration(
ChatSession* session, ChatGeneration* target) {
if (m_busy || !session || !target)
return;
m_active = session;
m_streaming = target;
m_streaming->setStreaming(true);
setBusy(true);
setStreamingChatId(session->id());
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions"));
if (!url.isValid() || url.host().isEmpty()) {
fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint));
return;
}
const auto* model = session->messagesModel();
const int targetRow =
model->rowOf(qobject_cast<ChatMessage*>(target->parent()));
if (targetRow < 0) {
fail(QStringLiteral("Internal error: generation target is not in the "
"session"));
return;
}
m_transcript = QJsonArray();
m_callBuilders.clear();
m_callResults.clear();
m_finishReason.clear();
m_round = 0;
m_contentMark = 0;
m_reasoningMark = 0;
sendRound();
}
void LlmClient::sendRound() {
if (!m_active || !m_streaming)
return;
m_finishReason.clear();
m_callBuilders.clear();
m_callResults.clear();
m_roundDone = false;
m_toolPhase = false;
m_pendingCalls = 0;
m_buffer.clear();
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions"));
if (!url.isValid() || url.host().isEmpty()) {
fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint));
return;
}
const auto* model = m_active->messagesModel();
const int targetRow =
model->rowOf(qobject_cast<ChatMessage*>(m_streaming->parent()));
if (targetRow < 0) {
fail(QStringLiteral("Internal error: generation target is not in the "
"session"));
return;
}
// Context: every message older than the target, oldest first, plus
// the tool exchanges of the current turn so far.
QJsonArray messages = buildContextMessages(m_active, targetRow);
for (const QJsonValue& value : m_transcript)
messages.append(value);
QJsonObject body;
QJsonObject streamOptions;
streamOptions[QStringLiteral("include_usage")] = true;
body[QStringLiteral("stream_options")] = streamOptions;
body[QStringLiteral("messages")] = messages;
body[QStringLiteral("stream")] = true;
body[QStringLiteral("temperature")] = m_temperature;
if (!m_model.isEmpty())
body[QStringLiteral("model")] = m_model;
const QJsonArray toolSpecs = m_tools->specifications();
if (!toolSpecs.isEmpty())
body[QStringLiteral("tools")] = toolSpecs;
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Accept", "text/event-stream");
m_reply = m_manager.post(request, QJsonDocument(body).toJson());
connect(m_reply, &QNetworkReply::readyRead, this, [this]() {
if (m_reply)
m_buffer.append(m_reply->readAll());
drainBuffer();
});
connect(m_reply, &QNetworkReply::finished, this, [this]() {
QNetworkReply* reply = m_reply;
if (!reply)
return;
m_reply = nullptr;
const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
// Data not already consumed by readyRead is only reachable here.
const QByteArray responseBody = reply->readAll();
m_buffer.append(responseBody);
reply->deleteLater();
drainBuffer();
if (!m_streaming)
return;
if (error == QNetworkReply::NoError)
roundFinished();
else if (error == QNetworkReply::OperationCanceledError)
finishTurn();
else
fail(serverErrorMessage(responseBody, errorString));
});
}
QJsonArray LlmClient::buildContextMessages(
ChatSession* session, int stopBeforeRow) const {
const auto* model = session->messagesModel();
QJsonArray messages;
for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) {
const auto* message = model->at(row);
const auto* generation = message->activeGeneration();
if (!generation)
continue;
if (message->role() == ChatMessage::Role::User) {
QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user");
user[QStringLiteral("content")] = generation->content();
messages.append(user);
continue;
}
// Assistant message: replay its tool calls (and their results)
// so the model keeps the full history of the turn.
QList<const LlmSegment*> toolSegments;
for (const auto* segment : generation->segments())
if (segment->type() == LlmSegment::Type::ToolCall)
toolSegments.append(segment);
QJsonObject assistant;
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
if (toolSegments.isEmpty())
assistant[QStringLiteral("content")] = generation->content();
else if (!generation->content().isEmpty())
assistant[QStringLiteral("content")] = generation->content();
if (!generation->reasoning().isEmpty())
assistant[QStringLiteral("reasoning_content")] =
generation->reasoning();
if (toolSegments.isEmpty()) {
messages.append(assistant);
continue;
}
QJsonArray calls;
for (const auto* segment : toolSegments) {
QJsonObject function;
function[QStringLiteral("name")] = segment->name();
function[QStringLiteral("arguments")] = segment->arguments();
QJsonObject call;
call[QStringLiteral("id")] = segment->toolCallId();
call[QStringLiteral("type")] = QStringLiteral("function");
call[QStringLiteral("function")] = function;
calls.append(call);
}
assistant[QStringLiteral("tool_calls")] = calls;
messages.append(assistant);
for (const auto* segment : toolSegments) {
QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] =
segment->toolCallId();
toolMessage[QStringLiteral("content")] = segment->result();
messages.append(toolMessage);
}
}
return messages;
}
void LlmClient::applyToolCallDelta(const QJsonObject& call) {
if (!m_streaming)
return;
const int index = call[QStringLiteral("index")].toInt(-1);
if (index < 0)
return;
while (m_callBuilders.size() <= index)
m_callBuilders.append(ToolCallBuilder{});
ToolCallBuilder& builder = m_callBuilders[index];
builder.seen = true;
const QString id = call[QStringLiteral("id")].toString();
if (!id.isEmpty())
builder.id = id;
const QJsonObject function = call[QStringLiteral("function")].toObject();
const QString name = function[QStringLiteral("name")].toString();
if (!name.isEmpty())
builder.name = name;
const QString arguments =
function[QStringLiteral("arguments")].toString();
if (!arguments.isEmpty())
builder.arguments += arguments;
if (!builder.segment) {
// A new call: close the in-flight text segments and open a
// running tool-call segment so the UI can track it live.
m_streaming->closeOpenSegments();
builder.segment =
m_streaming->beginToolCall(builder.name, builder.id);
}
builder.segment->setName(builder.name);
builder.segment->setToolCallId(builder.id);
if (!arguments.isEmpty())
builder.segment->appendArguments(arguments);
}
void LlmClient::roundFinished() {
// [DONE] and the reply's finished signal both funnel here; only the
// first may act.
if (m_roundDone || !m_streaming)
return;
m_roundDone = true;
bool hasCalls = false;
for (const auto& builder : m_callBuilders)
if (builder.seen)
hasCalls = true;
if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) {
finishTurn();
return;
}
if (m_round >= kMaxToolRounds) {
qWarning() << "LlmClient: tool round limit reached, ending turn";
finishTurn();
return;
}
m_streaming->closeOpenSegments();
// Record the assistant's tool-call message in the transcript so the
// next round (and the model) can see it.
const QString content = m_streaming->content();
const QString reasoning = m_streaming->reasoning();
QJsonObject assistant;
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
if (content.size() > m_contentMark) {
assistant[QStringLiteral("content")] = content.mid(m_contentMark);
m_contentMark = content.size();
}
if (reasoning.size() > m_reasoningMark) {
assistant[QStringLiteral("reasoning_content")] =
reasoning.mid(m_reasoningMark);
m_reasoningMark = reasoning.size();
}
QJsonArray calls;
for (const auto& builder : m_callBuilders) {
if (!builder.seen)
continue;
QJsonObject function;
function[QStringLiteral("name")] = builder.name;
function[QStringLiteral("arguments")] = builder.arguments;
QJsonObject call;
call[QStringLiteral("id")] = builder.id;
call[QStringLiteral("type")] = QStringLiteral("function");
call[QStringLiteral("function")] = function;
calls.append(call);
}
assistant[QStringLiteral("tool_calls")] = calls;
m_transcript.append(assistant);
m_round++;
executeAllCalls();
}
void LlmClient::executeAllCalls() {
m_toolPhase = true;
m_pendingCalls = 0;
m_callResults = QList<ToolCallResult>(m_callBuilders.size());
for (int i = 0; i < m_callBuilders.size(); ++i) {
const auto& call = m_callBuilders.at(i);
if (!call.seen)
continue;
LlmTool* tool = m_tools->tool(call.name);
QJsonObject args;
QString errorText;
if (!tool) {
errorText = QStringLiteral("Error: unknown tool '%1'")
.arg(call.name);
} else if (!call.arguments.isEmpty()) {
const QJsonDocument doc =
QJsonDocument::fromJson(call.arguments.toUtf8());
if (!doc.isObject()) {
errorText =
QStringLiteral("Error: tool arguments are not valid "
"JSON: %1")
.arg(call.arguments);
} else {
args = doc.object();
}
}
if (!errorText.isEmpty()) {
m_callResults[i] = { errorText, false };
if (LlmSegment* segment = call.segment)
segment->finishTool(errorText, false);
continue;
}
++m_pendingCalls;
tool->execute(
args,
[this, i, call](const QJsonObject& result) {
if (!m_streaming)
return;
const bool success =
result.contains(QStringLiteral("output"));
const QString content = success
? result[QStringLiteral("output")].toString()
: QStringLiteral("Error: ") +
result[QStringLiteral("error")].toString();
m_callResults[i] = { content, success };
if (LlmSegment* segment = call.segment)
segment->finishTool(content, success);
if (--m_pendingCalls == 0)
flushCallResults();
});
}
if (m_pendingCalls == 0)
flushCallResults();
}
void LlmClient::flushCallResults() {
if (!m_toolPhase)
return;
m_toolPhase = false;
if (!m_streaming)
return;
for (int i = 0; i < m_callBuilders.size(); ++i) {
const auto& call = m_callBuilders.at(i);
if (!call.seen)
continue;
QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] = call.id;
toolMessage[QStringLiteral("content")] =
m_callResults.at(i).content;
m_transcript.append(toolMessage);
}
sendRound();
}
void LlmClient::stop() {
if (!m_busy)
return;
if (m_toolPhase) {
m_tools->cancelAll();
if (m_streaming) {
for (auto* segment : m_streaming->segments()) {
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->running())
segment->finishTool(
QStringLiteral("Cancelled"), false);
}
}
finishTurn();
return;
}
if (m_reply)
m_reply->abort();
}
void LlmClient::endStream() {
if (!m_streaming)
return;
auto* generation = m_streaming;
auto* session = m_active;
m_streaming = nullptr;
m_active = nullptr;
generation->setStreaming(false);
if (generation->content().isEmpty() &&
generation->reasoning().isEmpty() &&
generation->toolCallCount() == 0) {
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
if (message->generationCount() <= 1) {
if (session)
session->removeMessage(message);
} else {
message->removeGeneration(generation);
}
}
}
setBusy(false);
setStreamingChatId(QString());
}
void LlmClient::finishTurn() {
if (!m_streaming)
return;
ChatSession* session = m_active;
endStream();
if (session && m_pendingClear == session) {
session->clearMessages();
m_pendingClear.clear();
}
if (session)
session->persist();
}
void LlmClient::clearOnFinish(ChatSession* session) {
m_pendingClear = session;
}
void LlmClient::sessionRemoved(ChatSession* session) {
if (m_pendingClear == session)
m_pendingClear.clear();
if (m_active == session) {
stop();
endStream();
}
}
void LlmClient::fail(const QString& message) {
qWarning() << "LlmClient:" << message;
finishTurn();
Q_EMIT errorOccurred(message);
}
void LlmClient::drainBuffer() {
while (true) {
const qsizetype newline = m_buffer.indexOf('\n');
if (newline < 0)
break;
const QByteArray line = m_buffer.left(newline).trimmed();
m_buffer.remove(0, newline + 1);
handleLine(line);
}
}
void LlmClient::handleLine(const QByteArray& line) {
if (!m_streaming || line.isEmpty() || !line.startsWith("data:"))
return;
const QByteArray data = line.mid(5).trimmed();
if (data == "[DONE]") {
roundFinished();
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject())
return;
const QJsonObject obj = doc.object();
updateTokenUsage(obj);
if (obj.contains("error")) {
const QJsonObject error = obj["error"].toObject();
const QString message = error["message"].toString();
fail(message.isEmpty()
? QStringLiteral("LLM server returned an error")
: message);
return;
}
for (const QJsonValue& choiceValue : obj["choices"].toArray()) {
if (!m_streaming)
continue;
const QJsonObject choice = choiceValue.toObject();
const QJsonObject delta = choice["delta"].toObject();
const QString finishReason =
choice[QStringLiteral("finish_reason")].toString();
if (!finishReason.isEmpty())
m_finishReason = finishReason;
m_streaming->appendContent(delta["content"].toString());
QString reasoning =
delta["reasoning_content"].toString();
if (reasoning.isEmpty())
reasoning = delta["reasoning"].toString();
m_streaming->appendReasoning(reasoning);
for (const QJsonValue& callValue :
delta["tool_calls"].toArray()) {
if (!m_streaming)
break;
applyToolCallDelta(callValue.toObject());
}
}
}
void LlmClient::refreshModels() {
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
if (!url.isValid() || url.host().isEmpty())
return;
auto* reply = m_manager.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QByteArray data = reply->readAll();
reply->deleteLater();
if (error != QNetworkReply::NoError) {
qWarning() << "LlmClient: failed to fetch models:" << error;
return;
}
const QJsonArray dataArr =
QJsonDocument::fromJson(data).object()["data"].toArray();
QStringList models;
QSet<QString> seen;
for (const QJsonValue& value : dataArr) {
const QString id = value.toObject()["id"].toString();
if (!id.isEmpty() && !seen.contains(id)) {
seen.insert(id);
models.append(id);
}
}
if (models.isEmpty())
return;
m_availableModels = models;
Q_EMIT availableModelsChanged();
if (m_model.isEmpty()) {
m_model = models.first();
Q_EMIT modelChanged();
}
});
}
void LlmClient::setContextSize(int size) {
if (size <= 0 || m_contextSize == size)
return;
m_contextSize = size;
Q_EMIT contextSizeChanged();
}
void LlmClient::probeContextSize() {
// llama.cpp-specific endpoint; other servers fall back to 4096.
QString base = m_endpoint.trimmed();
while (base.endsWith('/'))
base.chop(1);
const QUrl url = QUrl::fromUserInput(base + "/props");
if (!url.isValid() || url.host().isEmpty())
return;
auto* reply = m_manager.get(QNetworkRequest(url));
connect(
reply,
&QNetworkReply::finished,
this,
[this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QByteArray data = reply->readAll();
reply->deleteLater();
int size = 0;
if (error == QNetworkReply::NoError) {
const QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isArray()) {
for (const auto& value : doc.array()) {
const QJsonObject slot = value.toObject();
if (slot.contains("n_ctx")) {
size = slot["n_ctx"].toInt(0);
if (size > 0)
break;
}
}
} else if (doc.isObject()) {
const QJsonObject obj = doc.object();
size = obj["n_ctx"].toInt(0);
if (size <= 0)
size = obj["default_generation_settings"].toObject()
["n_ctx"].toInt(0);
}
}
setContextSize(size > 0 ? size : 4096);
});
}
void LlmClient::updateTokenUsage(const QJsonObject& data) {
if (!m_active || m_contextSize <= 0)
return;
const QJsonObject usage = data["usage"].toObject();
if (usage.isEmpty())
return;
const double used =
usage.value("prompt_tokens").toDouble() +
usage.value("completion_tokens").toDouble();
if (used > 0)
m_active->setLastTokenCount(static_cast<int>(used));
}
void LlmClient::shortRequest(
const QString& tag,
const QString& systemPrompt,
const QString& userText,
std::function<void(QString result)> onResult) {
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions"));
if (!url.isValid() || url.host().isEmpty())
return;
qInfo() << "LlmClient:" << tag << "request POST" << url.toString()
<< "model=" << m_model;
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Accept", "application/json");
QJsonArray messages;
QJsonObject system;
system[QStringLiteral("role")] = QStringLiteral("system");
system[QStringLiteral("content")] = systemPrompt;
messages.append(system);
QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user");
user[QStringLiteral("content")] = userText.simplified().mid(0, 512);
messages.append(user);
QJsonObject body;
if (!m_model.isEmpty())
body[QStringLiteral("model")] = m_model;
body[QStringLiteral("stream")] = false;
body[QStringLiteral("temperature")] = 0.3;
body[QStringLiteral("max_tokens")] = 128;
QJsonObject templateKwargs;
templateKwargs[QStringLiteral("enable_thinking")] = false;
body[QStringLiteral("chat_template_kwargs")] = templateKwargs;
body[QStringLiteral("messages")] = messages;
auto* reply = m_manager.post(request, QJsonDocument(body).toJson());
connect(
reply,
&QNetworkReply::finished,
this,
[this, reply, tag, onResult = std::move(onResult)]() {
const QByteArray data = reply->readAll();
reply->deleteLater();
qInfo() << "LlmClient:" << tag << "request finished"
<< "error=" << reply->error() << reply->errorString()
<< "http=" << reply->attribute(
QNetworkRequest::HttpStatusCodeAttribute)
.toInt()
<< "response="
<< QString::fromUtf8(data.left(400)).simplified();
if (reply->error() != QNetworkReply::NoError)
return;
const QJsonDocument doc = QJsonDocument::fromJson(data);
const QJsonArray choices =
doc.object()[QStringLiteral("choices")].toArray();
if (choices.isEmpty())
return;
const QString result =
choices.at(0).toObject()[QStringLiteral("message")].toObject()
[QStringLiteral("content")].toString()
.trimmed();
qInfo() << "LlmClient:" << tag << "raw result" << result;
onResult(result);
});
}
void LlmClient::requestTitle(ChatSession* session, const QString& userText) {
shortRequest(
QStringLiteral("title"),
QStringLiteral(
"Write a short, concise title for a chat conversation starting "
"with the user's message below. At most six words, no quotation "
"marks, no trailing punctuation. Reply with the title only."),
userText,
[this, session = QPointer<ChatSession>(session)](QString title) {
if (!session) {
qWarning() << "LlmClient: title request: session gone";
return;
}
const auto isQuote = [](QChar c) {
return c == QLatin1Char('"') || c == QLatin1Char('\'') ||
c == QChar(u'\u201C') || c == QChar(u'\u201D') ||
c == QChar(u'\u2018') || c == QChar(u'\u2019');
};
while (title.size() >= 2 && isQuote(title.at(0)) &&
isQuote(title.at(title.size() - 1)))
title = title.mid(1, title.size() - 2).simplified();
while (!title.isEmpty() &&
(title.endsWith(QLatin1Char('.')) ||
title.endsWith(QLatin1Char('!')) ||
title.endsWith(QLatin1Char('?'))))
title.chop(1);
if (title.size() < 2) {
qWarning() << "LlmClient: title rejected (too short)"
<< title;
return;
}
if (title.size() > 48)
title = title.left(47) + QStringLiteral("");
qInfo() << "LlmClient: suggesting title" << title;
Q_EMIT titleSuggested(session, title);
});
}
void LlmClient::requestIcon(ChatSession* session, const QString& userText) {
const QStringList icons = {
"chat", "lightbulb", "code",
"description", "article", "school",
"work", "build", "science",
"palette", "music_note", "sports_esports",
"takeout_dining", "flight", "photo_camera",
"psychology_alt", "favorite", "savings",
"gamepad", "auto_awesome",
};
const QString prompt =
QStringLiteral(
"Pick the single icon name from this list that best matches the "
"topic of the user's message below: %1. Reply with only the icon "
"name, exactly as written in the list, and nothing else.")
.arg(icons.join(QStringLiteral(", ")));
shortRequest(
QStringLiteral("icon"),
prompt,
userText,
[this, session = QPointer<ChatSession>(session), icons](
QString name) {
if (!session) {
qWarning() << "LlmClient: icon request: session gone";
return;
}
name = name.simplified().toLower();
const auto isQuote = [](QChar c) {
return c == QLatin1Char('"') || c == QLatin1Char('\'');
};
while (name.size() >= 2 && isQuote(name.at(0)) &&
isQuote(name.at(name.size() - 1)))
name = name.mid(1, name.size() - 2).simplified();
name.replace(QLatin1Char(' '), QLatin1Char('_'));
if (!icons.contains(name)) {
qWarning() << "LlmClient: icon not in list, using default"
<< name;
name = QStringLiteral("chat");
}
qInfo() << "LlmClient: suggesting icon" << name;
Q_EMIT iconSuggested(session, name);
});
}
} // namespace ZShell::llm
-160
View File
@@ -1,160 +0,0 @@
#pragma once
#include "segment.hpp"
#include "tool.hpp"
#include <QByteArray>
#include <QJsonArray>
#include <QNetworkAccessManager>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
#include <functional>
class QJsonObject;
class QNetworkReply;
namespace ZShell::llm {
class ChatGeneration;
class ChatSession;
class LlmSegment;
class LlmTool;
// The only component that talks to the LLM server: owns the network
// manager, the in-flight streaming state, the SSE parsing and the
// tool-calling loop.
class LlmClient : public QObject {
Q_OBJECT
public:
explicit LlmClient(QObject* parent = nullptr);
~LlmClient() override;
[[nodiscard]] QString endpoint() const { return m_endpoint; }
[[nodiscard]] QString model() const { return m_model; }
[[nodiscard]] double temperature() const { return m_temperature; }
[[nodiscard]] QStringList availableModels() const {
return m_availableModels;
}
[[nodiscard]] int contextSize() const { return m_contextSize; }
[[nodiscard]] ToolRegistry* tools() const { return m_tools; }
void setEndpoint(const QString& value);
void setModel(const QString& value);
void setTemperature(double value);
void setContextSize(int size);
void probeContextSize();
[[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
void setToolsEnabled(bool value) { m_tools->setEnabled(value); }
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
[[nodiscard]] ChatSession* streamingSession() const { return m_active; }
// Streams a new assistant reply into `target` (the active generation
// of a message of `session`). The context sent to the model is every
// message of the session that is older than the target message.
// Tool calls made by the model are executed and fed back
// transparently until the model produces its final answer.
void startGeneration(ChatSession* session, ChatGeneration* target);
void stop();
void endStream();
// Clears the session's conversation once the current stream ends.
void clearOnFinish(ChatSession* session);
// A session is about to be destroyed; drop any state pointing at it.
void sessionRemoved(ChatSession* session);
void refreshModels();
void requestTitle(ChatSession* session, const QString& userText);
void requestIcon(ChatSession* session, const QString& userText);
Q_SIGNALS:
void busyChanged();
void endpointChanged();
void modelChanged();
void availableModelsChanged();
void contextSizeChanged();
void toolsEnabledChanged();
void streamingChatIdChanged();
void errorOccurred(const QString& message);
void titleSuggested(ZShell::llm::ChatSession* session, const QString& title);
void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon);
private:
struct ToolCallBuilder {
QString id;
QString name;
QString arguments;
QPointer<LlmSegment> segment;
bool seen = false;
};
// Sends one streaming round: context + transcript so far.
void sendRound();
// The session context for a round, oldest first, ending just before
// `stopBeforeRow` (the message of the generation being streamed).
QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow) const;
void applyToolCallDelta(const QJsonObject& call);
// One round's stream ended; either ends the turn or executes the
// requested tool calls and sends the next round. Runs at most once
// per round ([DONE] and the reply's finished signal both reach it).
void roundFinished();
// Dispatches every call of the round; tools run concurrently.
void executeAllCalls();
// All results in: appends the tool messages (in call order) and
// sends the next round.
void flushCallResults();
// Ends the current turn gracefully and persists the session.
void finishTurn();
void fail(const QString& message);
void handleLine(const QByteArray& line);
void drainBuffer();
void updateTokenUsage(const QJsonObject& data);
void setBusy(bool value);
void setStreamingChatId(const QString& id);
void shortRequest(
const QString& tag,
const QString& systemPrompt,
const QString& userText,
std::function<void(QString result)> onResult);
static QString completionsPath(const QString& endpoint, const QString& subpath);
static QString serverErrorMessage(
const QByteArray& body, const QString& fallback);
QNetworkAccessManager m_manager;
ToolRegistry* m_tools = nullptr;
QNetworkReply* m_reply = nullptr;
QByteArray m_buffer;
ChatSession* m_active = nullptr;
ChatGeneration* m_streaming = nullptr;
QPointer<ChatSession> m_pendingClear;
bool m_busy = false;
QString m_streamingChatId;
QString m_endpoint;
QString m_model;
QStringList m_availableModels;
double m_temperature = 0.7;
int m_contextSize = 0;
// State of the multi-round tool loop of the current turn.
QJsonArray m_transcript;
QList<ToolCallBuilder> m_callBuilders;
struct ToolCallResult {
QString content;
bool success = false;
};
QList<ToolCallResult> m_callResults;
QString m_finishReason;
int m_round = 0;
qsizetype m_contentMark = 0;
qsizetype m_reasoningMark = 0;
bool m_roundDone = false;
bool m_toolPhase = false;
int m_pendingCalls = 0;
static constexpr int kMaxToolRounds = 12;
};
} // namespace ZShell::llm
-28
View File
@@ -1,28 +0,0 @@
#pragma once
#include <QObject>
#include <QtQml>
namespace ZShell::llm {
// Marker class exposing the LlmMarkdown block type enum to QML
// (LlmMarkdown.Type.*). Blocks themselves are value maps produced by
// MarkdownParser and stored on LlmSegment.
class LlmMarkdown : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Blocks are produced by LlmSegment")
public:
enum class Type : int {
Text = 0, // Paragraph, list, quote, table; "text" is markdown source
Heading, // "level" + "text" (markdown source of the content)
Code, // "language" + "code"
Math // "latex" (display math, without the $$ delimiters)
};
Q_ENUM(Type)
explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {}
};
} // namespace ZShell::llm
-144
View File
@@ -1,144 +0,0 @@
#include "markdownparser.hpp"
#include "markdownblock.hpp"
#include <cmark-gfm-extension_api.h>
#include <cmark-gfm.h>
#include <QRegularExpression>
#include <QStringList>
#include <QVariantMap>
namespace ZShell::llm {
QVariantList MarkdownParser::parse(const QString& source) {
QVariantList blocks;
if (source.trimmed().isEmpty())
return blocks;
const QStringList lines = source.split('\n');
// cmark-gfm line/column numbers are 1-based and inclusive.
auto sliceSource = [&](int startLine, int endLine) -> QString {
if (startLine < 1 || endLine < startLine)
return QString();
const int from = startLine;
const int to = qMin(endLine, static_cast<int>(lines.size()));
return lines.mid(from - 1, to - from + 1).join('\n').trimmed();
};
const QByteArray utf8 = source.toUtf8();
cmark_node* doc = cmark_parse_document(
utf8.constData(), static_cast<size_t>(utf8.size()),
CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS);
if (!doc)
return blocks;
// cmark-gfm has no math extension, so $$...$$ parses as an ordinary
// paragraph. Re-detect display math (a $$...$$ pair) here and split it
// out as its own block. Single-$ inline math is intentionally left
// untouched (rendered raw) for now.
const QRegularExpression mathRe(
QStringLiteral("\\$\\$(.+?)\\$\\$"),
QRegularExpression::DotMatchesEverythingOption);
auto makeBlock = [](LlmMarkdown::Type type) {
QVariantMap block;
block.insert("type", static_cast<int>(type));
return block;
};
auto appendText = [&](const QString& text) {
if (text.trimmed().isEmpty())
return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Text);
block.insert("text", text);
blocks.append(block);
};
auto appendMath = [&](const QString& latex) {
if (latex.trimmed().isEmpty())
return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Math);
block.insert("latex", latex);
blocks.append(block);
};
auto appendCode = [&](const QString& language, const QString& code) {
QVariantMap block = makeBlock(LlmMarkdown::Type::Code);
block.insert("language", language);
block.insert("code", code);
blocks.append(block);
};
auto appendHeading = [&](int level, const QString& text) {
if (text.trimmed().isEmpty())
return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Heading);
block.insert("level", level);
block.insert("text", text);
blocks.append(block);
};
for (cmark_node* node = cmark_node_first_child(doc); node;
node = cmark_node_next(node)) {
const cmark_node_type type = cmark_node_get_type(node);
const int startLine = cmark_node_get_start_line(node);
const int endLine = cmark_node_get_end_line(node);
if (type == CMARK_NODE_CODE_BLOCK) {
const char* literal = cmark_node_get_literal(node);
QString code = literal ? QString::fromUtf8(literal) : QString();
// Fenced block literals carry a trailing newline; drop one.
if (code.endsWith('\n'))
code.chop(1);
QString language;
if (const char* info = cmark_node_get_fence_info(node); info)
language = QString::fromUtf8(info).section(' ', 0, 0).trimmed().toLower();
appendCode(language, code);
continue;
}
if (type == CMARK_NODE_HEADING) {
// Inline content only (no leading #), so QML can style by
// level.
const char* content = cmark_node_get_string_content(node);
appendHeading(
cmark_node_get_heading_level(node),
content ? QString::fromUtf8(content).trimmed() : QString());
continue;
}
if (type == CMARK_NODE_PARAGRAPH) {
const QString text = sliceSource(startLine, endLine);
int cursor = 0;
bool anyMath = false;
for (auto it = mathRe.globalMatch(text, cursor); it.hasNext();
it = mathRe.globalMatch(text, cursor)) {
const QRegularExpressionMatch m = it.next();
anyMath = true;
appendText(
text.mid(cursor, static_cast<int>(m.capturedStart() - cursor)));
appendMath(m.captured(1).trimmed());
cursor = static_cast<int>(m.capturedEnd());
}
if (!anyMath)
appendText(text);
else
appendText(text.mid(cursor));
continue;
}
// Lists, block quotes, tables, horizontal rules, custom blocks:
// hand the raw markdown source to QML (rendered via
// Text.MarkdownText).
appendText(sliceSource(startLine, endLine));
}
cmark_node_free(doc);
return blocks;
}
} // namespace ZShell::llm
-25
View File
@@ -1,25 +0,0 @@
#pragma once
#include <QString>
#include <QVariantList>
namespace ZShell::llm {
// Splits a markdown document into top-level blocks for QML rendering
// (cmark-gfm AST walk). Inline structure is not flattened; Text and
// Heading blocks carry markdown source that QML renders with
// Text.MarkdownText.
//
// Block map keys:
// "type" int (LlmMarkdown::Type)
// "level" int (Heading)
// "language" QString (Code, lowercased, empty when unknown)
// "code" QString (Code)
// "text" QString (Text/Heading, markdown source)
// "latex" QString (Math, without the $$ delimiters)
class MarkdownParser {
public:
[[nodiscard]] static QVariantList parse(const QString& source);
};
} // namespace ZShell::llm
-176
View File
@@ -1,176 +0,0 @@
#include "mathtext.hpp"
#include "latinmodern-fonts.hpp"
#include <jkqtmathtext/jkqtmathtext.h>
#include <QBuffer>
#include <QDir>
#include <QFile>
#include <QFontDatabase>
namespace ZShell::llm {
namespace {
// A few px of breathing room around the equation.
constexpr int kRenderMargin = 2;
constexpr unsigned int kResolutionDpi = 96;
// Registers the embedded Latin Modern faces with the font database
// (once per process) and reports which families became available.
struct LatinModern {
bool roman = false;
bool math = false;
};
const LatinModern& loadLatinModern() {
static const LatinModern fonts = [] {
LatinModern result;
// addApplicationFont(QByteArray) fails in this environment, so
// the embedded bytes are staged to a per-process temp file and
// registered through the (stable) file-based API.
const auto add = [](const unsigned char* data, size_t size,
const QString& fileName, const QString& family) {
const QString path = QDir::tempPath() + QLatin1Char('/') + fileName;
{
QFile f(path);
if (!f.open(QIODevice::WriteOnly) ||
f.write(reinterpret_cast<const char*>(data),
static_cast<qint64>(size)) != static_cast<qint64>(size))
return false;
}
const int key = QFontDatabase::addApplicationFont(path);
if (key < 0)
return false;
return QFontDatabase::applicationFontFamilies(key).contains(family);
};
result.roman =
add(lmfont::lmroman10_regular, sizeof(lmfont::lmroman10_regular),
QStringLiteral("lmroman10-regular.otf"), QStringLiteral("LMRoman10"))
&& add(lmfont::lmroman10_italic, sizeof(lmfont::lmroman10_italic),
QStringLiteral("lmroman10-italic.otf"), QStringLiteral("LMRoman10"))
&& add(lmfont::lmroman10_bold, sizeof(lmfont::lmroman10_bold),
QStringLiteral("lmroman10-bold.otf"), QStringLiteral("LMRoman10"))
&& add(lmfont::lmroman10_bolditalic, sizeof(lmfont::lmroman10_bolditalic),
QStringLiteral("lmroman10-bolditalic.otf"),
QStringLiteral("LMRoman10"));
result.math = add(
lmfont::latinmodern_math, sizeof(lmfont::latinmodern_math),
QStringLiteral("latinmodern-math.otf"), QStringLiteral("Latin Modern Math"));
return result;
}();
return fonts;
}
} // namespace
LlmMathText::LlmMathText(QObject* parent)
: QObject(parent), m_renderer(parent, /* useFontsForGUI */ true) {
// Latin Modern is the default font of modern LaTeX; use the embedded
// faces instead of whatever the system happens to have installed.
const LatinModern& fonts = loadLatinModern();
if (fonts.roman)
m_renderer.setFontRomanAndMath(QStringLiteral("LMRoman10"),
JKQTMathTextFontEncoding::MTFEUnicode);
if (fonts.math) {
// Same pattern as JKQTMathText's useXITS(): the OpenType math
// font supplies the math alphabet and operators from its MATH
// table.
m_renderer.setFontMathRoman(QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer.setFallbackFontSymbols(
QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
}
}
void LlmMathText::setLatex(const QString& value) {
if (m_latex == value)
return;
m_latex = value;
reRender();
}
void LlmMathText::setColor(const QColor& value) {
if (m_color == value)
return;
m_color = value;
reRender();
}
void LlmMathText::setFontPointSize(double value) {
if (qFuzzyCompare(m_fontPointSize, value))
return;
m_fontPointSize = value;
reRender();
}
void LlmMathText::setDevicePixelRatio(qreal value) {
if (qFuzzyCompare(m_devicePixelRatio, value))
return;
m_devicePixelRatio = value;
reRender();
}
void LlmMathText::reRender() {
if (m_latex.trimmed().isEmpty()) {
m_image = QImage();
m_imageUrl = QUrl();
m_width = 0;
m_height = 0;
m_ok = false;
Q_EMIT changed();
return;
}
m_renderer.setFontPointSize(m_fontPointSize);
m_renderer.setFontColor(m_color);
const bool ok = m_renderer.parse(
m_latex,
JKQTMathText::LatexParser,
JKQTMathText::DefaultParseOptions);
if (!ok) {
m_image = QImage();
m_imageUrl = QUrl();
m_width = 0;
m_height = 0;
m_ok = false;
Q_EMIT changed();
return;
}
const QImage image = m_renderer.drawIntoImage(
/* drawBoxes */ false,
QColor(Qt::transparent),
kRenderMargin,
m_devicePixelRatio,
kResolutionDpi);
if (image.isNull()) {
m_image = QImage();
m_imageUrl = QUrl();
m_width = 0;
m_height = 0;
m_ok = false;
Q_EMIT changed();
return;
}
m_image = image;
QByteArray png;
{
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
}
m_imageUrl = QUrl(
QStringLiteral("data:image/png;base64,") + QString::fromLatin1(png.toBase64()));
// drawIntoImage renders at devicePixelRatio; convert back to
// logical pixels.
m_width = image.width() / m_devicePixelRatio;
m_height = image.height() / m_devicePixelRatio;
m_ok = true;
Q_EMIT changed();
}
} // namespace ZShell::llm
-72
View File
@@ -1,72 +0,0 @@
#pragma once
#include <QColor>
#include <QImage>
#include <QObject>
#include <QString>
#include <QUrl>
#include <QtQml>
#include <jkqtmathtext/jkqtmathtext.h>
namespace ZShell::llm {
// QML wrapper around JKQTMathText (JKQtPlotter's LaTeX renderer).
//
// Parses a display-math string and renders it into a transparent
// QImage at the given device pixel ratio. QML displays the image
// (scaling it to the bubble width when needed) and falls back to the
// raw LaTeX when parsing fails.
class LlmMathText : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString latex READ latex WRITE setLatex NOTIFY changed)
Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY changed)
Q_PROPERTY(double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY changed)
Q_PROPERTY(qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio NOTIFY changed)
Q_PROPERTY(QImage image READ image NOTIFY changed)
// data: URL of the rendered equation; usable directly as
// Image.source (a raw QImage is not).
Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed)
// Logical (CSS pixel) size of the rendered equation.
Q_PROPERTY(qreal width READ width NOTIFY changed)
Q_PROPERTY(qreal height READ height NOTIFY changed)
Q_PROPERTY(bool ok READ ok NOTIFY changed)
public:
explicit LlmMathText(QObject* parent = nullptr);
[[nodiscard]] QString latex() const { return m_latex; }
void setLatex(const QString& value);
[[nodiscard]] QColor color() const { return m_color; }
void setColor(const QColor& value);
[[nodiscard]] double fontPointSize() const { return m_fontPointSize; }
void setFontPointSize(double value);
[[nodiscard]] qreal devicePixelRatio() const { return m_devicePixelRatio; }
void setDevicePixelRatio(qreal value);
[[nodiscard]] QImage image() const { return m_image; }
[[nodiscard]] QUrl imageUrl() const { return m_imageUrl; }
[[nodiscard]] qreal width() const { return m_width; }
[[nodiscard]] qreal height() const { return m_height; }
[[nodiscard]] bool ok() const { return m_ok; }
Q_SIGNALS:
void changed();
private:
void reRender();
JKQTMathText m_renderer;
QString m_latex;
QColor m_color;
double m_fontPointSize = 12.0;
qreal m_devicePixelRatio = 1.0;
QImage m_image;
QUrl m_imageUrl;
qreal m_width = 0;
qreal m_height = 0;
bool m_ok = false;
};
} // namespace ZShell::llm
-81
View File
@@ -1,81 +0,0 @@
#include "message.hpp"
#include "messagemodel.hpp"
#include "session.hpp"
namespace ZShell::llm {
namespace {
ChatSession* sessionOf(const ChatMessage* message) {
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent()))
return model->session();
return nullptr;
}
} // namespace
ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent)
: QObject(parent), m_role(role), m_timestamp(timestamp) {}
ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) {
auto* generation = new ChatGeneration(timestamp, this);
m_generations.append(generation);
if (m_active < 0)
m_active = static_cast<int>(m_generations.size() - 1);
Q_EMIT generationsChanged();
return generation;
}
ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) {
auto* generation = addGeneration(timestamp);
setActiveInternal(static_cast<int>(m_generations.size() - 1));
return generation;
}
void ChatMessage::removeGeneration(ChatGeneration* generation) {
const int index = static_cast<int>(m_generations.indexOf(generation));
if (index < 0)
return;
const bool wasActive = index == m_active;
m_generations.removeAt(index);
delete generation;
if (m_generations.isEmpty()) {
m_active = -1;
} else if (m_active >= m_generations.size()) {
m_active = static_cast<int>(m_generations.size() - 1);
}
Q_EMIT generationsChanged();
if (wasActive)
Q_EMIT activeGenerationChanged();
}
void ChatMessage::setActiveInternal(int index) {
if (index < 0 || index >= m_generations.size() || index == m_active)
return;
m_active = index;
Q_EMIT activeGenerationChanged();
}
void ChatMessage::setActiveGeneration(int index) {
setActiveInternal(index);
}
void ChatMessage::edit(const QString& newContent) {
if (auto* generation = activeGeneration())
generation->setContent(newContent);
if (auto* session = sessionOf(this))
session->persist();
}
void ChatMessage::retry() {
if (auto* session = sessionOf(this))
session->retry(this);
}
void ChatMessage::generate() {
if (auto* session = sessionOf(this))
session->continueFrom(this);
}
} // namespace ZShell::llm
-79
View File
@@ -1,79 +0,0 @@
#pragma once
#include "generation.hpp"
#include <QList>
#include <QObject>
#include <QString>
#include <QtQml>
namespace ZShell::llm {
class ChatMessage : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Chat messages are created by the Chat singleton")
Q_PROPERTY(Role role READ role CONSTANT)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(int generationCount READ generationCount NOTIFY generationsChanged)
Q_PROPERTY(
QList<ZShell::llm::ChatGeneration*> generations READ generations
NOTIFY generationsChanged)
Q_PROPERTY(
ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration
NOTIFY activeGenerationChanged)
Q_PROPERTY(int activeGenerationIndex READ activeGenerationIndex NOTIFY activeGenerationChanged)
public:
enum class Role : int {
User = 0,
Assistant
};
Q_ENUM(Role)
explicit ChatMessage(
Role role, qint64 timestamp, QObject* parent = nullptr);
[[nodiscard]] Role role() const { return m_role; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] int generationCount() const {
return static_cast<int>(m_generations.size());
}
[[nodiscard]] QList<ChatGeneration*> generations() const {
return m_generations;
}
[[nodiscard]] ChatGeneration* activeGeneration() const {
return generation(m_active);
}
[[nodiscard]] int activeGenerationIndex() const { return m_active; }
[[nodiscard]] ChatGeneration* generation(int index) const {
if (index < 0 || index >= m_generations.size())
return nullptr;
return m_generations.at(index);
}
Q_INVOKABLE void setActiveGeneration(int index);
Q_INVOKABLE void edit(const QString& newContent);
Q_INVOKABLE void retry();
Q_INVOKABLE void generate();
// Creates an empty generation; callers fill it with segments.
ChatGeneration* addGeneration(qint64 timestamp);
ChatGeneration* appendGeneration(qint64 timestamp);
void removeGeneration(ChatGeneration* generation);
Q_SIGNALS:
void generationsChanged();
void activeGenerationChanged();
private:
void setActiveInternal(int index);
Role m_role;
qint64 m_timestamp;
QList<ChatGeneration*> m_generations;
int m_active = -1;
};
} // namespace ZShell::llm
-108
View File
@@ -1,108 +0,0 @@
#include "messagemodel.hpp"
#include "session.hpp"
namespace ZShell::llm {
ChatMessageModel::ChatMessageModel(ChatSession* session, QObject* parent)
: QAbstractListModel(parent), m_session(session) {}
ChatMessageModel::~ChatMessageModel() = default;
int ChatMessageModel::rowCount(const QModelIndex& parent) const {
if (parent.isValid()) return 0;
return static_cast<int>(m_messages.size());
}
QVariant ChatMessageModel::data(const QModelIndex& index, int role) const {
if (role != Qt::UserRole || !index.isValid() || index.row() < 0 ||
index.row() >= m_messages.size())
return {};
return QVariant::fromValue(m_messages.at(index.row()));
}
QHash<int, QByteArray> ChatMessageModel::roleNames() const {
return {{Qt::UserRole, "modelData"}};
}
ChatMessage* ChatMessageModel::at(int row) const {
if (row < 0 || row >= m_messages.size()) return nullptr;
return m_messages.at(row);
}
int ChatMessageModel::rowOf(const ChatMessage* message) const {
for (int i = 0; i < m_messages.size(); ++i)
if (m_messages.at(i) == message) return i;
return -1;
}
ChatMessage* ChatMessageModel::createMessage(
ChatMessage::Role role, qint64 timestamp) {
return new ChatMessage(role, timestamp, this);
}
ChatMessage* ChatMessageModel::appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp) {
auto* message = new ChatMessage(role, timestamp, this);
auto* generation = message->addGeneration(timestamp);
generation->setContent(content);
beginInsertRows(QModelIndex(), 0, 0);
m_messages.prepend(message);
endInsertRows();
emit lastMessageChanged();
return message;
}
void ChatMessageModel::removeMessage(ChatMessage* message) {
const int row = rowOf(message);
if (row < 0) return;
const bool wasLastMessage = row == 0;
beginRemoveRows(QModelIndex(), row, row);
m_messages.removeAt(row);
endRemoveRows();
delete message;
if (wasLastMessage) emit lastMessageChanged();
}
void ChatMessageModel::removeRange(int firstRow, int lastRow) {
if (firstRow < 0 || firstRow > lastRow || lastRow >= m_messages.size())
return;
const bool changesLastMessage = firstRow == 0;
beginRemoveRows(QModelIndex(), firstRow, lastRow);
for (int row = lastRow; row >= firstRow; --row)
delete m_messages.takeAt(row);
endRemoveRows();
if (changesLastMessage) emit lastMessageChanged();
}
void ChatMessageModel::clear() {
if (m_messages.isEmpty()) return;
beginRemoveRows(QModelIndex(), 0, static_cast<int>(m_messages.size() - 1));
qDeleteAll(m_messages);
m_messages.clear();
endRemoveRows();
emit lastMessageChanged();
}
void ChatMessageModel::loadMessages(QList<ChatMessage*> messages) {
beginResetModel();
qDeleteAll(m_messages);
m_messages = std::move(messages);
endResetModel();
emit lastMessageChanged();
}
} // namespace ZShell::llm
-68
View File
@@ -1,68 +0,0 @@
#pragma once
#include "message.hpp"
#include <QAbstractListModel>
#include <QHash>
#include <QList>
#include <QtQml>
#include <qtmetamacros.h>
namespace ZShell::llm {
class ChatSession;
// Owns a session's messages, most recent first: row 0 is always the newest
// message. Items are exposed through a role named "modelData" (like
// FileSystemModel), so delegates receive each ChatMessage as `modelData`.
class ChatMessageModel : public QAbstractListModel {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Chat message models are owned by ChatSession")
Q_PROPERTY(
ZShell::llm::ChatMessage* lastMessage READ lastMessage
NOTIFY lastMessageChanged)
public:
explicit ChatMessageModel(ChatSession* session, QObject* parent = nullptr);
~ChatMessageModel() override;
[[nodiscard]] int rowCount(
const QModelIndex& parent = QModelIndex()) const override;
[[nodiscard]] QVariant data(
const QModelIndex& index, int role = Qt::DisplayRole) const override;
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
[[nodiscard]] ChatSession* session() const { return m_session; }
// Most recent message first.
[[nodiscard]] QList<ChatMessage*> messages() const { return m_messages; }
[[nodiscard]] ChatMessage* at(int row) const;
[[nodiscard]] int rowOf(const ChatMessage* message) const;
[[nodiscard]] ChatMessage* lastMessage() const {
return m_messages.isEmpty() ? nullptr : m_messages.first();
}
// Creates a message owned by this model without inserting it.
ChatMessage* createMessage(ChatMessage::Role role, qint64 timestamp);
// Appends a new message as the newest one (row 0) with a single
// generation holding `content`.
ChatMessage* appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp);
void removeMessage(ChatMessage* message);
void removeRange(int firstRow, int lastRow);
void clear();
// Replaces every row; takes ownership of the given messages, most recent
// first.
void loadMessages(QList<ChatMessage*> messages);
signals:
void lastMessageChanged();
private:
ChatSession* m_session;
QList<ChatMessage*> m_messages;
};
} // namespace ZShell::llm
-138
View File
@@ -1,138 +0,0 @@
#include "segment.hpp"
#include "markdownparser.hpp"
#include <QDateTime>
namespace ZShell::llm {
namespace {
// Re-parse cadence while a segment streams; content between refreshes is
// at most this stale.
constexpr int kMarkdownRefreshMs = 150;
} // namespace
LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent)
: QObject(parent), m_type(type), m_timestamp(timestamp) {
m_markdownTimer.setInterval(kMarkdownRefreshMs);
connect(
&m_markdownTimer, &QTimer::timeout, this, [this]() {
if (!m_markdownDirty)
return;
m_markdownDirty = false;
parseMarkdown();
});
}
qint64 LlmSegment::elapsedMs() const {
if (m_startedAt <= 0)
return 0;
const qint64 end = m_endedAt > 0 ? m_endedAt
: QDateTime::currentMSecsSinceEpoch();
return end - m_startedAt;
}
void LlmSegment::begin() {
if (m_running)
return;
m_running = true;
m_startedAt = QDateTime::currentMSecsSinceEpoch();
m_endedAt = 0;
Q_EMIT runningChanged();
Q_EMIT elapsedMsChanged();
}
void LlmSegment::close() {
if (m_running) {
m_running = false;
m_endedAt = QDateTime::currentMSecsSinceEpoch();
Q_EMIT runningChanged();
Q_EMIT elapsedMsChanged();
}
// The segment is final; parse immediately so the UI does not wait
// out the debounce.
if (m_type == Type::Content && m_markdownDirty) {
m_markdownTimer.stop();
parseMarkdown();
}
}
void LlmSegment::appendText(const QString& piece) {
if (piece.isEmpty())
return;
m_text += piece;
Q_EMIT textChanged();
scheduleMarkdown();
}
void LlmSegment::setText(const QString& value) {
if (m_text == value)
return;
m_text = value;
Q_EMIT textChanged();
scheduleMarkdown();
}
void LlmSegment::setName(const QString& value) {
if (m_name == value)
return;
m_name = value;
Q_EMIT nameChanged();
}
void LlmSegment::setToolCallId(const QString& value) {
if (m_toolCallId == value)
return;
m_toolCallId = value;
Q_EMIT toolCallIdChanged();
}
void LlmSegment::appendArguments(const QString& piece) {
if (piece.isEmpty())
return;
m_arguments += piece;
Q_EMIT argumentsChanged();
}
void LlmSegment::setResult(const QString& value) {
if (m_result == value)
return;
m_result = value;
Q_EMIT resultChanged();
}
void LlmSegment::setStatus(Status value) {
if (m_status == value)
return;
m_status = value;
Q_EMIT statusChanged();
}
void LlmSegment::finishTool(const QString& resultText, bool success) {
setResult(resultText);
setStatus(success ? Status::Success : Status::Error);
close();
}
void LlmSegment::restore(qint64 elapsedMs) {
m_startedAt = m_timestamp;
m_endedAt = m_timestamp + qMax<qint64>(0, elapsedMs);
}
void LlmSegment::scheduleMarkdown() {
// Content segments only; reasoning/tool output is never parsed.
// (User content is parsed too, so it can later be rendered as blocks
// as well; the QML currently only does that for assistant messages.)
if (m_type != Type::Content)
return;
m_markdownDirty = true;
if (!m_markdownTimer.isActive())
m_markdownTimer.start();
}
void LlmSegment::parseMarkdown() {
m_markdown = MarkdownParser::parse(m_text);
Q_EMIT markdownChanged();
}
} // namespace ZShell::llm
-113
View File
@@ -1,113 +0,0 @@
#pragma once
#include <QObject>
#include <QTimer>
#include <QString>
#include <QVariantList>
#include <QtQml>
namespace ZShell::llm {
// One unit of activity within a ChatGeneration. A generation keeps its
// segments in chronological order: zero or more reasoning bursts and
// tool calls interleaved, plus at most one content segment holding the
// final answer.
class LlmSegment : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Segments are managed by ChatGeneration")
Q_PROPERTY(Type type READ type CONSTANT)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(QString text READ text NOTIFY textChanged)
Q_PROPERTY(QString name READ name NOTIFY nameChanged)
Q_PROPERTY(QString toolCallId READ toolCallId NOTIFY toolCallIdChanged)
Q_PROPERTY(QString arguments READ arguments NOTIFY argumentsChanged)
Q_PROPERTY(QString result READ result NOTIFY resultChanged)
Q_PROPERTY(Status status READ status NOTIFY statusChanged)
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged)
// Parsed markdown blocks (QVariantList of maps, see MarkdownParser).
// Only Content segments are parsed. Refreshes are debounced so a
// streaming segment re-parses at a steady cadence rather than per
// chunk.
Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged)
public:
enum class Type : int {
Reasoning = 0,
ToolCall,
Content
};
Q_ENUM(Type)
enum class Status : int {
None = 0,
Running,
Success,
Error
};
Q_ENUM(Status)
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
[[nodiscard]] Type type() const { return m_type; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] QString text() const { return m_text; }
[[nodiscard]] QString name() const { return m_name; }
[[nodiscard]] QString toolCallId() const { return m_toolCallId; }
[[nodiscard]] QString arguments() const { return m_arguments; }
[[nodiscard]] QString result() const { return m_result; }
[[nodiscard]] Status status() const { return m_status; }
[[nodiscard]] bool running() const { return m_running; }
[[nodiscard]] qint64 elapsedMs() const;
[[nodiscard]] QVariantList markdown() const { return m_markdown; }
// Starts the segment's clock; a no-op while already running.
void begin();
// Stops the segment's clock; a no-op when not running.
void close();
void appendText(const QString& piece);
void setText(const QString& value);
void setName(const QString& value);
void setToolCallId(const QString& value);
void appendArguments(const QString& piece);
void setResult(const QString& value);
void setStatus(Status value);
// Completes a tool call with the model-facing result text.
void finishTool(const QString& resultText, bool success);
// Restores persisted timing without a live clock.
void restore(qint64 elapsedMs);
Q_SIGNALS:
void textChanged();
void markdownChanged();
void nameChanged();
void toolCallIdChanged();
void argumentsChanged();
void resultChanged();
void statusChanged();
void runningChanged();
void elapsedMsChanged();
private:
void scheduleMarkdown();
void parseMarkdown();
Type m_type;
qint64 m_timestamp;
QString m_text;
QString m_name;
QString m_toolCallId;
QString m_arguments;
QString m_result;
Status m_status = Status::None;
bool m_running = false;
qint64 m_startedAt = 0;
qint64 m_endedAt = 0;
QVariantList m_markdown;
QTimer m_markdownTimer;
bool m_markdownDirty = false;
};
} // namespace ZShell::llm
-216
View File
@@ -1,216 +0,0 @@
#include "session.hpp"
#include "chatstore.hpp"
#include "llmclient.hpp"
#include <QDebug>
#include <QDateTime>
#include <QtGlobal>
namespace ZShell::llm {
namespace {
QString titleFrom(const QString& content) {
const QString flat = content.simplified();
if (flat.isEmpty()) return QString();
if (flat.size() <= 48) return flat;
return flat.left(47) + QStringLiteral("");
}
} // namespace
ChatSession::ChatSession(const QString& id, QObject* parent)
: QObject(parent), m_id(id) {
m_model = new ChatMessageModel(this, this);
connect(
m_model,
&QAbstractListModel::rowsInserted,
this,
&ChatSession::onModelRowsChanged);
connect(
m_model,
&QAbstractListModel::rowsRemoved,
this,
&ChatSession::onModelRowsChanged);
connect(
m_model,
&QAbstractListModel::modelReset,
this,
&ChatSession::onModelRowsChanged);
}
void ChatSession::onModelRowsChanged() {
setCount(m_model->rowCount());
}
void ChatSession::setTitle(const QString& value) {
if (m_title == value) return;
m_title = value;
Q_EMIT titleChanged();
persist();
}
void ChatSession::setIcon(const QString& value) {
if (m_icon == value) return;
m_icon = value;
Q_EMIT iconChanged();
}
void ChatSession::setUpdatedAt(qint64 value) {
if (m_updatedAt == value) return;
m_updatedAt = value;
Q_EMIT updatedAtChanged();
}
void ChatSession::setPinned(bool value) {
if (m_pinned == value) return;
m_pinned = value;
Q_EMIT pinnedChanged();
}
void ChatSession::setCount(int value) {
if (m_messageCount == value) return;
m_messageCount = value;
Q_EMIT messageCountChanged();
}
void ChatSession::setMeta(
const QString& title, qint64 createdAt, qint64 updatedAt, int messageCount) {
m_title = title;
m_createdAt = createdAt;
m_updatedAt = updatedAt;
m_messageCount = messageCount;
}
void ChatSession::setLastTokenCount(int value) {
m_lastTokenCount = value;
}
LlmClient* ChatSession::client() const {
if (auto* store = qobject_cast<ChatStore*>(parent()))
return store->llmClient();
return nullptr;
}
void ChatSession::ensureLoaded() {
if (m_loaded) return;
m_loaded = true;
if (auto* store = qobject_cast<ChatStore*>(parent()))
store->loadMessagesInto(this);
}
ChatMessageModel* ChatSession::messagesModel() {
ensureLoaded();
return m_model;
}
void ChatSession::adoptMessages(QList<ChatMessage*> messages) {
m_model->loadMessages(std::move(messages));
}
void ChatSession::persist() {
if (auto* store = qobject_cast<ChatStore*>(parent())) store->persist(this);
}
ChatMessage* ChatSession::appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp) {
return m_model->appendNewest(role, content, timestamp);
}
void ChatSession::removeMessage(ChatMessage* message) {
m_model->removeMessage(message);
}
void ChatSession::clearMessages() {
m_model->clear();
}
void ChatSession::startGeneration(ChatMessage* target) {
if (auto* generation = target->activeGeneration()) {
if (auto* clientObject = client())
clientObject->startGeneration(this, generation);
}
persist();
}
void ChatSession::sendMessage(const QString& text) {
const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
const qint64 now = QDateTime::currentMSecsSinceEpoch();
appendNewest(ChatMessage::Role::User, trimmed, now);
while (m_model->rowCount() > 200)
m_model->removeMessage(m_model->at(m_model->rowCount() - 1));
if (m_title.isEmpty()) {
m_title = titleFrom(trimmed);
Q_EMIT titleChanged();
qInfo() << "ChatSession:" << m_id << "new conversation,"
<< "fallback title" << m_title
<< "- requesting generated title and icon";
if (auto* clientObject = client()) {
clientObject->requestTitle(this, trimmed);
clientObject->requestIcon(this, trimmed);
}
}
auto* assistant =
appendNewest(ChatMessage::Role::Assistant, QString(), now);
startGeneration(assistant);
}
void ChatSession::retry(ChatMessage* target) {
if (!target || target->role() != ChatMessage::Role::Assistant) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
const int row = m_model->rowOf(target);
if (row < 0) return;
// Drop everything newer than the target, then regenerate from the
// context ending at the user message before it.
m_model->removeRange(0, row - 1);
if (m_model->rowCount() < 2) return;
const qint64 now = QDateTime::currentMSecsSinceEpoch();
target->appendGeneration(now);
startGeneration(target);
}
void ChatSession::continueFrom(ChatMessage* message) {
if (!message) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
const int row = m_model->rowOf(message);
if (row < 0) return;
m_model->removeRange(0, row - 1);
const qint64 now = QDateTime::currentMSecsSinceEpoch();
if (message->role() == ChatMessage::Role::Assistant) {
if (m_model->rowCount() < 2) return;
message->appendGeneration(now);
startGeneration(message);
} else {
auto* assistant =
appendNewest(ChatMessage::Role::Assistant, QString(), now);
startGeneration(assistant);
}
}
void ChatSession::clear() {
if (auto* clientObject = client()) {
if (clientObject->busy() && clientObject->streamingSession() == this) {
clientObject->clearOnFinish(this);
clientObject->stop();
return;
}
}
m_model->clear();
persist();
}
} // namespace ZShell::llm
-103
View File
@@ -1,103 +0,0 @@
#pragma once
#include "messagemodel.hpp"
#include "message.hpp"
#include <QDateTime>
#include <QObject>
#include <QString>
#include <QtQml>
namespace ZShell::llm {
class LlmClient;
class ChatSession : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Chat sessions are managed by Chat.chats")
Q_PROPERTY(QString id READ id CONSTANT)
Q_PROPERTY(QString title READ title WRITE setTitle NOTIFY titleChanged)
Q_PROPERTY(QString icon READ icon NOTIFY iconChanged)
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(int messageCount READ messageCount NOTIFY messageCountChanged)
Q_PROPERTY(
ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT)
public:
explicit ChatSession(const QString& id, QObject* parent = nullptr);
[[nodiscard]] QString id() const { return m_id; }
[[nodiscard]] QString title() const { return m_title; }
[[nodiscard]] QString icon() const { return m_icon; }
[[nodiscard]] QDateTime createdAt() const {
return QDateTime::fromMSecsSinceEpoch(m_createdAt);
}
[[nodiscard]] QDateTime updatedAt() const {
return QDateTime::fromMSecsSinceEpoch(m_updatedAt);
}
[[nodiscard]] qint64 createdAtMs() const { return m_createdAt; }
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
[[nodiscard]] bool pinned() const { return m_pinned; }
[[nodiscard]] int messageCount() const { return m_messageCount; }
// Loads the messages from the store on first access.
[[nodiscard]] ChatMessageModel* messagesModel();
void ensureLoaded();
[[nodiscard]] bool isLoaded() const { return m_loaded; }
[[nodiscard]] LlmClient* client() const;
[[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; }
void setLastTokenCount(int value);
void setTitle(const QString& value);
void setIcon(const QString& value);
void setUpdatedAt(qint64 value);
void setPinned(bool value);
void setMeta(
const QString& title,
qint64 createdAt,
qint64 updatedAt,
int messageCount);
// Replaces the model's rows with `messages` (most recent first).
void adoptMessages(QList<ChatMessage*> messages);
void persist();
void removeMessage(ChatMessage* message);
void clearMessages();
Q_INVOKABLE void sendMessage(const QString& text);
Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target);
Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message);
Q_INVOKABLE void clear();
Q_SIGNALS:
void titleChanged();
void iconChanged();
void updatedAtChanged();
void pinnedChanged();
void messageCountChanged();
private:
void onModelRowsChanged();
ChatMessage* appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp);
void startGeneration(ChatMessage* target);
void setCount(int value);
QString m_id;
QString m_title;
QString m_icon;
qint64 m_createdAt = 0;
qint64 m_updatedAt = 0;
bool m_pinned = false;
int m_messageCount = 0;
ChatMessageModel* m_model = nullptr;
bool m_loaded = false;
int m_lastTokenCount = 0;
};
} // namespace ZShell::llm
-59
View File
@@ -1,59 +0,0 @@
#include "tool.hpp"
namespace ZShell::llm {
LlmTool::LlmTool(QObject* parent) : QObject(parent) {}
LlmTool::~LlmTool() = default;
void LlmTool::cancel() {}
QJsonObject LlmTool::specification() const {
QJsonObject function;
function[QStringLiteral("name")] = name();
function[QStringLiteral("description")] = description();
function[QStringLiteral("parameters")] = parameters();
QJsonObject spec;
spec[QStringLiteral("type")] = QStringLiteral("function");
spec[QStringLiteral("function")] = function;
return spec;
}
ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {}
void ToolRegistry::setEnabled(bool value) {
if (m_enabled == value)
return;
m_enabled = value;
Q_EMIT enabledChanged();
}
void ToolRegistry::registerTool(LlmTool* tool) {
if (!tool || m_tools.contains(tool))
return;
tool->setParent(this);
m_tools.append(tool);
}
LlmTool* ToolRegistry::tool(const QString& name) const {
for (const auto* tool : m_tools)
if (tool->name() == name)
return const_cast<LlmTool*>(tool);
return nullptr;
}
QJsonArray ToolRegistry::specifications() const {
if (!m_enabled)
return {};
QJsonArray specs;
for (const auto* tool : m_tools)
specs.append(tool->specification());
return specs;
}
void ToolRegistry::cancelAll() {
for (auto* tool : m_tools)
tool->cancel();
}
} // namespace ZShell::llm
-70
View File
@@ -1,70 +0,0 @@
#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QList>
#include <QObject>
#include <QString>
#include <functional>
namespace ZShell::llm {
// A capability the model may invoke mid-turn. Tools run asynchronously
// and report exactly one result: `{"output": ...}` on success or
// `{"error": ...}` on failure.
class LlmTool : public QObject {
Q_OBJECT
public:
explicit LlmTool(QObject* parent = nullptr);
~LlmTool() override;
[[nodiscard]] virtual QString name() const = 0;
[[nodiscard]] virtual QString description() const = 0;
// JSON Schema describing the tool's `arguments` object.
[[nodiscard]] virtual QJsonObject parameters() const = 0;
// Runs the tool; `done` is invoked exactly once, with
// `{"output": ...}` on success or `{"error": ...}` on failure.
// `done` must be invoked asynchronously (on a later event loop
// iteration), never synchronously within execute().
virtual void execute(
const QJsonObject& args,
std::function<void(const QJsonObject& result)> done) = 0;
// Abandons in-flight work, if any.
virtual void cancel();
// The OpenAI-compatible `tools` entry for this tool.
[[nodiscard]] QJsonObject specification() const;
};
// Owns the set of tools available to the model.
class ToolRegistry : public QObject {
Q_OBJECT
Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged)
public:
explicit ToolRegistry(QObject* parent = nullptr);
[[nodiscard]] bool enabled() const { return m_enabled; }
void setEnabled(bool value);
// Takes ownership; tools become children of the registry.
void registerTool(LlmTool* tool);
[[nodiscard]] LlmTool* tool(const QString& name) const;
// The request body's `tools` array; empty while disabled.
[[nodiscard]] QJsonArray specifications() const;
// Abandons in-flight work in every tool.
void cancelAll();
Q_SIGNALS:
void enabledChanged();
private:
bool m_enabled = true;
QList<LlmTool*> m_tools;
};
} // namespace ZShell::llm
-428
View File
@@ -1,428 +0,0 @@
#include "webfetchtool.hpp"
#include <QJsonArray>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSet>
#include <QTimer>
namespace ZShell::llm {
namespace {
const char* kUserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
QJsonObject makeOutput(const QString& text) {
QJsonObject obj;
obj[QStringLiteral("output")] = text;
return obj;
}
QJsonObject makeError(const QString& message) {
QJsonObject obj;
obj[QStringLiteral("error")] = message;
return obj;
}
} // namespace
WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {}
WebFetchTool::~WebFetchTool() {
for (auto* job : m_jobs) {
if (job->timer)
job->timer->stop();
if (job->reply)
job->reply->abort();
}
// Pending result callbacks are dropped; the client is going away.
qDeleteAll(m_jobs);
}
QString WebFetchTool::name() const {
return QStringLiteral("webfetch");
}
QString WebFetchTool::description() const {
return QStringLiteral(
"Fetch content from an HTTP or HTTPS URL and return it as plain "
"text or raw HTML. HTML pages are reduced to their visible text "
"by default. This tool is read-only.");
}
QJsonObject WebFetchTool::parameters() const {
QJsonObject url;
url[QStringLiteral("type")] = QStringLiteral("string");
url[QStringLiteral("description")] =
QStringLiteral("The HTTP or HTTPS URL to fetch content from");
QJsonArray formats;
formats.append(QStringLiteral("text"));
formats.append(QStringLiteral("html"));
QJsonObject format;
format[QStringLiteral("type")] = QStringLiteral("string");
format[QStringLiteral("enum")] = formats;
format[QStringLiteral("description")] =
QStringLiteral("The format to return the content in. Defaults to "
"text.");
QJsonObject timeout;
timeout[QStringLiteral("type")] = QStringLiteral("integer");
timeout[QStringLiteral("minimum")] = 1;
timeout[QStringLiteral("maximum")] = MaxTimeoutSeconds;
timeout[QStringLiteral("description")] =
QStringLiteral("Optional timeout in seconds");
QJsonObject properties;
properties[QStringLiteral("url")] = url;
properties[QStringLiteral("format")] = format;
properties[QStringLiteral("timeout")] = timeout;
QJsonObject schema;
schema[QStringLiteral("type")] = QStringLiteral("object");
schema[QStringLiteral("properties")] = properties;
QJsonArray required;
required.append(QStringLiteral("url"));
schema[QStringLiteral("required")] = required;
return schema;
}
void WebFetchTool::completeJob(Job* job, QJsonObject result) {
// Deliver on a later event loop iteration; LlmClient relies on tool
// results never arriving synchronously within execute().
QMetaObject::invokeMethod(
this,
[this, job, result = std::move(result)]() mutable {
if (!m_jobs.contains(job))
return;
auto done = std::move(job->done);
m_jobs.removeAll(job);
job->timer->deleteLater();
if (job->reply)
job->reply->deleteLater();
delete job;
done(std::move(result));
},
Qt::QueuedConnection);
}
void WebFetchTool::execute(
const QJsonObject& args, std::function<void(const QJsonObject&)> done) {
auto* job = new Job;
job->done = std::move(done);
m_jobs.append(job);
auto fail = [this, job](const QString& message) {
job->timer->stop();
completeJob(job, makeError(message));
};
const QString urlText = args[QStringLiteral("url")].toString().trimmed();
const QUrl url = QUrl::fromUserInput(urlText);
if (!url.isValid() || url.host().isEmpty()) {
fail(QStringLiteral("Invalid URL: %1").arg(urlText));
return;
}
if (url.scheme() != QLatin1String("http") &&
url.scheme() != QLatin1String("https")) {
fail(QStringLiteral("URL must use http:// or https://"));
return;
}
job->format =
args[QStringLiteral("format")].toString(QStringLiteral("text"));
if (job->format != QLatin1String("html"))
job->format = QStringLiteral("text");
const int timeoutMs = qBound(
1,
args[QStringLiteral("timeout")].toInt(
DefaultTimeoutSeconds),
MaxTimeoutSeconds) *
1000;
job->timer = new QTimer(this);
job->timer->setSingleShot(true);
connect(
job->timer, &QTimer::timeout, this, [this, job]() {
if (job->reply)
job->reply->abort();
});
job->timer->start(timeoutMs);
QNetworkRequest request(url);
request.setRawHeader("User-Agent", QByteArray(kUserAgent));
request.setRawHeader(
"Accept",
job->format == QLatin1String("html")
? "text/html;q=1.0, application/xhtml+xml;q=0.9, */*;q=0.1"
: "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, "
"*/*;q=0.1");
request.setRawHeader("Accept-Language", "en-US,en;q=0.9");
job->reply = m_manager.get(request);
connect(job->reply, &QNetworkReply::readyRead, this, [this, job]() {
if (!job->reply)
return;
job->body += job->reply->readAll();
if (job->body.size() > MaxResponseBytes) {
job->tooLarge = true;
job->reply->abort();
}
});
connect(job->reply, &QNetworkReply::finished, this, [this, job]() {
QNetworkReply* reply = job->reply;
job->reply = nullptr;
job->timer->stop();
if (!reply)
return;
const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
QByteArray body = job->body;
body += reply->readAll();
job->body.clear();
const QByteArray contentType =
reply->rawHeader("Content-Type").toLower();
const int status =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute)
.toInt();
if (job->tooLarge) {
completeJob(job, makeError(
QStringLiteral("Response too large (exceeds the 5 MB "
"limit")));
return;
}
if (error != QNetworkReply::NoError) {
completeJob(
job,
makeError(
QStringLiteral("Request failed: %1").arg(errorString)));
return;
}
if (status < 200 || status >= 300) {
completeJob(
job,
makeError(
QStringLiteral("Server returned status %1")
.arg(status)));
return;
}
const QString mime =
QString::fromLatin1(contentType).section(QLatin1Char(';'), 0, 0)
.trimmed();
if (mime.startsWith(QLatin1String("image/"))) {
completeJob(
job,
makeError(
QStringLiteral(
"Unsupported fetched image content type: %1")
.arg(mime)));
return;
}
const bool textual = mime.isEmpty() ||
mime.startsWith(QLatin1String("text/")) ||
mime == QLatin1String("application/json") ||
mime.endsWith(QLatin1String("+json")) ||
mime == QLatin1String("application/xml") ||
mime.endsWith(QLatin1String("+xml")) ||
mime.startsWith(QLatin1String("application/javascript")) ||
mime.startsWith(QLatin1String("text/javascript"));
if (!textual) {
completeJob(
job,
makeError(
QStringLiteral(
"Unsupported fetched file content type: %1")
.arg(mime)));
return;
}
QString content = QString::fromUtf8(body);
if (mime.contains(QLatin1String("text/html")) &&
job->format == QLatin1String("text"))
content = extractTextFromHtml(content);
if (content.size() > MaxOutputChars)
content = content.left(MaxOutputChars) +
QStringLiteral("\n[... truncated ...]");
completeJob(job, makeOutput(content));
});
}
void WebFetchTool::cancel() {
for (auto* job : m_jobs) {
job->timer->stop();
if (job->reply)
job->reply->abort();
}
}
QString WebFetchTool::decodeEntities(const QString& text) {
if (!text.contains(QLatin1Char('&')))
return text;
QString out;
out.reserve(text.size());
for (qsizetype i = 0; i < text.size(); ++i) {
if (text.at(i) != QLatin1Char('&')) {
out += text.at(i);
continue;
}
const qsizetype semi = text.indexOf(QLatin1Char(';'), i);
if (semi < 0 || semi - i > 12) {
out += QLatin1Char('&');
continue;
}
const QString entity = text.mid(i + 1, semi - i - 1);
QString replacement;
if (entity == QLatin1String("amp"))
replacement = QLatin1Char('&');
else if (entity == QLatin1String("lt"))
replacement = QLatin1Char('<');
else if (entity == QLatin1String("gt"))
replacement = QLatin1Char('>');
else if (entity == QLatin1String("quot"))
replacement = QLatin1Char('"');
else if (entity == QLatin1String("apos"))
replacement = QLatin1Char('\'');
else if (entity == QLatin1String("nbsp"))
replacement = QLatin1Char(' ');
else {
bool ok = false;
const quint32 codePoint = entity.startsWith(QLatin1String("#x")) ||
entity.startsWith(QLatin1String("#X"))
? entity.mid(2).toUInt(&ok, 16)
: entity.toUInt(&ok);
if (ok && codePoint != 0) {
const char32_t ucs4[2] = {
static_cast<char32_t>(codePoint),
0,
};
replacement = QString::fromUcs4(ucs4);
}
}
if (replacement.isEmpty()) {
out += QLatin1Char('&');
continue;
}
out += replacement;
i = semi;
}
return out;
}
QString WebFetchTool::extractTextFromHtml(const QString& html) {
static const QSet<QString> kSkipTags = {
QStringLiteral("noscript"),
QStringLiteral("iframe"),
QStringLiteral("object"),
QStringLiteral("head"),
};
static const QSet<QString> kRawTags = {
QStringLiteral("script"),
QStringLiteral("style"),
};
static const QSet<QString> kVoidTags = {
QStringLiteral("area"), QStringLiteral("base"),
QStringLiteral("br"), QStringLiteral("col"),
QStringLiteral("embed"), QStringLiteral("hr"),
QStringLiteral("img"), QStringLiteral("input"),
QStringLiteral("link"), QStringLiteral("meta"),
QStringLiteral("source"), QStringLiteral("track"),
QStringLiteral("wbr"),
};
QString text;
text.reserve(html.size() / 2);
int skipDepth = 0;
qsizetype i = 0;
while (i < html.size()) {
const qsizetype open = html.indexOf(QLatin1Char('<'), i);
if (open < 0) {
if (skipDepth == 0)
text += html.mid(i);
break;
}
if (skipDepth == 0)
text += html.mid(i, open - i);
const qsizetype close = html.indexOf(QLatin1Char('>'), open);
if (close < 0)
break;
const QString tag =
html.mid(open + 1, close - open - 1).trimmed().toLower();
i = close + 1;
if (tag.startsWith(QLatin1Char('!')) ||
tag.startsWith(QLatin1Char('?')))
continue;
QString name = tag;
if (name.startsWith(QLatin1Char('/'))) {
if (skipDepth > 0)
--skipDepth;
continue;
}
qsizetype j = 0;
while (j < name.size() &&
(name.at(j).isLetterOrNumber() ||
name.at(j) == QLatin1Char(':') ||
name.at(j) == QLatin1Char('-')))
++j;
name = name.left(j);
if (kRawTags.contains(name)) {
// Raw-text element: swallow everything up to its close tag.
const qsizetype rawEnd =
html.indexOf(QStringLiteral("</") + name, i,
Qt::CaseInsensitive);
if (rawEnd < 0)
break;
const qsizetype rawClose = html.indexOf(QLatin1Char('>'), rawEnd);
if (rawClose < 0)
break;
i = rawClose + 1;
continue;
}
if (kVoidTags.contains(name))
continue;
if (skipDepth > 0) {
// Browsers implicitly close <head> at <body>; malformed pages
// without a </head> would otherwise swallow the whole page.
if (name == QLatin1String("body")) {
skipDepth = 0;
continue;
}
++skipDepth;
continue;
}
if (kSkipTags.contains(name)) {
++skipDepth;
continue;
}
// Normal tag: replace with a space so words do not merge.
text += QLatin1Char(' ');
}
QString out = decodeEntities(text);
QStringList lines;
for (const QString& line : out.split(QLatin1Char('\n'))) {
const QString flat = line.simplified();
if (flat.isEmpty()) {
if (!lines.isEmpty() && lines.last().isEmpty())
continue;
lines.append(QString());
} else {
lines.append(flat);
}
}
while (lines.size() > 1 && lines.first().isEmpty())
lines.removeFirst();
while (lines.size() > 1 && lines.last().isEmpty())
lines.removeLast();
return lines.join(QLatin1Char('\n'));
}
} // namespace ZShell::llm
-64
View File
@@ -1,64 +0,0 @@
#pragma once
#include "tool.hpp"
#include <QByteArray>
#include <QList>
#include <QNetworkAccessManager>
#include <functional>
class QNetworkReply;
class QTimer;
namespace ZShell::llm {
// Fetches an http(s) URL and returns its content as plain text or raw
// HTML. Read-only. Mirrors opencode's webfetch tool, without markdown
// conversion and the permission prompt. Concurrent fetches are
// supported; results are always delivered on a later event loop
// iteration, never synchronously from execute().
class WebFetchTool : public LlmTool {
Q_OBJECT
public:
static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
static constexpr int DefaultTimeoutSeconds = 30;
static constexpr int MaxTimeoutSeconds = 120;
// Caps the characters handed to the model so a large page cannot
// blow out the context.
static constexpr int MaxOutputChars = 64 * 1024;
explicit WebFetchTool(QObject* parent = nullptr);
~WebFetchTool() override;
QString name() const override;
QString description() const override;
QJsonObject parameters() const override;
void execute(
const QJsonObject& args,
std::function<void(const QJsonObject& result)> done) override;
void cancel() override;
// Strips tags (skipping script/style/noscript/iframe/object/embed/
// head) and decodes common entities.
static QString extractTextFromHtml(const QString& html);
static QString decodeEntities(const QString& text);
private:
struct Job {
QNetworkReply* reply = nullptr;
QTimer* timer = nullptr;
QByteArray body;
bool tooLarge = false;
QString format;
std::function<void(const QJsonObject& result)> done;
};
void completeJob(Job* job, QJsonObject result);
QNetworkAccessManager m_manager;
QList<Job*> m_jobs;
};
} // namespace ZShell::llm
+5 -2
View File
@@ -163,6 +163,7 @@ void Gpu::tick() {
const Type t = type(); const Type t = type();
if (t == Generic) { if (t == Generic) {
readGenericUsage(); readGenericUsage();
readGenericMemory();
readGpuTemperature(); readGpuTemperature();
} else if (t == Nvidia) { } else if (t == Nvidia) {
startNvidiaUsage(); startNvidiaUsage();
@@ -334,8 +335,10 @@ void Gpu::startNvidiaUsage() {
const qreal usage = const qreal usage =
parts.at(0).trimmed().toDouble(&ok1) / 100.0; parts.at(0).trimmed().toDouble(&ok1) / 100.0;
const qreal temp = parts.at(1).trimmed().toDouble(&ok2); const qreal temp = parts.at(1).trimmed().toDouble(&ok2);
const qreal memUsed = parts.at(2).trimmed().toDouble(&ok3); const qreal memUsed =
const qreal memTotal = parts.at(3).trimmed().toDouble(&ok4); parts.at(2).trimmed().toDouble(&ok3) * 1024;
const qreal memTotal =
parts.at(3).trimmed().toDouble(&ok4) * 1024;
if (ok1 && std::abs(usage - m_percentage) > 0.0001) { if (ok1 && std::abs(usage - m_percentage) > 0.0001) {
m_percentage = usage; m_percentage = usage;
+13 -6
View File
@@ -15,7 +15,6 @@ It includes a configurable top bar, launcher, notifications daemon + sidebar, wa
## Highlights ## Highlights
- Multi-window shell layout driven by `Drawers/Windows.qml` and panel/interactions wrappers - Multi-window shell layout driven by `Drawers/Windows.qml` and panel/interactions wrappers
- Configurable bar entries and popouts (audio, tray, network, power, resources, clock, active window)
- Launcher with app DB frequency tracking, action commands, fuzzy search modes, and wallpaper/scheme flows - Launcher with app DB frequency tracking, action commands, fuzzy search modes, and wallpaper/scheme flows
- Notification server with persistence (`~/.local/state/zshell/notifs.json`) and sidebar UI - Notification server with persistence (`~/.local/state/zshell/notifs.json`) and sidebar UI
- Dynamic Material 3 palette generation from wallpaper, optional template rendering, terminal sequence application - Dynamic Material 3 palette generation from wallpaper, optional template rendering, terminal sequence application
@@ -47,16 +46,25 @@ Core requirements:
- Hyprland (Wayland session integration) - Hyprland (Wayland session integration)
- Python 3 for scheme/wallpaper tooling - Python 3 for scheme/wallpaper tooling
Make sure to have the newest Quickshell version! As of writing, version `0.2.0.r136.gfb08ece-1`. Here's a list of package dependencies, names are from arch-packages so they
might differ slightly depending on your distro.
```
python python-pillow python-materialyoucolor libnotify cava
app2unit wl-clipboard dconf cliphist python-typer qt6-canvaspainter
python-build python-installer python-hatch python-hatch-vcs cmake ninja
```
Make sure to have the newest Quickshell version! As of writing, version `0.3.1.r1.g0fed22a-1`.
Used by major features (install as needed for your setup): Used by major features (install as needed for your setup):
- `app2unit` (launcher app execution) - `app2unit` (launcher app execution)
- `nmcli` (network integration) - `nmcli` (network integration)
- `brightnessctl`, `ddcutil` (brightness controls) - `brightnessctl`, `ddcutil` (brightness controls)
- `wl-copy`, `swappy` (picker/screenshot flow) - `wl-copy` (clipboard integration)
- `libqalculate` (launcher calculator)
- `PipeWire` + audio stack + `aubio`/`cava` paths for media visualization - `PipeWire` + audio stack + `aubio`/`cava` paths for media visualization
- `libqalculate` (launcher calculator)
- `gsettings` (optional GTK dark/light mode sync) - `gsettings` (optional GTK dark/light mode sync)
## Build and Install ## Build and Install
@@ -180,7 +188,7 @@ Important state/cache files:
- `~/.local/state/zshell/apps.sqlite` - `~/.local/state/zshell/apps.sqlite`
- `~/.cache/zshell/` - `~/.cache/zshell/`
Config is hot-reloaded and saved through `Config/Config.qml` serializers. Top-level sections include: Config is hot-reloaded and saved through the `Config` singleton. Top-level sections include:
- `general` - `general`
- `appearance` - `appearance`
@@ -265,7 +273,6 @@ Note: Template rendering (Jinja2) applies generated colors to `~/.config/zshell/
### `screenshot` — area picker ### `screenshot` — area picker
- `start` — open interactive area picker - `start` — open interactive area picker
- `start-freeze` — freeze screen then pick
### `wallpaper` — wallpaper management ### `wallpaper` — wallpaper management