Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78988700be | ||
|
|
3946541f87 | ||
|
|
e18875a752 | ||
|
|
aae3757daf | ||
|
|
37c6a9986e | ||
|
|
9657e5092a | ||
|
|
47bab21e22 |
+2
-1
@@ -3,8 +3,9 @@ FunctionsSpacing=true
|
|||||||
IndentWidth=4
|
IndentWidth=4
|
||||||
MaxColumnWidth=-1
|
MaxColumnWidth=-1
|
||||||
NewlineType=native
|
NewlineType=native
|
||||||
NormalizeOrder=true
|
GroupAttributesTogether=true
|
||||||
ObjectsSpacing=true
|
ObjectsSpacing=true
|
||||||
SemicolonRule=always
|
SemicolonRule=always
|
||||||
|
SingleLineEmptyObjects=true
|
||||||
SortImports=false
|
SortImports=false
|
||||||
UseTabs=true
|
UseTabs=true
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ Flickable {
|
|||||||
interval: 10
|
interval: 10
|
||||||
running: root.doneFakeFlick
|
running: root.doneFakeFlick
|
||||||
|
|
||||||
onTriggered: root.doneFakeFlick = false
|
onTriggered: {
|
||||||
|
root.doneFakeFlick = false;
|
||||||
|
root.returnToBounds();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,27 @@ 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: root.doneFakeFlick = false
|
// onTriggered: {
|
||||||
}
|
// root.doneFakeFlick = false;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,42 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Templates
|
import QtQuick.Templates
|
||||||
import qs.Helpers
|
import qs.Helpers
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
import qs.Services
|
|
||||||
|
|
||||||
ScrollBar {
|
ScrollBar {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
required property Flickable flickable
|
||||||
|
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
|
||||||
|
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
|
||||||
|
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
|
||||||
|
readonly property real axisContentPos: isHorizontal ? flickable.contentX : flickable.contentY
|
||||||
|
readonly property real axisLength: isHorizontal ? root.width : root.height
|
||||||
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
|
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
|
||||||
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
|
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
|
||||||
required property Flickable flickable
|
readonly property real nonAnimHeight: root.axisSize / root.axisContentSize
|
||||||
readonly property real nonAnimHeight: flickable.height / flickable.contentHeight
|
readonly property real nonAnimY: root.axisContentPos / root.axisContentSize
|
||||||
readonly property real nonAnimY: flickable.contentY / flickable.contentHeight
|
|
||||||
readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight)
|
readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight)
|
||||||
readonly property bool reversed: flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop
|
readonly property bool reversed: isHorizontal ? (flickable instanceof ListView && flickable.layoutDirection === Qt.RightToLeft) : (flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop)
|
||||||
property bool shouldBeActive
|
property bool shouldBeActive
|
||||||
readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0
|
readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0
|
||||||
|
|
||||||
enabled: !Visibilities.getForActive().isDrawing
|
enabled: !Visibilities.getForActive().isDrawing
|
||||||
implicitWidth: Tokens.padding.extraSmall * 2
|
parent: flickable.parent
|
||||||
|
anchors.left: isHorizontal ? flickable.left : undefined
|
||||||
|
anchors.right: flickable.right
|
||||||
|
anchors.top: isHorizontal ? undefined : flickable.top
|
||||||
|
anchors.bottom: flickable.bottom
|
||||||
|
implicitWidth: isHorizontal ? 0 : Tokens.padding.extraSmall * 2
|
||||||
|
implicitHeight: isHorizontal ? Tokens.padding.extraSmall * 2 : 0
|
||||||
|
|
||||||
contentItem: Item {
|
contentItem: Item {}
|
||||||
}
|
|
||||||
Behavior on position {
|
Behavior on position {
|
||||||
enabled: !fullMouse.pressed
|
enabled: !fullMouse.pressed
|
||||||
|
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onHoveredChanged: {
|
onHoveredChanged: {
|
||||||
@@ -47,54 +57,26 @@ ScrollBar {
|
|||||||
target: root.flickable
|
target: root.flickable
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomClippingRect {
|
Loader {
|
||||||
anchors.bottom: parent.bottom
|
anchors.fill: parent
|
||||||
anchors.right: parent.right
|
sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack
|
||||||
anchors.top: parent.top
|
}
|
||||||
implicitWidth: handle.implicitWidth
|
|
||||||
radius: Tokens.rounding.full
|
|
||||||
|
|
||||||
CustomRect {
|
Component {
|
||||||
id: handle
|
id: verticalTrack
|
||||||
|
|
||||||
anchors.right: parent.right
|
VerticalScrollBarTrack {
|
||||||
color: Colors.palette.m3secondary
|
scrollBar: root
|
||||||
implicitHeight: root.height * root.effectiveSize
|
mouseArea: fullMouse
|
||||||
implicitWidth: fullMouse.pressed || fullMouse.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall
|
}
|
||||||
opacity: {
|
}
|
||||||
if (!root.enabled)
|
|
||||||
return 0;
|
|
||||||
if (root.size === 1)
|
|
||||||
return 0;
|
|
||||||
if (fullMouse.pressed)
|
|
||||||
return 1;
|
|
||||||
if (fullMouse.containsMouse)
|
|
||||||
return 0.8;
|
|
||||||
if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive)
|
|
||||||
return 0.6;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
radius: Tokens.rounding.full
|
|
||||||
y: root.reversed ? root.height * (1 + root.nonAnimY) * root.travelScale : root.height * root.nonAnimY * root.travelScale
|
|
||||||
|
|
||||||
Behavior on implicitWidth {
|
Component {
|
||||||
Anim {
|
id: horizontalTrack
|
||||||
}
|
|
||||||
}
|
|
||||||
Behavior on opacity {
|
|
||||||
Anim {
|
|
||||||
type: Anim.DefaultEffects
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
HorizontalScrollBarTrack {
|
||||||
id: mouse
|
scrollBar: root
|
||||||
|
mouseArea: fullMouse
|
||||||
acceptedButtons: Qt.NoButton
|
|
||||||
anchors.fill: parent
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
hoverEnabled: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,21 +93,25 @@ ScrollBar {
|
|||||||
|
|
||||||
property real pressOffset: 0
|
property real pressOffset: 0
|
||||||
|
|
||||||
function contentYFromThumbTop(thumbTop) {
|
function contentPosFromThumbStart(thumbStart) {
|
||||||
var visualPos = root.effectiveTravel > 0 ? thumbTop / root.travelScale : 0;
|
var visualPos = root.effectiveTravel > 0 ? thumbStart / root.travelScale : 0;
|
||||||
|
return root.reversed ? (visualPos - 1) * root.axisContentSize : visualPos * root.axisContentSize;
|
||||||
return root.reversed ? (visualPos - 1) * root.flickable.contentHeight : visualPos * root.flickable.contentHeight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFromEvent(event) {
|
function updateFromEvent(event) {
|
||||||
var posInTrack = event.y / root.height;
|
var eventPos = root.isHorizontal ? event.x : event.y;
|
||||||
var thumbTop = posInTrack - pressOffset;
|
var posInTrack = eventPos / root.axisLength;
|
||||||
thumbTop = Math.max(0, Math.min(root.effectiveTravel, thumbTop));
|
var thumbStart = posInTrack - pressOffset;
|
||||||
|
thumbStart = Math.max(0, Math.min(root.effectiveTravel, thumbStart));
|
||||||
|
|
||||||
root.flickable.contentY = contentYFromThumbTop(thumbTop);
|
var newPos = contentPosFromThumbStart(thumbStart);
|
||||||
|
if (root.isHorizontal)
|
||||||
|
root.flickable.contentX = newPos;
|
||||||
|
else
|
||||||
|
root.flickable.contentY = newPos;
|
||||||
}
|
}
|
||||||
|
|
||||||
function visualThumbTop() {
|
function visualThumbStart() {
|
||||||
const visualPos = root.reversed ? (1 + root.nonAnimY) : root.nonAnimY;
|
const visualPos = root.reversed ? (1 + root.nonAnimY) : root.nonAnimY;
|
||||||
return visualPos * root.travelScale;
|
return visualPos * root.travelScale;
|
||||||
}
|
}
|
||||||
@@ -140,22 +126,18 @@ ScrollBar {
|
|||||||
updateFromEvent(event);
|
updateFromEvent(event);
|
||||||
}
|
}
|
||||||
onPressed: event => {
|
onPressed: event => {
|
||||||
var currentTop = visualThumbTop();
|
var currentStart = visualThumbStart();
|
||||||
var currentBottom = currentTop + root.effectiveSize;
|
var currentEnd = currentStart + root.effectiveSize;
|
||||||
var clickPos = event.y / root.height;
|
var eventPos = root.isHorizontal ? event.x : event.y;
|
||||||
|
var clickPos = eventPos / root.axisLength;
|
||||||
|
|
||||||
var clickedInsideThumb = clickPos >= currentTop && clickPos <= currentBottom;
|
var clickedInsideThumb = clickPos >= currentStart && clickPos <= currentEnd;
|
||||||
|
pressOffset = clickedInsideThumb ? (clickPos - currentStart) : root.effectiveSize / 2;
|
||||||
if (clickedInsideThumb) {
|
|
||||||
pressOffset = clickPos - currentTop;
|
|
||||||
} else {
|
|
||||||
pressOffset = root.effectiveSize / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFromEvent(event);
|
updateFromEvent(event);
|
||||||
}
|
}
|
||||||
onWheel: event => {
|
onWheel: event => {
|
||||||
var delta = event.angleDelta.y > 0 ? -0.1 : 0.1;
|
var delta = (root.isHorizontal ? event.angleDelta.x : event.angleDelta.y) > 0 ? -0.1 : 0.1;
|
||||||
var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta));
|
var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta));
|
||||||
root.position = newPos;
|
root.position = newPos;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import QtQuick
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
CustomClippingRect {
|
||||||
|
id: track
|
||||||
|
|
||||||
|
required property var scrollBar
|
||||||
|
required property var mouseArea
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
implicitHeight: handle.implicitHeight
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: handle
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
color: Colors.palette.m3secondary
|
||||||
|
implicitWidth: track.scrollBar.width * track.scrollBar.effectiveSize
|
||||||
|
implicitHeight: track.mouseArea.pressed || track.mouseArea.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall
|
||||||
|
opacity: {
|
||||||
|
if (!track.scrollBar.enabled)
|
||||||
|
return 0;
|
||||||
|
if (track.scrollBar.size === 1)
|
||||||
|
return 0;
|
||||||
|
if (track.mouseArea.pressed)
|
||||||
|
return 1;
|
||||||
|
if (track.mouseArea.containsMouse)
|
||||||
|
return 0.8;
|
||||||
|
if (track.scrollBar.policy === CustomScrollBar.AlwaysOn || track.scrollBar.shouldBeActive)
|
||||||
|
return 0.6;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
x: track.scrollBar.reversed ? track.scrollBar.width * (1 + track.scrollBar.nonAnimY) * track.scrollBar.travelScale : track.scrollBar.width * track.scrollBar.nonAnimY * track.scrollBar.travelScale
|
||||||
|
|
||||||
|
Behavior on implicitHeight {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
hoverEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Templates
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
TextArea {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
color: Colors.palette.m3onSurface
|
||||||
|
cursorVisible: !readOnly
|
||||||
|
font.pointSize: Tokens.font.size.small
|
||||||
|
implicitHeight: contentHeight + topPadding + bottomPadding
|
||||||
|
implicitWidth: contentWidth + leftPadding + rightPadding
|
||||||
|
placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom
|
||||||
|
renderType: TextArea.NativeRendering
|
||||||
|
selectedTextColor: color
|
||||||
|
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
CAnim {}
|
||||||
|
}
|
||||||
|
Behavior on selectionColor {
|
||||||
|
CAnim {}
|
||||||
|
}
|
||||||
|
cursorDelegate: Item {}
|
||||||
|
|
||||||
|
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 x {
|
||||||
|
Anim {
|
||||||
|
duration: Tokens.anim.durations.expressiveFastEffects
|
||||||
|
easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.StandardSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ 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
|
||||||
|
|
||||||
@@ -28,11 +29,11 @@ CustomListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function marginEnd(): real {
|
function marginEnd(): real {
|
||||||
return horizontal ? rightMargin : bottomMargin;
|
return horizontal ? rightMargin - fadeThreshold : bottomMargin - fadeThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
function marginStart(): real {
|
function marginStart(): real {
|
||||||
return horizontal ? leftMargin : topMargin;
|
return horizontal ? leftMargin - fadeThreshold : topMargin - fadeThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
function overshootStart(): real {
|
function overshootStart(): real {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import QtQuick
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
CustomClippingRect {
|
||||||
|
id: track
|
||||||
|
|
||||||
|
required property var scrollBar
|
||||||
|
required property var mouseArea
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
implicitWidth: handle.implicitWidth
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: handle
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
color: Colors.palette.m3secondary
|
||||||
|
implicitHeight: track.scrollBar.height * track.scrollBar.effectiveSize
|
||||||
|
implicitWidth: track.mouseArea.pressed || track.mouseArea.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall
|
||||||
|
opacity: {
|
||||||
|
if (!track.scrollBar.enabled)
|
||||||
|
return 0;
|
||||||
|
if (track.scrollBar.size === 1)
|
||||||
|
return 0;
|
||||||
|
if (track.mouseArea.pressed)
|
||||||
|
return 1;
|
||||||
|
if (track.mouseArea.containsMouse)
|
||||||
|
return 0.8;
|
||||||
|
if (track.scrollBar.policy === CustomScrollBar.AlwaysOn || track.scrollBar.shouldBeActive)
|
||||||
|
return 0.6;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
y: track.scrollBar.reversed ? track.scrollBar.height * (1 + track.scrollBar.nonAnimY) * track.scrollBar.travelScale : track.scrollBar.height * track.scrollBar.nonAnimY * track.scrollBar.travelScale
|
||||||
|
|
||||||
|
Behavior on implicitWidth {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
hoverEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,7 +140,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-6
@@ -63,12 +63,10 @@ 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: {
|
||||||
@@ -293,8 +291,7 @@ 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 {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-16
@@ -126,33 +126,29 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,8 +348,7 @@ Item {
|
|||||||
y: selectionRect.y - root.realBorderWidth
|
y: selectionRect.y - root.realBorderWidth
|
||||||
|
|
||||||
Behavior on border.color {
|
Behavior on border.color {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +397,5 @@ Item {
|
|||||||
onUndoRequested: annotations.undo()
|
onUndoRequested: annotations.undo()
|
||||||
}
|
}
|
||||||
|
|
||||||
component ExAnim: Anim {
|
component ExAnim: Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ WidgetBase {
|
|||||||
}
|
}
|
||||||
required property GridLayout loader
|
required property GridLayout loader
|
||||||
required property Wrapper popouts
|
required property Wrapper popouts
|
||||||
readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.small * 2 : verticalColumn.implicitHeight + Tokens.padding.small * 2
|
readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2
|
||||||
required property PersistentProperties visibilities
|
required property PersistentProperties visibilities
|
||||||
|
|
||||||
color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer
|
color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer
|
||||||
@@ -57,14 +57,13 @@ WidgetBase {
|
|||||||
|
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
color: root.contentColor
|
color: root.contentColor
|
||||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||||
height: implicitHeight
|
height: implicitHeight
|
||||||
text: Time.dateStr
|
text: Time.dateStr
|
||||||
visible: root.horizontal
|
visible: root.horizontal
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +83,11 @@ WidgetBase {
|
|||||||
|
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
color: root.contentColor
|
color: root.contentColor
|
||||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||||
text: Qt.formatDateTime(Time.date, modelData)
|
text: Qt.formatDateTime(Time.date, modelData)
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +112,7 @@ WidgetBase {
|
|||||||
|
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
color: root.contentColor
|
color: root.contentColor
|
||||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||||
text: {
|
text: {
|
||||||
if (modelData.includes("h"))
|
if (modelData.includes("h"))
|
||||||
return Time.hourStr;
|
return Time.hourStr;
|
||||||
@@ -126,8 +124,7 @@ WidgetBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,13 +132,12 @@ WidgetBase {
|
|||||||
CustomText {
|
CustomText {
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
color: root.contentColor
|
color: root.contentColor
|
||||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||||
text: Qt.formatDateTime(Time.date, "AP")
|
text: Qt.formatDateTime(Time.date, "AP")
|
||||||
visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0
|
visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
Keys.onPressed: e => {
|
||||||
|
if (e.key == Qt.Key_Return) {
|
||||||
|
if (!(e.modifiers & Qt.ShiftModifier)) {
|
||||||
|
root.send(text);
|
||||||
|
e.accepted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSendPressed: root.send(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import QtQuick
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
TextAreaBase {
|
||||||
|
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: TextAreaBase.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Shapes
|
||||||
|
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 codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
|
||||||
|
property color codeHeaderColor: Colors.palette.m3outline
|
||||||
|
|
||||||
|
// Highlighter spans for the current code; refreshed when the code or
|
||||||
|
// its language changes. Highlighting runs off the GUI thread; the
|
||||||
|
// token drops results that arrive after the code already changed.
|
||||||
|
property var codeSpans: []
|
||||||
|
property int highlightToken: 0
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
const token = ++root.highlightToken;
|
||||||
|
codeSpans = [];
|
||||||
|
CodeHighlighter.highlight(root.code, root.language, root, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHighlightSpans(token, spans) {
|
||||||
|
if (token !== root.highlightToken)
|
||||||
|
return;
|
||||||
|
codeSpans = spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleColor(kind) {
|
||||||
|
var s = CodeColors.active;
|
||||||
|
switch (kind) {
|
||||||
|
case "comment":
|
||||||
|
return s.comment;
|
||||||
|
case "string":
|
||||||
|
return s.string;
|
||||||
|
case "string.key":
|
||||||
|
return s.stringKey;
|
||||||
|
case "number":
|
||||||
|
case "constant":
|
||||||
|
return s.number;
|
||||||
|
case "keyword":
|
||||||
|
return s.keyword;
|
||||||
|
case "type":
|
||||||
|
return s.type;
|
||||||
|
case "function":
|
||||||
|
return s.functions;
|
||||||
|
case "method":
|
||||||
|
return s.method ?? s.functions;
|
||||||
|
case "macro":
|
||||||
|
return s.macro;
|
||||||
|
case "preproc":
|
||||||
|
return s.preproc ?? s.macro;
|
||||||
|
case "operator":
|
||||||
|
return s.operator ?? s.normal;
|
||||||
|
case "property":
|
||||||
|
return s.property ?? s.normal;
|
||||||
|
case "label":
|
||||||
|
return s.label;
|
||||||
|
case "attribute":
|
||||||
|
return s.attribute ?? s.label;
|
||||||
|
default:
|
||||||
|
return s.normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text): string {
|
||||||
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightedHtml(code, spans): string {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
return out.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, " ")).replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin
|
||||||
|
implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeRect.implicitHeight + codeRect.anchors.margins + codeRect.anchors.topMargin
|
||||||
|
color: root.codeBackgroundColor
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
|
onLanguageChanged: refresh()
|
||||||
|
onCodeChanged: refresh()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: iconItem
|
||||||
|
|
||||||
|
readonly property string iconPath: CodeIcons.path(root.language)
|
||||||
|
|
||||||
|
Layout.fillHeight: true
|
||||||
|
implicitWidth: height
|
||||||
|
|
||||||
|
Shape {
|
||||||
|
id: shape
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: iconItem.iconPath !== ""
|
||||||
|
preferredRendererType: Shape.CurveRenderer
|
||||||
|
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
|
||||||
|
|
||||||
|
ShapePath {
|
||||||
|
strokeColor: "transparent"
|
||||||
|
fillColor: Colors.palette.m3tertiary
|
||||||
|
fillRule: ShapePath.WindingFill
|
||||||
|
|
||||||
|
PathSvg {
|
||||||
|
path: iconItem.iconPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Shape {
|
||||||
|
id: fallbackShape
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
anchors.verticalCenterOffset: -1
|
||||||
|
visible: iconItem.iconPath === ""
|
||||||
|
preferredRendererType: Shape.CurveRenderer
|
||||||
|
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
|
||||||
|
|
||||||
|
ShapePath {
|
||||||
|
strokeColor: Colors.palette.m3tertiary
|
||||||
|
strokeWidth: 16
|
||||||
|
capStyle: ShapePath.RoundCap
|
||||||
|
joinStyle: ShapePath.RoundJoin
|
||||||
|
fillColor: "transparent"
|
||||||
|
|
||||||
|
PathSvg {
|
||||||
|
path: "M 40 64 L 112 128 L 40 192"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShapePath {
|
||||||
|
strokeColor: Colors.palette.m3tertiary
|
||||||
|
strokeWidth: 16
|
||||||
|
capStyle: ShapePath.RoundCap
|
||||||
|
fillColor: "transparent"
|
||||||
|
|
||||||
|
PathSvg {
|
||||||
|
path: "M 120 192 L 216 192"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: langText
|
||||||
|
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
color: root.codeHeaderColor
|
||||||
|
font.pointSize: Tokens.font.size.small
|
||||||
|
text: CodeIcons.name(root.language)
|
||||||
|
visible: root.language.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
icon: root.copied ? "check" : "content_copy"
|
||||||
|
inactiveColor: "transparent"
|
||||||
|
inactiveOnColor: root.codeHeaderColor
|
||||||
|
label.animate: true
|
||||||
|
type: IconButton.Text
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
Quickshell.clipboardText = root.code;
|
||||||
|
root.copied = true;
|
||||||
|
copyResetTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: copyResetTimer
|
||||||
|
|
||||||
|
interval: 1500
|
||||||
|
|
||||||
|
onTriggered: root.copied = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: codeRect
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: headerRow.bottom
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.topMargin: Tokens.padding.small
|
||||||
|
radius: root.radius - anchors.margins
|
||||||
|
anchors.margins: Tokens.padding.extraSmall
|
||||||
|
implicitWidth: codeText.implicitWidth + codeFlick.anchors.margins * 2
|
||||||
|
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
|
||||||
|
color: CodeColors.active.bg
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: codeFlick
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Tokens.padding.small
|
||||||
|
|
||||||
|
CustomScrollBar.horizontal: CustomScrollBar {
|
||||||
|
flickable: codeFlick
|
||||||
|
}
|
||||||
|
TextAreaBase.flickable: TextAreaBase {
|
||||||
|
id: codeText
|
||||||
|
|
||||||
|
color: CodeColors.active.normal
|
||||||
|
font.family: Config.appearance.font.family.mono
|
||||||
|
font.pointSize: Tokens.font.size.small
|
||||||
|
textFormat: Text.RichText
|
||||||
|
text: root.highlightedHtml(root.code, root.codeSpans)
|
||||||
|
readOnly: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import ZShell.Config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property JsonObject schemes: JsonObject {
|
||||||
|
readonly property Scheme oneDark: Scheme {
|
||||||
|
comment: "#5c6370"
|
||||||
|
string: "#98c379"
|
||||||
|
stringKey: "#e06c75"
|
||||||
|
number: "#d19a66"
|
||||||
|
keyword: "#c678dd"
|
||||||
|
type: "#e5c07b"
|
||||||
|
functions: "#61afef"
|
||||||
|
method: "#61afef"
|
||||||
|
macro: "#98c379"
|
||||||
|
preproc: "#abb2bf"
|
||||||
|
operator: "#abb2bf"
|
||||||
|
property: "#abb2bf"
|
||||||
|
label: "#e06c75"
|
||||||
|
attribute: "#d19a66"
|
||||||
|
bg: "#282c34"
|
||||||
|
normal: "#abb2bf"
|
||||||
|
}
|
||||||
|
readonly property Scheme nord: Scheme {
|
||||||
|
comment: "#4c566a"
|
||||||
|
string: "#a3be8c"
|
||||||
|
stringKey: "#ebcb8b"
|
||||||
|
number: "#b48ead"
|
||||||
|
keyword: "#81a1c1"
|
||||||
|
type: "#8fbcbb"
|
||||||
|
functions: "#88c0d0"
|
||||||
|
method: "#88c0d0"
|
||||||
|
macro: "#5e81ac"
|
||||||
|
preproc: "#5e81ac"
|
||||||
|
operator: "#81a1c1"
|
||||||
|
property: "#d8dee9"
|
||||||
|
label: "#d08770"
|
||||||
|
attribute: "#d8dee9"
|
||||||
|
bg: "#2e3440"
|
||||||
|
normal: "#d8dee9"
|
||||||
|
}
|
||||||
|
readonly property Scheme dracula: Scheme {
|
||||||
|
comment: "#6272a4"
|
||||||
|
string: "#f1fa8c"
|
||||||
|
stringKey: "#f8f8f2"
|
||||||
|
number: "#ffb86c"
|
||||||
|
keyword: "#ff79c6"
|
||||||
|
type: "#8be9fd"
|
||||||
|
functions: "#50fa7b"
|
||||||
|
method: "#50fa7b"
|
||||||
|
macro: "#ff79c6"
|
||||||
|
preproc: "#ff79c6"
|
||||||
|
operator: "#f8f8f2"
|
||||||
|
property: "#f8f8f2"
|
||||||
|
label: "#6272a4"
|
||||||
|
attribute: "#8be9fd"
|
||||||
|
bg: "#282a36"
|
||||||
|
normal: "#f8f8f2"
|
||||||
|
}
|
||||||
|
readonly property Scheme githubDark: Scheme {
|
||||||
|
comment: "#8b949e"
|
||||||
|
string: "#a5d6ff"
|
||||||
|
stringKey: "#79c0ff"
|
||||||
|
number: "#79c0ff"
|
||||||
|
keyword: "#ff7b72"
|
||||||
|
type: "#ffa657"
|
||||||
|
functions: "#d2a8ff"
|
||||||
|
method: "#d2a8ff"
|
||||||
|
macro: "#ff7b72"
|
||||||
|
preproc: "#79c0ff"
|
||||||
|
operator: "#ff7b72"
|
||||||
|
property: "#79c0ff"
|
||||||
|
label: "#7ee787"
|
||||||
|
attribute: "#7ee787"
|
||||||
|
bg: "#0d1117"
|
||||||
|
normal: "#c9d1d9"
|
||||||
|
}
|
||||||
|
readonly property Scheme solarizedDark: Scheme {
|
||||||
|
comment: "#586e75"
|
||||||
|
string: "#2aa198"
|
||||||
|
stringKey: "#268bd2"
|
||||||
|
number: "#2aa198"
|
||||||
|
keyword: "#859900"
|
||||||
|
type: "#b58900"
|
||||||
|
functions: "#268bd2"
|
||||||
|
method: "#268bd2"
|
||||||
|
macro: "#cb4b16"
|
||||||
|
preproc: "#cb4b16"
|
||||||
|
operator: "#859900"
|
||||||
|
property: "#268bd2"
|
||||||
|
label: "#6c71c4"
|
||||||
|
attribute: "#657b83"
|
||||||
|
bg: "#002b36"
|
||||||
|
normal: "#839496"
|
||||||
|
}
|
||||||
|
readonly property Scheme monokai: Scheme {
|
||||||
|
comment: "#75715e"
|
||||||
|
string: "#e6db74"
|
||||||
|
stringKey: "#f8f8f2"
|
||||||
|
number: "#ae81ff"
|
||||||
|
keyword: "#f92672"
|
||||||
|
type: "#a6e22e"
|
||||||
|
functions: "#a6e22e"
|
||||||
|
method: "#a6e22e"
|
||||||
|
macro: "#a6e22e"
|
||||||
|
preproc: "#f92672"
|
||||||
|
operator: "#f92672"
|
||||||
|
property: "#fda5ff"
|
||||||
|
label: "#f92672"
|
||||||
|
attribute: "#a6e22e"
|
||||||
|
bg: "#272822"
|
||||||
|
normal: "#f8f8f2"
|
||||||
|
}
|
||||||
|
readonly property Scheme gruvboxDark: Scheme {
|
||||||
|
comment: "#928374"
|
||||||
|
string: "#b8bb26"
|
||||||
|
stringKey: "#ebdbb2"
|
||||||
|
number: "#d3869b"
|
||||||
|
keyword: "#fb4934"
|
||||||
|
type: "#fabd2f"
|
||||||
|
functions: "#b8bb26"
|
||||||
|
method: "#b8bb26"
|
||||||
|
macro: "#8ec07c"
|
||||||
|
preproc: "#8ec07c"
|
||||||
|
operator: "#ebdbb2"
|
||||||
|
property: "#83a598"
|
||||||
|
label: "#fb4934"
|
||||||
|
attribute: "#8ec07c"
|
||||||
|
bg: "#1d2021"
|
||||||
|
normal: "#ebdbb2"
|
||||||
|
}
|
||||||
|
readonly property Scheme catppuccinMocha: Scheme {
|
||||||
|
comment: "#9399b2"
|
||||||
|
string: "#a6e3a1"
|
||||||
|
stringKey: "#b4befe"
|
||||||
|
number: "#fab387"
|
||||||
|
keyword: "#cba6f7"
|
||||||
|
type: "#f9e2af"
|
||||||
|
functions: "#89b4fa"
|
||||||
|
method: "#89b4fa"
|
||||||
|
macro: "#cba6f7"
|
||||||
|
preproc: "#f5c2e7"
|
||||||
|
operator: "#89dceb"
|
||||||
|
property: "#b4befe"
|
||||||
|
label: "#74c7ec"
|
||||||
|
attribute: "#f9e2af"
|
||||||
|
bg: "#1e1e2e"
|
||||||
|
normal: "#cdd6f4"
|
||||||
|
}
|
||||||
|
readonly property Scheme tokyoNight: Scheme {
|
||||||
|
comment: "#565f89"
|
||||||
|
string: "#9ece6a"
|
||||||
|
stringKey: "#73daca"
|
||||||
|
number: "#ff9e64"
|
||||||
|
keyword: "#9d7cd8"
|
||||||
|
type: "#2ac3de"
|
||||||
|
functions: "#7aa2f7"
|
||||||
|
method: "#7aa2f7"
|
||||||
|
macro: "#7dcfff"
|
||||||
|
preproc: "#7dcfff"
|
||||||
|
operator: "#89ddff"
|
||||||
|
property: "#73daca"
|
||||||
|
label: "#7aa2f7"
|
||||||
|
attribute: "#73daca"
|
||||||
|
bg: "#1a1b26"
|
||||||
|
normal: "#c0caf5"
|
||||||
|
}
|
||||||
|
readonly property Scheme ayuDark: Scheme {
|
||||||
|
comment: "#5a6673"
|
||||||
|
string: "#aad94c"
|
||||||
|
stringKey: "#aad94c"
|
||||||
|
number: "#d2a6ff"
|
||||||
|
keyword: "#ff8f40"
|
||||||
|
type: "#59c2ff"
|
||||||
|
functions: "#ffb454"
|
||||||
|
method: "#ffb454"
|
||||||
|
macro: "#59c2ff"
|
||||||
|
preproc: "#ff8f40"
|
||||||
|
operator: "#f29668"
|
||||||
|
property: "#f07178"
|
||||||
|
label: "#59c2ff"
|
||||||
|
attribute: "#ffb454"
|
||||||
|
bg: "#0a0e14"
|
||||||
|
normal: "#bfbdb6"
|
||||||
|
}
|
||||||
|
readonly property Scheme palenight: Scheme {
|
||||||
|
comment: "#697098"
|
||||||
|
string: "#c3e88d"
|
||||||
|
stringKey: "#82b1ff"
|
||||||
|
number: "#f78c6c"
|
||||||
|
keyword: "#ff5370"
|
||||||
|
type: "#ffcb6b"
|
||||||
|
functions: "#82b1ff"
|
||||||
|
method: "#82b1ff"
|
||||||
|
macro: "#c792ea"
|
||||||
|
preproc: "#ffcb6b"
|
||||||
|
operator: "#89ddff"
|
||||||
|
property: "#c3e88d"
|
||||||
|
label: "#c792ea"
|
||||||
|
attribute: "#ffcb6b"
|
||||||
|
bg: "#292d3e"
|
||||||
|
normal: "#bfc7d5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readonly property var active: schemes[Config.llm.appearance.scheme]
|
||||||
|
|
||||||
|
component Scheme: JsonObject {
|
||||||
|
property color comment: "#697098"
|
||||||
|
property color string: "#c3e88d"
|
||||||
|
property color stringKey: "#82b1ff"
|
||||||
|
property color number: "#f78c6c"
|
||||||
|
property color keyword: "#ff5370"
|
||||||
|
property color type: "#ffcb6b"
|
||||||
|
property color functions: "#82b1ff"
|
||||||
|
property color method: "#82b1ff"
|
||||||
|
property color macro: "#c792ea"
|
||||||
|
property color preproc: "#ffcb6b"
|
||||||
|
property color operator: "#89ddff"
|
||||||
|
property color property: "#c3e88d"
|
||||||
|
property color label: "#c792ea"
|
||||||
|
property color attribute: "#ffcb6b"
|
||||||
|
property color bg: "#292d3e"
|
||||||
|
property color normal: "#bfc7d5"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,120 @@
|
|||||||
|
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 + blocks.anchors.topMargin * 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.topMargin: Tokens.padding.medium
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
spacing: Tokens.spacing.small
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
color: Colors.tPalette.m3outlineVariant
|
||||||
|
font.pointSize: 36
|
||||||
|
text: "chat"
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
color: Colors.tPalette.m3onSurfaceVariant
|
||||||
|
text: qsTr("No messages yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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.margins: Tokens.spacing.small
|
||||||
|
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
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DelegateChoice {
|
||||||
|
roleValue: LlmMarkdown.Type.Heading
|
||||||
|
|
||||||
|
delegate: CustomText {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
color: Colors.palette.m3onSurface
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
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
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
text: modelData.text
|
||||||
|
textFormat: Text.MarkdownText
|
||||||
|
font.pointSize: Tokens.font.size.smaller
|
||||||
|
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import qs.Components
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
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 {
|
||||||
@@ -10,21 +14,94 @@ 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
|
||||||
|
|
||||||
CustomRect {
|
Tabs {
|
||||||
|
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
|
||||||
|
|
||||||
NotifDock {
|
Item {
|
||||||
props: root.props
|
id: pages
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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"
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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,13 +12,11 @@ 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,14 +1,12 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -21,8 +19,7 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
IdleInhibit {
|
IdleInhibit {}
|
||||||
}
|
|
||||||
|
|
||||||
Record {
|
Record {
|
||||||
props: root.props
|
props: root.props
|
||||||
@@ -32,7 +29,6 @@ Item {
|
|||||||
|
|
||||||
Toggles {
|
Toggles {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
popouts: root.popouts
|
|
||||||
visibilities: root.visibilities
|
visibilities: root.visibilities
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ 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
|
||||||
@@ -18,12 +16,12 @@ Item {
|
|||||||
|
|
||||||
reloadableId: "utilities"
|
reloadableId: "utilities"
|
||||||
}
|
}
|
||||||
readonly property bool shouldBeActive: visibilities.sidebar
|
readonly property bool shouldBeActive: visibilities.sidebar && !sidebar.chatActive
|
||||||
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 + 8 * 2
|
implicitHeight: content.implicitHeight + Tokens.padding.small * 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
|
||||||
@@ -45,7 +43,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
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 {
|
||||||
@@ -12,6 +16,7 @@ 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
|
||||||
@@ -26,6 +31,17 @@ 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,11 +17,14 @@ Item {
|
|||||||
property int horizontalContentMargin
|
property int horizontalContentMargin
|
||||||
required property string icon
|
required property string icon
|
||||||
required property string label
|
required property string label
|
||||||
|
property string subtext: ""
|
||||||
property bool open
|
property bool open
|
||||||
property real openHeight: Math.min(rootParent.height * 0.8, 600)
|
property real openHeight: Math.min(rootParent.height * 0.8, 600)
|
||||||
property real openWidth: Math.min(rootParent.width * 0.8, 400)
|
property real openWidth: Math.min(rootParent.width * 0.8, 400)
|
||||||
required property Item rootParent
|
required property Item rootParent
|
||||||
property bool separateContent
|
property bool separateContent
|
||||||
|
property bool first: false
|
||||||
|
property bool last: false
|
||||||
|
|
||||||
signal accepted
|
signal accepted
|
||||||
signal cancelled
|
signal cancelled
|
||||||
@@ -44,8 +47,7 @@ Item {
|
|||||||
color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer
|
color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,12 +133,13 @@ Item {
|
|||||||
id: dialogBg
|
id: dialogBg
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
bottomLeftRadius: Tokens.rounding.largeIncreased
|
bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||||
bottomRightRadius: Tokens.rounding.largeIncreased
|
bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||||
|
topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||||
|
topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||||
deformScale: 0
|
deformScale: 0
|
||||||
group: blobGroup
|
group: blobGroup
|
||||||
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
|
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
|
||||||
radius: Tokens.rounding.extraSmall
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RowButton {
|
RowButton {
|
||||||
@@ -145,9 +148,11 @@ Item {
|
|||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
height: Math.min(implicitHeight, parent.height) // Clamp to parent height due to overshoot anim
|
height: Math.min(implicitHeight, parent.height)
|
||||||
icon: root.icon
|
icon: root.icon
|
||||||
last: true
|
subtext: root.subtext
|
||||||
|
last: root.last
|
||||||
|
first: root.first
|
||||||
text: root.label
|
text: root.label
|
||||||
|
|
||||||
transform: Matrix4x4 {
|
transform: Matrix4x4 {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import qs.Modules.Settings.Pages.Audio
|
|||||||
import qs.Modules.Settings.Pages.Apps
|
import qs.Modules.Settings.Pages.Apps
|
||||||
import qs.Modules.Settings.Pages.Panels
|
import qs.Modules.Settings.Pages.Panels
|
||||||
import qs.Modules.Settings.Pages.Panels.Bar
|
import qs.Modules.Settings.Pages.Panels.Bar
|
||||||
|
import qs.Modules.Settings.Pages.Panels.Sidebar
|
||||||
import qs.Modules.Settings.Pages.Services
|
import qs.Modules.Settings.Pages.Services
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
|
||||||
@@ -23,13 +24,11 @@ QtObject {
|
|||||||
// Wallpaper & style
|
// Wallpaper & style
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
WallpaperPage {
|
WallpaperPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
WallpaperSelect {
|
WallpaperSelect {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -37,32 +36,27 @@ QtObject {
|
|||||||
// Screenshot
|
// Screenshot
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
ScreenshotPage {
|
ScreenshotPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Connectivity
|
// Connectivity
|
||||||
Component {
|
Component {
|
||||||
PlaceholderComp {
|
PlaceholderComp {}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Component {
|
Component {
|
||||||
PlaceholderComp {
|
PlaceholderComp {}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Component {
|
Component {
|
||||||
// Audio
|
// Audio
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
AudioPage {
|
AudioPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
AppVolumes {
|
AppVolumes {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -71,49 +65,45 @@ QtObject {
|
|||||||
Component {
|
Component {
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
PanelsPage {
|
PanelsPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
BarPanel {
|
BarPanel {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
DashboardPanel {
|
DashboardPanel {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
ResourcesPanel {
|
ResourcesPanel {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
LauncherPanel {
|
LauncherPanel {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
SidebarPanel {
|
SidebarPanel {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bar sub pages
|
// Bar sub pages
|
||||||
Component {
|
Component {
|
||||||
BarTray {
|
BarTray {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
BarStatusIcons {
|
BarStatusIcons {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
BarClock {
|
BarClock {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar sub pages
|
||||||
|
Component {
|
||||||
|
SidebarLlm {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -122,18 +112,15 @@ QtObject {
|
|||||||
Component {
|
Component {
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
AppsPage {
|
AppsPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
AllApps {
|
AllApps {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
AppInfo {
|
AppInfo {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -142,13 +129,11 @@ QtObject {
|
|||||||
Component {
|
Component {
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
ServicesPage {
|
ServicesPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
NotificationsPage {
|
NotificationsPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -157,15 +142,13 @@ QtObject {
|
|||||||
Component {
|
Component {
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
AboutPage {
|
AboutPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
readonly property Component placeholderComp: Component {
|
readonly property Component placeholderComp: Component {
|
||||||
PlaceholderComp {
|
PlaceholderComp {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
component PlaceholderComp: Item {
|
component PlaceholderComp: Item {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ PageBase {
|
|||||||
enabled: Object.keys(model).length > 0
|
enabled: Object.keys(model).length > 0
|
||||||
header: qsTr("Add new entry")
|
header: qsTr("Add new entry")
|
||||||
icon: "add"
|
icon: "add"
|
||||||
|
last: true
|
||||||
label: qsTr("Add entry")
|
label: qsTr("Add entry")
|
||||||
model: {
|
model: {
|
||||||
const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id));
|
const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id));
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Components
|
||||||
|
import qs.Modules.Settings.Common
|
||||||
|
|
||||||
|
PageBase {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property var schemes: [
|
||||||
|
{
|
||||||
|
id: "oneDark",
|
||||||
|
label: qsTr("One Dark")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nord",
|
||||||
|
label: qsTr("Nord")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dracula",
|
||||||
|
label: qsTr("Dracula")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "githubDark",
|
||||||
|
label: qsTr("Github Dark")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "solarizedDark",
|
||||||
|
label: qsTr("Solarized Dark")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "monokai",
|
||||||
|
label: qsTr("Monokai")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gruvboxDark",
|
||||||
|
label: qsTr("Gruvbox Dark")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "catppuccinMocha",
|
||||||
|
label: qsTr("Catppuccin Mocha")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tokyoNight",
|
||||||
|
label: qsTr("Tokyo Night")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ayuDark",
|
||||||
|
label: qsTr("Ayu Dark")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "palenight",
|
||||||
|
label: qsTr("Pale Night")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
isSubPage: true
|
||||||
|
title: qsTr("Sidebar")
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.top: parent.top
|
||||||
|
spacing: Tokens.spacing.extraSmall / 2
|
||||||
|
width: root.cappedWidth
|
||||||
|
|
||||||
|
SectionHeader {
|
||||||
|
first: true
|
||||||
|
text: qsTr("Appearance")
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogSelectButton {
|
||||||
|
id: selectScheme
|
||||||
|
|
||||||
|
acceptLabel: qsTr("Confirm")
|
||||||
|
enabled: Object.keys(model).length > 0
|
||||||
|
header: qsTr("Select scheme")
|
||||||
|
first: true
|
||||||
|
last: true
|
||||||
|
icon: "add"
|
||||||
|
label: qsTr("Color scheme")
|
||||||
|
subtext: qsTr("Select the color scheme used for code blocks")
|
||||||
|
model: root.schemes
|
||||||
|
rootParent: root.flickable
|
||||||
|
|
||||||
|
onAccepted: {
|
||||||
|
if (!selectedItem)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Config.llm.appearance.scheme = selectedItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ PageBase {
|
|||||||
header: qsTr("Add new entry")
|
header: qsTr("Add new entry")
|
||||||
icon: "add"
|
icon: "add"
|
||||||
label: qsTr("Add entry")
|
label: qsTr("Add entry")
|
||||||
|
last: true
|
||||||
model: {
|
model: {
|
||||||
const present = new Set(Config.utilities.quickToggles.values.map(item => item.id));
|
const present = new Set(Config.utilities.quickToggles.values.map(item => item.id));
|
||||||
return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({
|
return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({
|
||||||
@@ -108,5 +109,18 @@ PageBase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SectionHeader {
|
||||||
|
text: qsTr("AI chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
NavRow {
|
||||||
|
text: qsTr("AI chat")
|
||||||
|
icon: "robot_2"
|
||||||
|
first: true
|
||||||
|
last: true
|
||||||
|
|
||||||
|
onClicked: root.sState.openSubPage(9)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,3 +79,4 @@ add_subdirectory(Services)
|
|||||||
add_subdirectory(Components)
|
add_subdirectory(Components)
|
||||||
add_subdirectory(Blobs)
|
add_subdirectory(Blobs)
|
||||||
add_subdirectory(Config)
|
add_subdirectory(Config)
|
||||||
|
add_subdirectory(Llm)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#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"
|
||||||
@@ -33,6 +34,8 @@
|
|||||||
|
|
||||||
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))
|
||||||
@@ -44,6 +47,7 @@ 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))
|
||||||
@@ -51,6 +55,7 @@ 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);
|
||||||
@@ -81,8 +86,14 @@ 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*) {
|
||||||
return new Config();
|
if (!s_instance)
|
||||||
|
s_instance = new Config();
|
||||||
|
return s_instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString Config::filePath() const {
|
QString Config::filePath() const {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ 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;
|
||||||
@@ -46,6 +47,7 @@ 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")
|
||||||
@@ -63,6 +65,7 @@ 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)
|
||||||
@@ -74,6 +77,7 @@ 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();
|
||||||
@@ -103,6 +107,8 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "configobject.hpp"
|
||||||
|
#include <qhashfunctions.h>
|
||||||
|
#include <qobject.h>
|
||||||
|
#include <qqmlintegration.h>
|
||||||
|
|
||||||
|
namespace ZShell::config {
|
||||||
|
|
||||||
|
class LlmAppearance : public ConfigObject {
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ANONYMOUS
|
||||||
|
|
||||||
|
CFG_PROPERTY(QString, scheme, QStringLiteral("tokyoNight"))
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit LlmAppearance(QObject* parent = nullptr) : ConfigObject(parent) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
||||||
|
CFG_PROPERTY(bool, tools, true)
|
||||||
|
CONFIG_SUBOBJECT(LlmAppearance, appearance)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit Llm(QObject* parent = nullptr)
|
||||||
|
: ConfigObject(parent), m_appearance(new LlmAppearance(this)) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::config
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# 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}")
|
||||||
|
|
||||||
|
# --- tree-sitter grammar discovery (configure time) ---
|
||||||
|
#
|
||||||
|
# Discover installed tree-sitter grammars — system packages
|
||||||
|
# (libtree-sitter-<lang>.so) and the parsers Neovim's nvim-treesitter
|
||||||
|
# installs (~/.local/share/nvim/site/parser/*.so) — and pair each with
|
||||||
|
# highlight queries. Query sources, in priority order:
|
||||||
|
# 1. vendored highlight-queries/*.scm (version-pinned; see the
|
||||||
|
# per-file source headers, MIT),
|
||||||
|
# 2. Neovim's own queries (version-matched to its parsers),
|
||||||
|
# 3. tree-sitter/highlighting from GitHub (cached in the build dir).
|
||||||
|
# The result is embedded as highlight-queries.hpp. Re-run cmake to pick
|
||||||
|
# up grammars installed later.
|
||||||
|
#
|
||||||
|
# Candidate entry points are read from the .so with `nm` rather than
|
||||||
|
# assumed to be tree_sitter_<id>; a missing entry point just makes that
|
||||||
|
# candidate fail at runtime.
|
||||||
|
function(_ts_entry_point out file)
|
||||||
|
# OUTPUT_VARIABLE + OUTPUT_QUIET loses the output on CMake 4, so
|
||||||
|
# capture through a temp file.
|
||||||
|
get_filename_component(_ts_nm_base "${file}" NAME)
|
||||||
|
set(_ts_nm_file "${CMAKE_CURRENT_BINARY_DIR}/ts-entry-${_ts_nm_base}")
|
||||||
|
execute_process(
|
||||||
|
COMMAND nm -D --defined-only "${file}"
|
||||||
|
RESULT_VARIABLE _ts_nm_rc
|
||||||
|
OUTPUT_FILE "${_ts_nm_file}"
|
||||||
|
ERROR_FILE "${_ts_nm_file}.err")
|
||||||
|
set(_ts_sym "tree_sitter_missing")
|
||||||
|
if(_ts_nm_rc EQUAL 0 AND EXISTS "${_ts_nm_file}")
|
||||||
|
file(READ "${_ts_nm_file}" _ts_nm_out)
|
||||||
|
file(REMOVE "${_ts_nm_file}" "${_ts_nm_file}.err")
|
||||||
|
# The library also exports the external scanner functions; the
|
||||||
|
# entry point is the one without the _external suffix.
|
||||||
|
string(REGEX MATCHALL " T tree_sitter_[A-Za-z0-9_]+" _ts_syms "${_ts_nm_out}")
|
||||||
|
foreach(_ts_s IN LISTS _ts_syms)
|
||||||
|
string(REPLACE " T " "" _ts_s "${_ts_s}")
|
||||||
|
if(NOT _ts_s MATCHES "_external")
|
||||||
|
set(_ts_sym "${_ts_s}")
|
||||||
|
break()
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
endif()
|
||||||
|
set(${out} "${_ts_sym}" PARENT_SCOPE)
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
# Append a query text to <files> (PARENT_SCOPE) unless it duplicates a
|
||||||
|
# hash in <hashes>. The text is written to the build tree right away:
|
||||||
|
# the s-expression `;` comments would split CMake list items and the
|
||||||
|
# query `(` `)` break bracket arguments, so query text only ever lives
|
||||||
|
# in variables and files, never in lists.
|
||||||
|
# Expand Neovim's "; inherits:" directives by appending the inherited
|
||||||
|
# query set's highlights file (recursively; a visited set prevents
|
||||||
|
# cycles). Several languages are stubs that inherit the real query
|
||||||
|
# (html inherits html_tags, qmljs inherits ecma, ...).
|
||||||
|
function(_ts_expand_inherits query_dir text outVar)
|
||||||
|
set(result "${text}")
|
||||||
|
set(_visited "")
|
||||||
|
set(_depth 0)
|
||||||
|
while(_depth LESS 8)
|
||||||
|
# Do not match the leading `;`: a MATCHALL result that itself
|
||||||
|
# contains a semicolon is re-split into list items.
|
||||||
|
string(REGEX MATCHALL "inherits:[ \t]*[A-Za-z0-9_]+" _inh "${result}")
|
||||||
|
if(NOT _inh)
|
||||||
|
break()
|
||||||
|
endif()
|
||||||
|
set(_added FALSE)
|
||||||
|
foreach(_entry IN LISTS _inh)
|
||||||
|
string(REGEX REPLACE "^inherits:[ \t]*" "" _name "${_entry}")
|
||||||
|
set(_file "${query_dir}/${_name}/highlights.scm")
|
||||||
|
if(NOT EXISTS "${_file}" OR _file IN_LIST _visited)
|
||||||
|
continue()
|
||||||
|
endif()
|
||||||
|
list(APPEND _visited "${_file}")
|
||||||
|
file(READ "${_file}" _inh_text)
|
||||||
|
string(APPEND result "\n${_inh_text}")
|
||||||
|
set(_added TRUE)
|
||||||
|
endforeach()
|
||||||
|
if(NOT _added)
|
||||||
|
break()
|
||||||
|
endif()
|
||||||
|
math(EXPR _depth "${_depth} + 1")
|
||||||
|
endwhile()
|
||||||
|
set(${outVar} "${result}" PARENT_SCOPE)
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
function(_ts_add_query sid text filesVar hashesVar)
|
||||||
|
# filesVar/hashesVar hold the caller's variable names.
|
||||||
|
set(files "${${filesVar}}")
|
||||||
|
set(hashes "${${hashesVar}}")
|
||||||
|
string(SHA256 _ts_qh "${text}")
|
||||||
|
if(_ts_qh IN_LIST hashes)
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
list(APPEND hashes "${_ts_qh}")
|
||||||
|
list(LENGTH files _ts_qi)
|
||||||
|
set(_ts_qfile "${CMAKE_CURRENT_BINARY_DIR}/ts-queries/${sid}_${_ts_qi}.scm")
|
||||||
|
file(WRITE "${_ts_qfile}" "${text}")
|
||||||
|
list(APPEND files "${_ts_qfile}")
|
||||||
|
set(${filesVar} "${files}" PARENT_SCOPE)
|
||||||
|
set(${hashesVar} "${hashes}" PARENT_SCOPE)
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
set(_ts_candidates "") # entries: <id>|<cmake-safe id>|<lib>|<symbol>
|
||||||
|
file(GLOB _ts_sys_files
|
||||||
|
"/usr/lib/libtree-sitter-*.so" "/usr/local/lib/libtree-sitter-*.so")
|
||||||
|
foreach(_ts_file IN LISTS _ts_sys_files)
|
||||||
|
get_filename_component(_ts_name "${_ts_file}" NAME)
|
||||||
|
string(REGEX REPLACE "^libtree-sitter-(.+)\.so$" "\\1" _ts_id "${_ts_name}")
|
||||||
|
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||||
|
_ts_entry_point(_ts_sym "${_ts_file}")
|
||||||
|
list(APPEND _ts_candidates
|
||||||
|
"${_ts_id}|${_ts_sid}|libtree-sitter-${_ts_id}.so|${_ts_sym}")
|
||||||
|
endforeach()
|
||||||
|
set(_ts_nvim_parser_dirs
|
||||||
|
"$ENV{HOME}/.local/share/nvim/site/parser"
|
||||||
|
"$ENV{HOME}/.local/share/nvim/runtime/parser"
|
||||||
|
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/parser")
|
||||||
|
set(_ts_nvim_query_dirs
|
||||||
|
"$ENV{HOME}/.local/share/nvim/site/queries"
|
||||||
|
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/runtime/queries")
|
||||||
|
foreach(_ts_dir IN LISTS _ts_nvim_parser_dirs)
|
||||||
|
file(GLOB _ts_dir_files "${_ts_dir}/*.so")
|
||||||
|
foreach(_ts_file IN LISTS _ts_dir_files)
|
||||||
|
get_filename_component(_ts_name "${_ts_file}" NAME)
|
||||||
|
string(REGEX REPLACE "\\.so$" "" _ts_id "${_ts_name}")
|
||||||
|
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||||
|
_ts_entry_point(_ts_sym "${_ts_file}")
|
||||||
|
list(APPEND _ts_candidates
|
||||||
|
"${_ts_id}|${_ts_sid}|${_ts_file}|${_ts_sym}")
|
||||||
|
endforeach()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
set(_ts_ids "")
|
||||||
|
foreach(_ts_c IN LISTS _ts_candidates)
|
||||||
|
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
|
||||||
|
list(GET _ts_parts 0 _ts_id)
|
||||||
|
if(NOT _ts_id IN_LIST _ts_ids)
|
||||||
|
list(APPEND _ts_ids "${_ts_id}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
list(SORT _ts_ids)
|
||||||
|
|
||||||
|
set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp")
|
||||||
|
set(_hl_header
|
||||||
|
"#pragma once\n\n// Generated by CMake. Discoverd tree-sitter grammars and their\n// highlight queries: vendored highlight-queries/*.scm (MIT), Neovim\n// nvim-treesitter queries, and tree-sitter/highlighting (MIT).\nnamespace ZShell::llm::hq {\nstruct Candidate { const char* lib; const char* symbol; };\nstruct Grammar { const char* id; int nCandidates; const Candidate* candidates; int nQueries; const char* const* queries; };\n")
|
||||||
|
set(_ts_grammar_rows "")
|
||||||
|
set(_ts_vendored
|
||||||
|
c cpp python javascript typescript tsx bash json rust go yaml toml sql)
|
||||||
|
foreach(_ts_id IN LISTS _ts_ids)
|
||||||
|
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||||
|
|
||||||
|
# Query candidates in priority order (deduped; identical texts are
|
||||||
|
# skipped, the nvim site and plugin copies are usually the same
|
||||||
|
# file).
|
||||||
|
set(_ts_qfiles "")
|
||||||
|
set(_ts_qhashes "")
|
||||||
|
if(_ts_id STREQUAL "cpp")
|
||||||
|
# The C++ grammar is a superset of C and its query only covers
|
||||||
|
# the C++ delta; base C coverage comes from the C query.
|
||||||
|
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/c.scm" _ts_qa)
|
||||||
|
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/cpp.scm" _ts_qb)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||||
|
elseif(_ts_id STREQUAL "typescript" OR _ts_id STREQUAL "tsx")
|
||||||
|
# The TS grammars reuse the JS node names; the JS query usually
|
||||||
|
# compiles against them and gives full coverage.
|
||||||
|
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/javascript.scm" _ts_qa)
|
||||||
|
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/typescript.scm" _ts_qb)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||||
|
elseif(_ts_id IN_LIST _ts_vendored)
|
||||||
|
file(READ
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_ts_id}.scm" _ts_q)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||||
|
endif()
|
||||||
|
foreach(_ts_qd IN LISTS _ts_nvim_query_dirs)
|
||||||
|
if(EXISTS "${_ts_qd}/${_ts_id}/highlights.scm")
|
||||||
|
file(READ "${_ts_qd}/${_ts_id}/highlights.scm" _ts_q)
|
||||||
|
_ts_expand_inherits("${_ts_qd}" "${_ts_q}" _ts_q)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
if(NOT _ts_qfiles)
|
||||||
|
# Last resort: fetch from the tree-sitter/highlighting repo.
|
||||||
|
# Version-skewed against locally installed grammars, so only
|
||||||
|
# used when nothing local exists. Cached; offline builds simply
|
||||||
|
# drop the language.
|
||||||
|
set(_ts_dl "${CMAKE_CURRENT_BINARY_DIR}/ts-query-downloads/${_ts_sid}.scm")
|
||||||
|
if(NOT EXISTS "${_ts_dl}")
|
||||||
|
file(DOWNLOAD
|
||||||
|
"https://raw.githubusercontent.com/tree-sitter/highlighting/main/queries/${_ts_id}/highlight.scm"
|
||||||
|
"${_ts_dl}" STATUS _ts_dl_status TIMEOUT 30)
|
||||||
|
list(GET _ts_dl_status 0 _ts_dl_rc)
|
||||||
|
if(NOT _ts_dl_rc EQUAL 0)
|
||||||
|
file(REMOVE "${_ts_dl}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
if(EXISTS "${_ts_dl}")
|
||||||
|
file(READ "${_ts_dl}" _ts_q)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
list(LENGTH _ts_qfiles _ts_nq)
|
||||||
|
if(_ts_nq EQUAL 0)
|
||||||
|
continue()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Emit query sources.
|
||||||
|
set(_ts_qn 0)
|
||||||
|
set(_ts_q_ptrs "")
|
||||||
|
foreach(_ts_qfile IN LISTS _ts_qfiles)
|
||||||
|
file(READ "${_ts_qfile}" _ts_q)
|
||||||
|
string(APPEND _hl_header
|
||||||
|
"inline constexpr const char* q_${_ts_sid}_${_ts_qn} = R\"ZSQUERY(${_ts_q})ZSQUERY\";\n")
|
||||||
|
string(APPEND _ts_q_ptrs "q_${_ts_sid}_${_ts_qn}, ")
|
||||||
|
math(EXPR _ts_qn "${_ts_qn} + 1")
|
||||||
|
endforeach()
|
||||||
|
string(APPEND _hl_header
|
||||||
|
"inline constexpr const char* const q_${_ts_sid}[] = { ${_ts_q_ptrs} };\n")
|
||||||
|
|
||||||
|
# Emit library candidates (system package first, then Neovim).
|
||||||
|
string(APPEND _hl_header "inline constexpr Candidate cand_${_ts_sid}[] = {\n")
|
||||||
|
set(_ts_nc 0)
|
||||||
|
foreach(_ts_c IN LISTS _ts_candidates)
|
||||||
|
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
|
||||||
|
list(GET _ts_parts 0 _ts_cid)
|
||||||
|
if(_ts_cid STREQUAL _ts_id)
|
||||||
|
list(GET _ts_parts 2 _ts_lib)
|
||||||
|
list(GET _ts_parts 3 _ts_sym)
|
||||||
|
string(APPEND _hl_header
|
||||||
|
" { R\"ZSLIB(${_ts_lib})ZSLIB\", R\"ZSSYM(${_ts_sym})ZSSYM\" },\n")
|
||||||
|
math(EXPR _ts_nc "${_ts_nc} + 1")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
string(APPEND _hl_header "};\n")
|
||||||
|
if(_ts_nc EQUAL 0)
|
||||||
|
# Queries but no library: pointless, drop the language.
|
||||||
|
string(APPEND _hl_header "") # (arrays stay; grammar row is skipped)
|
||||||
|
continue()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
list(APPEND _ts_grammar_rows
|
||||||
|
"{ \"${_ts_id}\", ${_ts_nc}, cand_${_ts_sid}, ${_ts_nq}, q_${_ts_sid} },")
|
||||||
|
endforeach()
|
||||||
|
string(APPEND _hl_header "inline constexpr Grammar grammars[] = {\n")
|
||||||
|
foreach(_ts_row IN LISTS _ts_grammar_rows)
|
||||||
|
string(APPEND _hl_header " ${_ts_row}\n")
|
||||||
|
endforeach()
|
||||||
|
string(APPEND _hl_header "};\n}\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)
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
#include "chatstore.hpp"
|
||||||
|
|
||||||
|
#include "llmclient.hpp"
|
||||||
|
#include "message.hpp"
|
||||||
|
#include "segment.hpp"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QSqlDatabase>
|
||||||
|
#include <QSqlError>
|
||||||
|
#include <QSqlQuery>
|
||||||
|
#include <QThreadPool>
|
||||||
|
#include <QVector>
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain data for one session's messages, fetched on a worker thread
|
||||||
|
// and turned into the QObject tree on the GUI thread. Messages are
|
||||||
|
// ordered as the model displays them (newest first).
|
||||||
|
struct SegmentRow {
|
||||||
|
QString type;
|
||||||
|
QString text;
|
||||||
|
QString name;
|
||||||
|
QString toolCallId;
|
||||||
|
QString arguments;
|
||||||
|
QString result;
|
||||||
|
int status = 0;
|
||||||
|
qint64 elapsedMs = 0;
|
||||||
|
qint64 timestamp = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GenerationRow {
|
||||||
|
qint64 timestamp = 0;
|
||||||
|
bool active = false;
|
||||||
|
QVector<SegmentRow> segments;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MessageRow {
|
||||||
|
bool user = false;
|
||||||
|
qint64 timestamp = 0;
|
||||||
|
QVector<GenerationRow> generations;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // 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() {
|
||||||
|
m_dbPath =
|
||||||
|
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) +
|
||||||
|
QStringLiteral("/zshell/chats.sqlite");
|
||||||
|
QDir().mkpath(QFileInfo(m_dbPath).absolutePath());
|
||||||
|
|
||||||
|
QSqlDatabase db =
|
||||||
|
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
||||||
|
db.setDatabaseName(m_dbPath);
|
||||||
|
if (!db.open()) {
|
||||||
|
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
|
||||||
|
<< 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;
|
||||||
|
if (!session->isLoaded()) {
|
||||||
|
// Saving now would persist an incomplete model and wipe the
|
||||||
|
// stored history; run it again when the load lands.
|
||||||
|
m_pendingPersists.insert(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
|
||||||
|
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) {
|
||||||
|
if (!session)
|
||||||
|
return;
|
||||||
|
const QString sessionId = session->id();
|
||||||
|
const QString path = m_dbPath;
|
||||||
|
|
||||||
|
// SQL on a worker thread (its own connection; QSqlDatabase objects
|
||||||
|
// are thread-affine). Rows come back as plain data.
|
||||||
|
QThreadPool::globalInstance()->start(
|
||||||
|
[store = QPointer<ChatStore>(this),
|
||||||
|
session = QPointer<ChatSession>(session), sessionId, path]() {
|
||||||
|
QList<MessageRow> rows;
|
||||||
|
const QString connName = QUuid::createUuid().toString();
|
||||||
|
{
|
||||||
|
QSqlDatabase db = QSqlDatabase::addDatabase(
|
||||||
|
QStringLiteral("QSQLITE"), connName);
|
||||||
|
db.setDatabaseName(path);
|
||||||
|
if (db.open()) {
|
||||||
|
// Tolerate the GUI thread writing while we read.
|
||||||
|
QSqlQuery busy(db);
|
||||||
|
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
|
||||||
|
// 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", sessionId);
|
||||||
|
if (!query.exec()) {
|
||||||
|
qWarning()
|
||||||
|
<< "ChatStore: failed to load messages for"
|
||||||
|
<< sessionId << ":"
|
||||||
|
<< query.lastError().text();
|
||||||
|
} else {
|
||||||
|
while (query.next()) {
|
||||||
|
const int messageId = query.value(0).toInt();
|
||||||
|
MessageRow message;
|
||||||
|
message.user =
|
||||||
|
query.value(1).toString() ==
|
||||||
|
QLatin1String("user");
|
||||||
|
message.timestamp = 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);
|
||||||
|
if (generationQuery.exec()) {
|
||||||
|
while (generationQuery.next()) {
|
||||||
|
GenerationRow generation;
|
||||||
|
generation.timestamp =
|
||||||
|
generationQuery.value(1).toLongLong();
|
||||||
|
generation.active =
|
||||||
|
generationQuery.value(2).toInt() != 0;
|
||||||
|
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()) {
|
||||||
|
SegmentRow segment;
|
||||||
|
segment.type =
|
||||||
|
segmentQuery.value(0).toString();
|
||||||
|
segment.text =
|
||||||
|
segmentQuery.value(1).toString();
|
||||||
|
segment.name =
|
||||||
|
segmentQuery.value(2).toString();
|
||||||
|
segment.toolCallId =
|
||||||
|
segmentQuery.value(3).toString();
|
||||||
|
segment.arguments =
|
||||||
|
segmentQuery.value(4).toString();
|
||||||
|
segment.result =
|
||||||
|
segmentQuery.value(5).toString();
|
||||||
|
segment.status =
|
||||||
|
segmentQuery.value(6).toInt();
|
||||||
|
segment.elapsedMs =
|
||||||
|
segmentQuery.value(7).toLongLong();
|
||||||
|
segment.timestamp =
|
||||||
|
segmentQuery.value(8).toLongLong();
|
||||||
|
generation.segments.append(segment);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning()
|
||||||
|
<< "ChatStore: failed to load "
|
||||||
|
"segments for generation"
|
||||||
|
<< generationQuery.value(0).toInt()
|
||||||
|
<< ":"
|
||||||
|
<< segmentQuery.lastError().text();
|
||||||
|
}
|
||||||
|
message.generations.append(generation);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning()
|
||||||
|
<< "ChatStore: failed to load generations "
|
||||||
|
"for message"
|
||||||
|
<< messageId << ":"
|
||||||
|
<< generationQuery.lastError().text();
|
||||||
|
}
|
||||||
|
rows.append(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.close();
|
||||||
|
} else {
|
||||||
|
qWarning()
|
||||||
|
<< "ChatStore: failed to open database for load:"
|
||||||
|
<< db.lastError().text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove only once every QSqlDatabase copy and query is gone;
|
||||||
|
// while any reference is alive Qt refuses the removal and the
|
||||||
|
// connection is left dangling in a broken state.
|
||||||
|
QSqlDatabase::removeDatabase(connName);
|
||||||
|
|
||||||
|
// Build the object tree on the GUI thread. Deliver through
|
||||||
|
// the app instance (never destroyed) and re-check the
|
||||||
|
// pointers there: posting to `store` from the pool thread
|
||||||
|
// would race with its destruction.
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
QCoreApplication::instance(),
|
||||||
|
[store, session, rows = std::move(rows)]() mutable {
|
||||||
|
ChatStore* st = store;
|
||||||
|
ChatSession* s = session;
|
||||||
|
if (!st || !s)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Rows fetched from disk; newest first.
|
||||||
|
auto* model = s->model();
|
||||||
|
if (!model)
|
||||||
|
return;
|
||||||
|
QList<ChatMessage*> messages;
|
||||||
|
for (const MessageRow& row : rows) {
|
||||||
|
auto* message = model->createMessage(
|
||||||
|
row.user ? ChatMessage::Role::User
|
||||||
|
: ChatMessage::Role::Assistant,
|
||||||
|
row.timestamp);
|
||||||
|
int activeIndex = 0;
|
||||||
|
for (int i = 0; i < row.generations.size(); ++i) {
|
||||||
|
const GenerationRow& generationRow =
|
||||||
|
row.generations.at(i);
|
||||||
|
auto* generation =
|
||||||
|
message->addGeneration(generationRow.timestamp);
|
||||||
|
for (const SegmentRow& segmentRow :
|
||||||
|
generationRow.segments) {
|
||||||
|
auto* segment = new LlmSegment(
|
||||||
|
segmentTypeFromName(segmentRow.type),
|
||||||
|
segmentRow.timestamp,
|
||||||
|
generation);
|
||||||
|
segment->setText(segmentRow.text);
|
||||||
|
segment->setName(segmentRow.name);
|
||||||
|
segment->setToolCallId(segmentRow.toolCallId);
|
||||||
|
segment->appendArguments(segmentRow.arguments);
|
||||||
|
segment->setResult(segmentRow.result);
|
||||||
|
segment->setStatus(
|
||||||
|
static_cast<LlmSegment::Status>(
|
||||||
|
segmentRow.status));
|
||||||
|
segment->restore(segmentRow.elapsedMs);
|
||||||
|
generation->addSegment(segment);
|
||||||
|
}
|
||||||
|
if (generationRow.active)
|
||||||
|
activeIndex = i;
|
||||||
|
}
|
||||||
|
message->setActiveGeneration(activeIndex);
|
||||||
|
messages.append(message);
|
||||||
|
}
|
||||||
|
// Rows added live while the load was in flight are
|
||||||
|
// newer than anything on disk; keep them in front.
|
||||||
|
if (model->rowCount() > 0) {
|
||||||
|
QList<ChatMessage*> live = messages;
|
||||||
|
for (int r = 0; r < model->rowCount(); ++r)
|
||||||
|
live.prepend(model->at(r));
|
||||||
|
messages = live;
|
||||||
|
}
|
||||||
|
if (!messages.isEmpty() || model->rowCount() > 0)
|
||||||
|
s->adoptMessages(messages);
|
||||||
|
|
||||||
|
// Mark loaded only once the model holds both the
|
||||||
|
// fetched history and the rows added live while the
|
||||||
|
// load ran, so a deferred startGeneration (triggered
|
||||||
|
// by loaded()) builds its context from the complete
|
||||||
|
// conversation.
|
||||||
|
s->markLoaded();
|
||||||
|
|
||||||
|
if (s->takeClearPending()) {
|
||||||
|
// Cleared while the load was in flight; drop
|
||||||
|
// everything now that the model is populated.
|
||||||
|
s->clear();
|
||||||
|
} else if (st->m_pendingPersists.remove(s)) {
|
||||||
|
st->persist(s);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "session.hpp"
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QSet>
|
||||||
|
#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);
|
||||||
|
// Loads the session's messages from the database. The SQL runs on a
|
||||||
|
// worker thread; the object tree is built and the model updated on
|
||||||
|
// the GUI thread when it arrives (ChatSession::loaded).
|
||||||
|
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;
|
||||||
|
QString m_dbPath;
|
||||||
|
// Sessions whose persist() ran before their messages finished
|
||||||
|
// loading; persisted once the load lands.
|
||||||
|
QSet<ChatSession*> m_pendingPersists;
|
||||||
|
|
||||||
|
[[nodiscard]] QSqlDatabase db() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
#include "codehighlighter.hpp"
|
||||||
|
|
||||||
|
#include "highlight-queries.hpp"
|
||||||
|
|
||||||
|
#include <tree_sitter/api.h>
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QMap>
|
||||||
|
#include <QMutexLocker>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QThreadPool>
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#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* (*)();
|
||||||
|
|
||||||
|
// Grammar registry generated by CMake from the installed grammars and
|
||||||
|
// their highlight queries (see CMakeLists.txt).
|
||||||
|
const QHash<QString, CodeHighlighter::Grammar>& grammars() {
|
||||||
|
static const QHash<QString, CodeHighlighter::Grammar> grammars = [] {
|
||||||
|
QHash<QString, CodeHighlighter::Grammar> map;
|
||||||
|
for (const auto& g : hq::grammars) {
|
||||||
|
CodeHighlighter::Grammar grammar;
|
||||||
|
for (int i = 0; i < g.nCandidates; ++i) {
|
||||||
|
grammar.libs.push_back(g.candidates[i].lib);
|
||||||
|
grammar.symbols.push_back(g.candidates[i].symbol);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < g.nQueries; ++i)
|
||||||
|
grammar.queries.push_back(g.queries[i]);
|
||||||
|
map.insert(g.id, std::move(grammar));
|
||||||
|
}
|
||||||
|
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("qml", "qmljs");
|
||||||
|
map.insert("qmljs", "qmljs");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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") || n == "boolean" || n == "bool"
|
||||||
|
|| n.startsWith("character"))
|
||||||
|
return hl::Role::Constant;
|
||||||
|
if (n.startsWith("keyword"))
|
||||||
|
return hl::Role::Keyword;
|
||||||
|
if (n == "type" || n.startsWith("type."))
|
||||||
|
return hl::Role::Type;
|
||||||
|
if (n.startsWith("namespace") || n.startsWith("module")
|
||||||
|
|| n == "support.type" || n == "support.namespace")
|
||||||
|
return hl::Role::Type;
|
||||||
|
if (n.startsWith("function") || n == "constructor"
|
||||||
|
|| n.startsWith("support.function"))
|
||||||
|
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.")
|
||||||
|
|| n.startsWith("punctuation"))
|
||||||
|
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;
|
||||||
|
// HTML tag names, CSS variables and friends.
|
||||||
|
if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter"))
|
||||||
|
return hl::Role::Keyword;
|
||||||
|
if (n.startsWith("variable"))
|
||||||
|
return hl::Role::Constant;
|
||||||
|
if (n.startsWith("support"))
|
||||||
|
return hl::Role::Function;
|
||||||
|
return hl::Role::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* CodeHighlighter::roleName(uint8_t role) {
|
||||||
|
return hl::roleName(static_cast<hl::Role>(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
void CodeHighlighter::highlight(
|
||||||
|
const QString& code, const QString& language, QObject* target, int token) {
|
||||||
|
QThreadPool::globalInstance()->start([this, target, token, code, language]() {
|
||||||
|
const QVariantList spans = doHighlight(code, language);
|
||||||
|
// The target item may be long gone by now (delegates are
|
||||||
|
// recreated constantly while chats load); a destroyed target is
|
||||||
|
// simply skipped. Deliver through the app instance (never
|
||||||
|
// destroyed) and re-check there: posting to `target` from the
|
||||||
|
// pool thread would race with its destruction.
|
||||||
|
QPointer<QObject> guard(target);
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
QCoreApplication::instance(),
|
||||||
|
[guard, token, spans]() {
|
||||||
|
if (!guard)
|
||||||
|
return;
|
||||||
|
// QML functions are only invokable by their generic
|
||||||
|
// QVariant overload, so pass untyped arguments.
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
guard, "onHighlightSpans",
|
||||||
|
Q_ARG(QVariant, token), Q_ARG(QVariant, spans));
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantList CodeHighlighter::doHighlight(
|
||||||
|
const QString& code, const QString& language) const {
|
||||||
|
QVariantList spans;
|
||||||
|
if (code.isEmpty())
|
||||||
|
return spans;
|
||||||
|
|
||||||
|
const QString tag = language.trimmed().toLower();
|
||||||
|
const QString id = [&] {
|
||||||
|
const QString alias = aliases().value(tag);
|
||||||
|
return alias.isEmpty() ? tag : alias; // unknown tags = grammar id
|
||||||
|
}();
|
||||||
|
const Grammar& grammar = hl::grammars().value(id);
|
||||||
|
if (grammar.libs.empty())
|
||||||
|
return spans;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
const TSLanguage* lang = nullptr;
|
||||||
|
TSQuery* query = nullptr;
|
||||||
|
{
|
||||||
|
QMutexLocker locker(&m_stateMutex);
|
||||||
|
auto& state = m_states[id];
|
||||||
|
// A missing library is retriable (it may be installed while the
|
||||||
|
// shell runs); an ABI mismatch on every candidate is not. Cache
|
||||||
|
// successes and permanent failures; leave retriable misses out.
|
||||||
|
if (!state || (!state->lang && !state->bad)) {
|
||||||
|
std::shared_ptr<State> fresh = std::make_shared<State>();
|
||||||
|
bool abiMismatch = false;
|
||||||
|
for (size_t i = 0; i < grammar.libs.size(); ++i) {
|
||||||
|
void* lib = dlopen(grammar.libs[i].c_str(),
|
||||||
|
RTLD_NOW | RTLD_LOCAL);
|
||||||
|
if (!lib)
|
||||||
|
continue;
|
||||||
|
auto* symbol = reinterpret_cast<hl::LanguageFn>(
|
||||||
|
dlsym(lib, grammar.symbols[i].c_str()));
|
||||||
|
if (!symbol) {
|
||||||
|
dlclose(lib);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const TSLanguage* candidate = symbol();
|
||||||
|
const uint32_t version = ts_language_abi_version(candidate);
|
||||||
|
if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION ||
|
||||||
|
version > TREE_SITTER_LANGUAGE_VERSION) {
|
||||||
|
dlclose(lib);
|
||||||
|
abiMismatch = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fresh->lib = lib;
|
||||||
|
fresh->lang = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (fresh->lang) {
|
||||||
|
// Candidates in priority order; first that compiles wins.
|
||||||
|
for (const char* source : grammar.queries) {
|
||||||
|
TSQueryError errorType = TSQueryErrorNone;
|
||||||
|
uint32_t errorOffset = 0;
|
||||||
|
TSQuery* candidate = ts_query_new(
|
||||||
|
static_cast<const TSLanguage*>(fresh->lang),
|
||||||
|
source,
|
||||||
|
static_cast<uint32_t>(std::strlen(source)),
|
||||||
|
&errorOffset,
|
||||||
|
&errorType);
|
||||||
|
if (!candidate)
|
||||||
|
continue;
|
||||||
|
fresh->query = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!fresh->query)
|
||||||
|
fresh->bad = true;
|
||||||
|
} else if (abiMismatch) {
|
||||||
|
fresh->bad = true;
|
||||||
|
}
|
||||||
|
if (fresh->lang || fresh->bad)
|
||||||
|
state = std::move(fresh);
|
||||||
|
}
|
||||||
|
if (!state || state->bad || !state->lang)
|
||||||
|
return spans;
|
||||||
|
lang = static_cast<const TSLanguage*>(state->lang);
|
||||||
|
query = static_cast<TSQuery*>(state->query);
|
||||||
|
}
|
||||||
|
|
||||||
|
TSParser* parser = ts_parser_new();
|
||||||
|
ts_parser_set_language(parser, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVariantList>
|
||||||
|
#include <QtQml>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#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 are dlopen()'d
|
||||||
|
// lazily, so a missing grammar degrades that language to plain text
|
||||||
|
// instead of breaking the build or the app. At configure time CMake
|
||||||
|
// discovers installed grammars (system packages plus the parsers
|
||||||
|
// Neovim's nvim-treesitter installs) and pairs each with highlight
|
||||||
|
// queries (vendored, Neovim's, or fetched from
|
||||||
|
// tree-sitter/highlighting); both are embedded in the generated
|
||||||
|
// highlight-queries.hpp.
|
||||||
|
//
|
||||||
|
// For each grammar the first loadable (ABI-compatible) library wins
|
||||||
|
// and the first query that compiles against it wins, so a version
|
||||||
|
// skew between a library and its query degrades gracefully.
|
||||||
|
//
|
||||||
|
// The fence language the LLM wrote (```cpp, ```python, ...) is mapped
|
||||||
|
// to a grammar through an alias table; tags not in the table are used
|
||||||
|
// as grammar ids as-is.
|
||||||
|
//
|
||||||
|
// highlight() parses the code off the GUI thread and delivers a list of
|
||||||
|
// span maps by calling target's "onHighlightSpans(token, spans)" method
|
||||||
|
// (on the GUI thread):
|
||||||
|
// { "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).
|
||||||
|
// token is passed back unchanged so the caller can drop results for
|
||||||
|
// superseded code; a destroyed target is simply skipped.
|
||||||
|
class CodeHighlighter : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_SINGLETON
|
||||||
|
|
||||||
|
public:
|
||||||
|
Q_INVOKABLE void highlight(
|
||||||
|
const QString& code, const QString& language, QObject* target, int token);
|
||||||
|
|
||||||
|
static CodeHighlighter* create(QQmlEngine*, QJSEngine*);
|
||||||
|
|
||||||
|
struct Grammar {
|
||||||
|
// Library candidates in priority order (system package, then
|
||||||
|
// Neovim copies); libs[i] pairs with symbols[i].
|
||||||
|
std::vector<std::string> libs;
|
||||||
|
std::vector<std::string> symbols;
|
||||||
|
// Candidate query sources in priority order; first that
|
||||||
|
// compiles against the loaded grammar wins.
|
||||||
|
std::vector<const char*> queries;
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
// 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);
|
||||||
|
// The parsing work; runs on worker threads, so the per-language
|
||||||
|
// state must be initialized under m_stateMutex and is shared as an
|
||||||
|
// immutable object afterwards.
|
||||||
|
[[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const;
|
||||||
|
|
||||||
|
mutable QHash<QString, std::shared_ptr<const State>> m_states;
|
||||||
|
mutable QMutex m_stateMutex;
|
||||||
|
static CodeHighlighter* s_instance;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
% 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)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,250 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
; 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 "^-")
|
||||||
|
)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
; 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
|
||||||
@@ -0,0 +1,858 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
#include "mathtext.hpp"
|
||||||
|
|
||||||
|
#include "latinmodern-fonts.hpp"
|
||||||
|
|
||||||
|
#include <jkqtmathtext/jkqtmathtext.h>
|
||||||
|
|
||||||
|
#include <QBuffer>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFontDatabase>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QThreadPool>
|
||||||
|
|
||||||
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// A few px of breathing room around the equation.
|
||||||
|
constexpr int kRenderMargin = 2;
|
||||||
|
constexpr unsigned int kResolutionDpi = 96;
|
||||||
|
// The render cache is unbounded between clears; cap it so a very long
|
||||||
|
// session with many distinct equations cannot grow it forever.
|
||||||
|
constexpr int kCacheLimit = 512;
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
// No parent: the renderer is used from pool threads, and a parented
|
||||||
|
// QObject would taint children it creates there.
|
||||||
|
: QObject(parent), m_renderer(std::make_shared<JKQTMathText>(
|
||||||
|
nullptr, /* 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Process-wide render cache; the key is the full render state, so
|
||||||
|
// re-opening a chat reuses already-rendered equations. Accessed on the
|
||||||
|
// GUI thread only.
|
||||||
|
struct MathRender {
|
||||||
|
bool ok = false;
|
||||||
|
QImage image;
|
||||||
|
QUrl url;
|
||||||
|
qreal width = 0;
|
||||||
|
qreal height = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
QHash<QString, MathRender>& mathCache() {
|
||||||
|
static QHash<QString, MathRender> cache;
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void LlmMathText::reRender() {
|
||||||
|
++m_requestId;
|
||||||
|
if (m_latex.trimmed().isEmpty()) {
|
||||||
|
m_image = QImage();
|
||||||
|
m_imageUrl = QUrl();
|
||||||
|
m_width = 0;
|
||||||
|
m_height = 0;
|
||||||
|
m_ok = false;
|
||||||
|
Q_EMIT changed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString key = m_latex
|
||||||
|
+ QLatin1Char(0x1f) + m_color.name()
|
||||||
|
+ QLatin1Char(0x1f) + QString::number(m_fontPointSize)
|
||||||
|
+ QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
|
||||||
|
if (auto it = mathCache().find(key); it != mathCache().end()) {
|
||||||
|
m_image = it->image;
|
||||||
|
m_imageUrl = it->url;
|
||||||
|
m_width = it->width;
|
||||||
|
m_height = it->height;
|
||||||
|
m_ok = it->ok;
|
||||||
|
Q_EMIT changed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A render is already running; it re-renders the latest state when
|
||||||
|
// it completes (id mismatch), so there is nothing to do here.
|
||||||
|
if (m_inFlight)
|
||||||
|
return;
|
||||||
|
m_inFlight = true;
|
||||||
|
|
||||||
|
const QString latex = m_latex;
|
||||||
|
const QColor color = m_color;
|
||||||
|
const double pointSize = m_fontPointSize;
|
||||||
|
const qreal dpr = m_devicePixelRatio;
|
||||||
|
// The worker captures the renderer by value (shared_ptr) so it
|
||||||
|
// stays alive even if this object is destroyed mid-render; it is
|
||||||
|
// only ever used by the single in-flight worker (m_inFlight),
|
||||||
|
// never concurrently.
|
||||||
|
auto renderer = m_renderer;
|
||||||
|
QThreadPool::globalInstance()->start([this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() {
|
||||||
|
MathRender render;
|
||||||
|
renderer->setFontPointSize(pointSize);
|
||||||
|
renderer->setFontColor(color);
|
||||||
|
if (renderer->parse(
|
||||||
|
latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) {
|
||||||
|
const QImage image = renderer->drawIntoImage(
|
||||||
|
/* drawBoxes */ false,
|
||||||
|
QColor(Qt::transparent),
|
||||||
|
kRenderMargin,
|
||||||
|
dpr,
|
||||||
|
kResolutionDpi);
|
||||||
|
if (!image.isNull()) {
|
||||||
|
QByteArray png;
|
||||||
|
{
|
||||||
|
QBuffer buffer(&png);
|
||||||
|
buffer.open(QIODevice::WriteOnly);
|
||||||
|
image.save(&buffer, "PNG");
|
||||||
|
}
|
||||||
|
render.image = image;
|
||||||
|
render.url = QUrl(
|
||||||
|
QStringLiteral("data:image/png;base64,")
|
||||||
|
+ QString::fromLatin1(png.toBase64()));
|
||||||
|
// drawIntoImage renders at devicePixelRatio; convert
|
||||||
|
// back to logical pixels.
|
||||||
|
render.width = image.width() / dpr;
|
||||||
|
render.height = image.height() / dpr;
|
||||||
|
render.ok = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deliver through the app instance (never destroyed) and
|
||||||
|
// re-check the pointer on the GUI thread: posting to `this`
|
||||||
|
// from the pool thread would race with its destruction.
|
||||||
|
QPointer<LlmMathText> guard(this);
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
QCoreApplication::instance(),
|
||||||
|
[guard, id, key, render = std::move(render)]() mutable {
|
||||||
|
LlmMathText* self = guard;
|
||||||
|
if (!self)
|
||||||
|
return;
|
||||||
|
self->m_inFlight = false;
|
||||||
|
if (id != self->m_requestId) {
|
||||||
|
// Superseded while the worker ran; render the
|
||||||
|
// latest state.
|
||||||
|
self->reRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (render.ok) {
|
||||||
|
auto& cache = mathCache();
|
||||||
|
if (cache.size() >= kCacheLimit)
|
||||||
|
cache.clear();
|
||||||
|
cache.insert(key, render);
|
||||||
|
}
|
||||||
|
self->m_image = render.image;
|
||||||
|
self->m_imageUrl = render.url;
|
||||||
|
self->m_width = render.width;
|
||||||
|
self->m_height = render.height;
|
||||||
|
self->m_ok = render.ok;
|
||||||
|
Q_EMIT self->changed();
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QtQml>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#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, off the GUI thread (with a
|
||||||
|
// process-wide cache keyed on the full render state, so re-opening a
|
||||||
|
// chat does not re-render the same equations). 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();
|
||||||
|
|
||||||
|
// Shared so an in-flight worker render keeps the renderer alive if
|
||||||
|
// this object (and its QML item) is destroyed mid-render.
|
||||||
|
std::shared_ptr<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;
|
||||||
|
// Bumps on every reRender; a delivery carrying an older id was
|
||||||
|
// superseded and is dropped.
|
||||||
|
int m_requestId = 0;
|
||||||
|
bool m_inFlight = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#include "messagemodel.hpp"
|
||||||
|
|
||||||
|
#include "session.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
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();
|
||||||
|
// The new list may share rows with the current one (live rows kept
|
||||||
|
// in front of fetched rows); delete only what is truly gone.
|
||||||
|
for (ChatMessage* message : m_messages)
|
||||||
|
if (std::find(messages.begin(), messages.end(), message)
|
||||||
|
== messages.end())
|
||||||
|
delete message;
|
||||||
|
m_messages = std::move(messages);
|
||||||
|
endResetModel();
|
||||||
|
|
||||||
|
emit lastMessageChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#include "segment.hpp"
|
||||||
|
|
||||||
|
#include "markdownparser.hpp"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QThreadPool>
|
||||||
|
|
||||||
|
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;
|
||||||
|
// A parse may still be running; leave the dirty flag set so
|
||||||
|
// it re-parses the newest text when that one completes.
|
||||||
|
if (parseMarkdown())
|
||||||
|
m_markdownDirty = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
if (parseMarkdown())
|
||||||
|
m_markdownDirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool LlmSegment::parseMarkdown() {
|
||||||
|
if (m_parseInFlight)
|
||||||
|
return false;
|
||||||
|
m_parseInFlight = true;
|
||||||
|
const QString text = m_text;
|
||||||
|
QThreadPool::globalInstance()->start([this, text]() {
|
||||||
|
const QVariantList blocks = MarkdownParser::parse(text);
|
||||||
|
// Deliver through qApp (never destroyed) and re-check the
|
||||||
|
// pointer on the GUI thread: posting to `this` from the pool
|
||||||
|
// thread would race with its destruction.
|
||||||
|
QPointer<LlmSegment> guard(this);
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
QCoreApplication::instance(),
|
||||||
|
[guard, blocks]() {
|
||||||
|
LlmSegment* seg = guard;
|
||||||
|
if (!seg)
|
||||||
|
return;
|
||||||
|
seg->m_parseInFlight = false;
|
||||||
|
seg->m_markdown = blocks;
|
||||||
|
Q_EMIT seg->markdownChanged();
|
||||||
|
// Text arrived while the worker was running; parse it.
|
||||||
|
if (seg->m_markdownDirty)
|
||||||
|
seg->parseMarkdown();
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#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();
|
||||||
|
// Kicks off an off-thread parse; returns false when one is already
|
||||||
|
// in flight (the pending change is picked up when it completes).
|
||||||
|
bool 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;
|
||||||
|
bool m_parseInFlight = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#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_loadRequested) return;
|
||||||
|
m_loadRequested = true;
|
||||||
|
if (auto* store = qobject_cast<ChatStore*>(parent()))
|
||||||
|
store->loadMessagesInto(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatMessageModel* ChatSession::messagesModel() {
|
||||||
|
ensureLoaded();
|
||||||
|
return m_model;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatSession::markLoaded() {
|
||||||
|
if (m_loaded) return;
|
||||||
|
m_loaded = true;
|
||||||
|
Q_EMIT loaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSession::takeClearPending() {
|
||||||
|
const bool pending = m_clearPending;
|
||||||
|
m_clearPending = false;
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
auto* generation = target->activeGeneration();
|
||||||
|
auto* clientObject = client();
|
||||||
|
if (generation && clientObject) {
|
||||||
|
if (isLoaded()) {
|
||||||
|
clientObject->startGeneration(this, generation);
|
||||||
|
} else {
|
||||||
|
// The store load is still in flight; the request needs the
|
||||||
|
// full history, so start once it lands.
|
||||||
|
connect(
|
||||||
|
this, &ChatSession::loaded, clientObject,
|
||||||
|
[this, generation, clientObject]() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isLoaded()) {
|
||||||
|
// The load lands shortly; drop everything once it does.
|
||||||
|
m_clearPending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_model->clear();
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#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; }
|
||||||
|
// The messages model; the first access starts the (async) load
|
||||||
|
// from the store.
|
||||||
|
[[nodiscard]] ChatMessageModel* messagesModel();
|
||||||
|
void ensureLoaded();
|
||||||
|
// True once the async load from the store has finished.
|
||||||
|
[[nodiscard]] bool isLoaded() const { return m_loaded; }
|
||||||
|
// The model without triggering a load (ChatStore use during the
|
||||||
|
// load itself).
|
||||||
|
[[nodiscard]] ChatMessageModel* model() const { return m_model; }
|
||||||
|
void markLoaded();
|
||||||
|
[[nodiscard]] bool takeClearPending();
|
||||||
|
|
||||||
|
[[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();
|
||||||
|
// The messages finished loading from the store.
|
||||||
|
void loaded();
|
||||||
|
|
||||||
|
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_loadRequested = false;
|
||||||
|
bool m_loaded = false;
|
||||||
|
bool m_clearPending = false;
|
||||||
|
int m_lastTokenCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,572 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.request import urlopen
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DEVICON_VERSION = "v2.17.0"
|
||||||
|
|
||||||
|
ICONS = {
|
||||||
|
"c": "c",
|
||||||
|
"cpp": "cplusplus",
|
||||||
|
"csharp": "csharp",
|
||||||
|
"python": "python",
|
||||||
|
"rust": "rust",
|
||||||
|
"javascript": "javascript",
|
||||||
|
"typescript": "typescript",
|
||||||
|
"bash": "bash",
|
||||||
|
"zsh": "zsh",
|
||||||
|
"powershell": "powershell",
|
||||||
|
"cmake": "cmake",
|
||||||
|
"lua": "lua",
|
||||||
|
"java": "java",
|
||||||
|
"kotlin": "kotlin",
|
||||||
|
"swift": "swift",
|
||||||
|
"go": "go",
|
||||||
|
"dart": "dart",
|
||||||
|
"php": "php",
|
||||||
|
"ruby": "ruby",
|
||||||
|
"scala": "scala",
|
||||||
|
"haskell": "haskell",
|
||||||
|
"elixir": "elixir",
|
||||||
|
"erlang": "erlang",
|
||||||
|
"clojure": "clojure",
|
||||||
|
"r": "r",
|
||||||
|
"perl": "perl",
|
||||||
|
"zig": "zig",
|
||||||
|
"nim": "nim",
|
||||||
|
"ocaml": "ocaml",
|
||||||
|
"fsharp": "fsharp",
|
||||||
|
"visualbasic": "visualbasic",
|
||||||
|
"fortran": "fortran",
|
||||||
|
"crystal": "crystal",
|
||||||
|
"gleam": "gleam",
|
||||||
|
"julia": "julia",
|
||||||
|
"objectivec": "objectivec",
|
||||||
|
"vala": "vala",
|
||||||
|
"groovy": "groovy",
|
||||||
|
"racket": "racket",
|
||||||
|
"haxe": "haxe",
|
||||||
|
"purescript": "purescript",
|
||||||
|
"delphi": "delphi",
|
||||||
|
"coffeescript": "coffeescript",
|
||||||
|
"elm": "elm",
|
||||||
|
"awk": "awk",
|
||||||
|
"matlab": "matlab",
|
||||||
|
"solidity": "solidity",
|
||||||
|
"wasm": "wasm",
|
||||||
|
"vim": "vim",
|
||||||
|
"sql": "sqlite",
|
||||||
|
"json": "json",
|
||||||
|
"yaml": "yaml",
|
||||||
|
"xml": "xml",
|
||||||
|
"html": "html5",
|
||||||
|
"css": "css3",
|
||||||
|
"sass": "sass",
|
||||||
|
"markdown": "markdown",
|
||||||
|
"docker": "docker",
|
||||||
|
"latex": "latex",
|
||||||
|
"graphql": "graphql",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Markdown / syntax-highlighter aliases -> Devicon icon name.
|
||||||
|
ALIASES = {
|
||||||
|
"cpp": "cpp",
|
||||||
|
"cc": "cpp",
|
||||||
|
"cxx": "cpp",
|
||||||
|
|
||||||
|
"cs": "csharp",
|
||||||
|
|
||||||
|
"js": "javascript",
|
||||||
|
"jsx": "javascript",
|
||||||
|
|
||||||
|
"ts": "typescript",
|
||||||
|
"tsx": "typescript",
|
||||||
|
|
||||||
|
"py": "python",
|
||||||
|
|
||||||
|
"sh": "bash",
|
||||||
|
"shell": "bash",
|
||||||
|
|
||||||
|
"ps1": "powershell",
|
||||||
|
|
||||||
|
"vb": "visualbasic",
|
||||||
|
|
||||||
|
"objective-c": "objectivec",
|
||||||
|
"obj-c": "objectivec",
|
||||||
|
|
||||||
|
"groovyscript": "groovy",
|
||||||
|
|
||||||
|
"pascal": "delphi",
|
||||||
|
|
||||||
|
"coffee": "coffeescript",
|
||||||
|
|
||||||
|
"mysql": "sql",
|
||||||
|
"postgres": "sql",
|
||||||
|
"postgresql": "sql",
|
||||||
|
"sqlite": "sql",
|
||||||
|
|
||||||
|
"yml": "yaml",
|
||||||
|
|
||||||
|
"htm": "html",
|
||||||
|
|
||||||
|
"scss": "sass",
|
||||||
|
|
||||||
|
"md": "markdown",
|
||||||
|
|
||||||
|
"dockerfile": "docker",
|
||||||
|
"docker-compose": "docker",
|
||||||
|
|
||||||
|
"tex": "latex",
|
||||||
|
|
||||||
|
"gql": "graphql",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ICON_VARIANTS = (
|
||||||
|
"plain",
|
||||||
|
"original",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_svg(devicon_name: str) -> str:
|
||||||
|
for variant in ICON_VARIANTS:
|
||||||
|
url = (
|
||||||
|
f"https://raw.githubusercontent.com/devicons/devicon/"
|
||||||
|
f"{DEVICON_VERSION}/icons/{devicon_name}/"
|
||||||
|
f"{devicon_name}-{variant}.svg"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Fetching {devicon_name} ({variant}): {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urlopen(url) as response:
|
||||||
|
return response.read().decode("utf-8")
|
||||||
|
except HTTPError as error:
|
||||||
|
if error.code != 404:
|
||||||
|
raise
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No usable SVG variant found for '{devicon_name}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def element_to_path(element) -> str:
|
||||||
|
"""Convert a basic SVG shape element to equivalent path data."""
|
||||||
|
tag = element.tag.rsplit("}", 1)[-1]
|
||||||
|
attrib = element.attrib
|
||||||
|
|
||||||
|
if tag == "path":
|
||||||
|
return attrib.get("d", "")
|
||||||
|
|
||||||
|
if tag == "circle":
|
||||||
|
cx = float(attrib["cx"])
|
||||||
|
cy = float(attrib["cy"])
|
||||||
|
r = float(attrib["r"])
|
||||||
|
return (
|
||||||
|
f"M {cx - r},{cy} "
|
||||||
|
f"a {r},{r} 0 1,0 {2 * r},0 "
|
||||||
|
f"a {r},{r} 0 1,0 {-2 * r},0 Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
if tag == "ellipse":
|
||||||
|
cx = float(attrib["cx"])
|
||||||
|
cy = float(attrib["cy"])
|
||||||
|
rx = float(attrib["rx"])
|
||||||
|
ry = float(attrib["ry"])
|
||||||
|
return (
|
||||||
|
f"M {cx - rx},{cy} "
|
||||||
|
f"a {rx},{ry} 0 1,0 {2 * rx},0 "
|
||||||
|
f"a {rx},{ry} 0 1,0 {-2 * rx},0 Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
if tag == "rect":
|
||||||
|
x = float(attrib["x"])
|
||||||
|
y = float(attrib["y"])
|
||||||
|
width = float(attrib["width"])
|
||||||
|
height = float(attrib["height"])
|
||||||
|
return f"M {x},{y} h {width} v {height} h {-width} Z"
|
||||||
|
|
||||||
|
if tag in ("polygon", "polyline"):
|
||||||
|
points = attrib["points"].split()
|
||||||
|
commands = [
|
||||||
|
f"{float(points[i])},{float(points[i + 1])}"
|
||||||
|
for i in range(0, len(points) - 1, 2)
|
||||||
|
]
|
||||||
|
data = "M " + " L ".join(commands)
|
||||||
|
if tag == "polygon":
|
||||||
|
data += " Z"
|
||||||
|
return data
|
||||||
|
|
||||||
|
if tag == "line":
|
||||||
|
return (
|
||||||
|
f"M {float(attrib['x1'])},{float(attrib['y1'])} "
|
||||||
|
f"L {float(attrib['x2'])},{float(attrib['y2'])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SVG path data normalization
|
||||||
|
#
|
||||||
|
# Devicon path data crams arc flags against the following coordinate
|
||||||
|
# (e.g. "a28.78 28.78 0 00-2.65-7.58"), which Qt's PathSvg parser handles
|
||||||
|
# unreliably. Parse every path and re-emit it with canonical spacing.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PATH_COMMANDS = "MmLlHhVvCcSsQqTtAaZz"
|
||||||
|
_NUMBER = re.compile(r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?")
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_separators(data: str, i: int) -> int:
|
||||||
|
while i < len(data) and data[i] in " \t\r\n,":
|
||||||
|
i += 1
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
def _read_number(data: str, i: int) -> tuple[str, int]:
|
||||||
|
i = _skip_separators(data, i)
|
||||||
|
match = _NUMBER.match(data, i)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected number in path data at {i}: {data[i:i + 24]!r}"
|
||||||
|
)
|
||||||
|
return match.group(0), match.end()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_path(data: str) -> list[tuple[str, list[str]]]:
|
||||||
|
"""Parse SVG path data into (command, parameters) tuples."""
|
||||||
|
commands: list[tuple[str, list[str]]] = []
|
||||||
|
i, length = 0, len(data)
|
||||||
|
|
||||||
|
while i < length:
|
||||||
|
i = _skip_separators(data, i)
|
||||||
|
if i >= length:
|
||||||
|
break
|
||||||
|
|
||||||
|
command = data[i]
|
||||||
|
if command not in PATH_COMMANDS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unexpected character {command!r} at {i} in {data!r}"
|
||||||
|
)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if command in "Zz":
|
||||||
|
commands.append((command, []))
|
||||||
|
continue
|
||||||
|
|
||||||
|
implicit = command
|
||||||
|
while i < length:
|
||||||
|
i = _skip_separators(data, i)
|
||||||
|
if i >= length or data[i] in PATH_COMMANDS:
|
||||||
|
break
|
||||||
|
|
||||||
|
if implicit in "Mm":
|
||||||
|
x, i = _read_number(data, i)
|
||||||
|
y, i = _read_number(data, i)
|
||||||
|
commands.append((implicit, [x, y]))
|
||||||
|
# Subsequent coordinate pairs after a moveto are linetos.
|
||||||
|
implicit = "L" if implicit == "M" else "l"
|
||||||
|
elif implicit in "LlTt":
|
||||||
|
x, i = _read_number(data, i)
|
||||||
|
y, i = _read_number(data, i)
|
||||||
|
commands.append((implicit, [x, y]))
|
||||||
|
elif implicit in "Hh":
|
||||||
|
x, i = _read_number(data, i)
|
||||||
|
commands.append((implicit, [x]))
|
||||||
|
elif implicit in "Vv":
|
||||||
|
y, i = _read_number(data, i)
|
||||||
|
commands.append((implicit, [y]))
|
||||||
|
elif implicit in "Cc":
|
||||||
|
# Cubic bezier: x1 y1 x2 y2 x y
|
||||||
|
p = []
|
||||||
|
for _ in range(6):
|
||||||
|
value, i = _read_number(data, i)
|
||||||
|
p.append(value)
|
||||||
|
commands.append((implicit, p))
|
||||||
|
elif implicit in "SsQq":
|
||||||
|
# Smooth cubic / quadratic: x1 y1 x y
|
||||||
|
p = []
|
||||||
|
for _ in range(4):
|
||||||
|
value, i = _read_number(data, i)
|
||||||
|
p.append(value)
|
||||||
|
commands.append((implicit, p))
|
||||||
|
elif implicit in "Aa":
|
||||||
|
rx, i = _read_number(data, i)
|
||||||
|
ry, i = _read_number(data, i)
|
||||||
|
rotation, i = _read_number(data, i)
|
||||||
|
# Arc flags are single digits, possibly crammed against
|
||||||
|
# the following coordinate, so read them positionally.
|
||||||
|
i = _skip_separators(data, i)
|
||||||
|
large_arc = data[i]
|
||||||
|
i += 1
|
||||||
|
i = _skip_separators(data, i)
|
||||||
|
sweep = data[i]
|
||||||
|
i += 1
|
||||||
|
if large_arc not in "01" or sweep not in "01":
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid arc flags near {i - 2} in {data!r}"
|
||||||
|
)
|
||||||
|
x, i = _read_number(data, i)
|
||||||
|
y, i = _read_number(data, i)
|
||||||
|
commands.append(
|
||||||
|
(implicit, [rx, ry, rotation, large_arc, sweep, x, y])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unhandled path command {implicit!r}")
|
||||||
|
|
||||||
|
return commands
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path(data: str) -> str:
|
||||||
|
"""Re-emit path data with canonical spacing (Qt PathSvg friendly)."""
|
||||||
|
return " ".join(
|
||||||
|
command if not params else command + " " + " ".join(params)
|
||||||
|
for command, params in parse_path(data)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_paths(svg: str) -> str:
|
||||||
|
root = ET.fromstring(svg)
|
||||||
|
|
||||||
|
paths = []
|
||||||
|
|
||||||
|
for element in root.iter():
|
||||||
|
path = element_to_path(element)
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized = normalize_path(path)
|
||||||
|
|
||||||
|
# Guard against a tokenizer that changes the path it normalizes.
|
||||||
|
if parse_path(normalized) != parse_path(path):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Path normalization round-trip mismatch:\n"
|
||||||
|
f" in : {path[:80]}\n out: {normalized[:80]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
paths.append(normalized)
|
||||||
|
|
||||||
|
if not paths:
|
||||||
|
raise RuntimeError("SVG contains no drawable shape elements")
|
||||||
|
|
||||||
|
# Multiple path elements are valid SVG path data when concatenated.
|
||||||
|
return " ".join(paths)
|
||||||
|
|
||||||
|
|
||||||
|
def qml_string(value: str) -> str:
|
||||||
|
# JSON string escaping is valid for the string syntax we need in QML.
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def qml_identifier(name: str) -> str:
|
||||||
|
name = re.sub(r"[^A-Za-z0-9_]", "_", name)
|
||||||
|
|
||||||
|
if name and name[0].isdigit():
|
||||||
|
name = "_" + name
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def generate(output: Path) -> None:
|
||||||
|
icons = {}
|
||||||
|
|
||||||
|
for name, devicon_name in ICONS.items():
|
||||||
|
svg = fetch_svg(devicon_name)
|
||||||
|
icons[name] = extract_paths(svg)
|
||||||
|
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with output.open("w", encoding="utf-8") as file:
|
||||||
|
file.write(
|
||||||
|
"""pragma Singleton
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Icon path properties
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
for icon, path in icons.items():
|
||||||
|
identifier = qml_identifier(icon)
|
||||||
|
|
||||||
|
file.write(
|
||||||
|
f" readonly property string {identifier}: "
|
||||||
|
f"{qml_string(path)}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
file.write("\n")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Language -> icon lookup
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
file.write(
|
||||||
|
""" function path(language) {
|
||||||
|
const key = language.toLowerCase()
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
for icon in icons:
|
||||||
|
identifier = qml_identifier(icon)
|
||||||
|
|
||||||
|
file.write(
|
||||||
|
f' case "{icon}":\n'
|
||||||
|
f" return {identifier}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
for alias, icon in ALIASES.items():
|
||||||
|
if icon not in icons:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Alias '{alias}' points to unavailable icon '{icon}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
identifier = qml_identifier(icon)
|
||||||
|
|
||||||
|
file.write(
|
||||||
|
f' case "{alias}":\n'
|
||||||
|
f" return {identifier}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
file.write(
|
||||||
|
""" default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function name(language) {
|
||||||
|
switch (language.toLowerCase()) {
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Human-readable names can initially be handled here.
|
||||||
|
# Add/remove these as your codeblock UI evolves.
|
||||||
|
names = {
|
||||||
|
"c": "C",
|
||||||
|
"cpp": "C++",
|
||||||
|
"csharp": "C#",
|
||||||
|
"python": "Python",
|
||||||
|
"rust": "Rust",
|
||||||
|
"javascript": "JavaScript",
|
||||||
|
"typescript": "TypeScript",
|
||||||
|
"qml": "QML",
|
||||||
|
"bash": "Bash",
|
||||||
|
"zsh": "Zsh",
|
||||||
|
"powershell": "PowerShell",
|
||||||
|
"cmake": "CMake",
|
||||||
|
"lua": "Lua",
|
||||||
|
"java": "Java",
|
||||||
|
"kotlin": "Kotlin",
|
||||||
|
"swift": "Swift",
|
||||||
|
"go": "Go",
|
||||||
|
"dart": "Dart",
|
||||||
|
"php": "PHP",
|
||||||
|
"ruby": "Ruby",
|
||||||
|
"scala": "Scala",
|
||||||
|
"haskell": "Haskell",
|
||||||
|
"elixir": "Elixir",
|
||||||
|
"erlang": "Erlang",
|
||||||
|
"clojure": "Clojure",
|
||||||
|
"r": "R",
|
||||||
|
"perl": "Perl",
|
||||||
|
"zig": "Zig",
|
||||||
|
"nim": "Nim",
|
||||||
|
"ocaml": "OCaml",
|
||||||
|
"fsharp": "F#",
|
||||||
|
"visualbasic": "Visual Basic",
|
||||||
|
"fortran": "Fortran",
|
||||||
|
"crystal": "Crystal",
|
||||||
|
"gleam": "Gleam",
|
||||||
|
"julia": "Julia",
|
||||||
|
"objectivec": "Objective-C",
|
||||||
|
"vala": "Vala",
|
||||||
|
"groovy": "Groovy",
|
||||||
|
"racket": "Racket",
|
||||||
|
"haxe": "Haxe",
|
||||||
|
"purescript": "PureScript",
|
||||||
|
"delphi": "Delphi",
|
||||||
|
"coffeescript": "CoffeeScript",
|
||||||
|
"elm": "Elm",
|
||||||
|
"awk": "AWK",
|
||||||
|
"matlab": "MATLAB",
|
||||||
|
"solidity": "Solidity",
|
||||||
|
"wasm": "Wasm",
|
||||||
|
"vim": "Vim",
|
||||||
|
"sql": "SQL",
|
||||||
|
"json": "JSON",
|
||||||
|
"yaml": "YAML",
|
||||||
|
"xml": "XML",
|
||||||
|
"html": "HTML",
|
||||||
|
"css": "CSS",
|
||||||
|
"sass": "Sass",
|
||||||
|
"markdown": "Markdown",
|
||||||
|
"docker": "Docker",
|
||||||
|
"latex": "LaTeX",
|
||||||
|
"graphql": "GraphQL",
|
||||||
|
}
|
||||||
|
|
||||||
|
for icon in icons:
|
||||||
|
if icon not in names:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = names[icon]
|
||||||
|
|
||||||
|
file.write(
|
||||||
|
f' case "{icon}":\n'
|
||||||
|
f' return "{name}"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
for alias, icon in ALIASES.items():
|
||||||
|
if icon not in names:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file.write(
|
||||||
|
f' case "{alias}":\n'
|
||||||
|
f' return "{names[icon]}"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
file.write(
|
||||||
|
""" default:
|
||||||
|
return language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Generated {output}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} OUTPUT.qml", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
generate(Path(sys.argv[1]))
|
||||||
Reference in New Issue
Block a user