36 Commits
Author SHA1 Message Date
zach d3345906b9 Merge pull request 'Feat: LLM Chat' (#155) from feat/llm-chat-tab into main
Reviewed-on: #155
2026-10-18 17:05:05 +02:00
zach 08d42e1f8a chore: formatter & clangd lsp option
C++ / fmt (pull_request) Successful in 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 27s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m40s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 45s
2026-10-18 15:23:40 +02:00
zach 45aa6c9005 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 20s
C++ / clang-tidy (pull_request) Failing after 12s
JS/TS / fmt (pull_request) Successful in 6s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 48s
Rust / fmt (pull_request) Successful in 28s
Python / verify (pull_request) Successful in 1m45s
Rust / clippy (pull_request) Successful in 46s
2026-10-18 14:43:00 +02:00
zach 1763b03287 Merge pull request 'Chore/remove nix support' (#159) from chore/remove-nix-support into main
Reviewed-on: #159
2026-10-18 14:42:50 +02:00
zach 72ff6ad42a Merge branch 'main' into chore/remove-nix-support
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 9s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 29s
Rust / clippy (pull_request) Successful in 55s
2026-10-18 14:42:43 +02:00
zach 2a45740020 Merge pull request 'fix(pr-template): adds an "Other" issue type' (#160) from fix/ci-pr-template into main
Reviewed-on: #160
2026-10-18 14:42:35 +02:00
zach 0a503bc27c fix: remove unimplemented method
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 27s
Rust / build (pull_request) Successful in 49s
Rust / fmt (pull_request) Successful in 28s
Python / verify (pull_request) Successful in 1m41s
Rust / clippy (pull_request) Successful in 45s
2026-10-18 13:03:25 +02:00
zach 1fe8a3a959 chore: restructure llm plugin to be more coherent & fix various bugs in llm ui
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 36s
Rust / clippy (pull_request) Successful in 54s
2026-10-18 12:58:38 +02:00
AramJonghu ae25f33fc8 fix(pr-template): adds an "Other" issue type
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
Rust / fmt (pull_request) Successful in 48s
Rust / build (pull_request) Successful in 1m9s
Rust / clippy (pull_request) Successful in 52s
Python / static (pull_request) Successful in 55s
JS/TS / fmt (pull_request) Successful in 11s
JS/TS / lint (pull_request) Successful in 9s
Python / verify (pull_request) Successful in 1m26s
2026-10-14 00:22:33 +02:00
AramJonghu 02267ec9a6 docs(readme): adjust Nix support documentation
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 27s
Rust / build (pull_request) Successful in 50s
Rust / fmt (pull_request) Successful in 32s
Python / verify (pull_request) Successful in 2m16s
C++ / fmt (pull_request) Failing after 7s
C++ / build (pull_request) Failing after 12s
Rust / clippy (pull_request) Successful in 50s
C++ / clang-tidy (pull_request) Failing after 26s
2026-10-14 00:08:17 +02:00
AramJonghu 4b8d74a7e5 ci(cleanup): removed NixOS from issue template
Avoids confusion
2026-10-14 00:08:17 +02:00
AramJonghu 1fa3c84a56 chore(nix): remove all .nix files and flake.lock 2026-10-14 00:08:17 +02:00
zach fc81be922f Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 48s
Python / verify (pull_request) Successful in 1m38s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 46s
2026-10-12 16:06:23 +02:00
zach 8d54e1ce6f fix: undo accidental debug code 2026-10-12 16:05:52 +02:00
zach bee8cd061b Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 7s
C++ / build (pull_request) Failing after 11s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 48s
Python / verify (pull_request) Successful in 1m39s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 46s
2026-10-12 15:58:33 +02:00
zach b9e818faa9 fix: add proper atRestOverride for notification background 2026-10-12 15:58:25 +02:00
zach 2cabd72fc0 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 12s
C++ / clang-tidy (pull_request) Failing after 12s
JS/TS / fmt (pull_request) Successful in 8s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 31s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m32s
Rust / fmt (pull_request) Successful in 51s
Rust / clippy (pull_request) Successful in 1m1s
2026-10-12 15:54:52 +02:00
zach 255db9e533 fix: ignore stale coordinate data for panels + convert wavyline to use new qt 6.12 canvas painter api 2026-10-12 15:54:32 +02:00
zach a4f6aa1d4a Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 16s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 14s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m37s
Rust / fmt (pull_request) Successful in 31s
Rust / clippy (pull_request) Successful in 49s
2026-10-11 08:42:22 +02:00
zach 3883917228 fix: ui no longer blocks when volumes are unreachable + add support for network and fuse volumes 2026-10-11 08:42:05 +02:00
zach bf6a7027e4 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 7s
C++ / build (pull_request) Failing after 13s
C++ / clang-tidy (pull_request) Failing after 12s
JS/TS / fmt (pull_request) Successful in 9s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 27s
Rust / build (pull_request) Successful in 49s
Rust / fmt (pull_request) Successful in 28s
Python / verify (pull_request) Successful in 1m40s
Rust / clippy (pull_request) Successful in 47s
2026-10-08 12:09:59 +02:00
zach 873b2eb01b fix: dashboard tabs scroll now only loads previous item + next item 2026-10-08 12:09:50 +02:00
zach d70fb61e4a gemini glow fixes + positioning for window view
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m38s
Rust / fmt (pull_request) Successful in 27s
Rust / clippy (pull_request) Successful in 53s
2026-10-08 12:08:48 +02:00
zach 4c410d638c Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 7s
C++ / build (pull_request) Failing after 21s
C++ / clang-tidy (pull_request) Failing after 12s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Successful in 1m33s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 49s
2026-10-07 22:39:19 +02:00
zach aef7cd5918 fix: qt 6.12 build 2026-10-07 22:38:49 +02:00
zach a13fa8442b fix: qt 6.12 build
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 22s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 54s
Python / verify (pull_request) Successful in 1m41s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 53s
2026-10-07 22:37:46 +02:00
zach 4d75b317cd revert rounding var
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 18s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 8s
JS/TS / lint (pull_request) Successful in 12s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 52s
Python / verify (pull_request) Successful in 1m38s
Rust / fmt (pull_request) Successful in 31s
Rust / clippy (pull_request) Successful in 1m0s
2026-10-07 13:33:54 +02:00
zach 76823eff79 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 29s
C++ / build (pull_request) Failing after 33s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 11s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 51s
Python / verify (pull_request) Successful in 1m36s
Rust / fmt (pull_request) Successful in 43s
Rust / clippy (pull_request) Successful in 1m4s
2026-10-07 13:32:49 +02:00
zach 909b5113fb fix: closed panels don't add resistance to bounce + use qtrendering for text 2026-10-07 13:32:37 +02:00
zach c06cbea854 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 8s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 48s
Python / verify (pull_request) Successful in 1m40s
Rust / fmt (pull_request) Successful in 25s
Rust / clippy (pull_request) Successful in 43s
2026-10-06 13:33:41 +02:00
zach 376595cd0b fix: don't use zoom value when rendering wallpaper 2026-10-06 13:30:27 +02:00
zach a00bbe50f2 add rounding var
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 8s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 48s
Python / verify (pull_request) Successful in 1m43s
Rust / fmt (pull_request) Successful in 25s
Rust / clippy (pull_request) Successful in 46s
2026-10-06 00:55:50 +02:00
zach 11e94fbb4b Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Successful in 1m40s
Rust / fmt (pull_request) Successful in 29s
Rust / clippy (pull_request) Successful in 46s
2026-10-05 19:33:42 +02:00
zach 9f5e62f9c7 feat: use custom wayland layer-shell to render wallpaper to reduce vram 2026-10-05 19:31:15 +02:00
zach b91befa9f7 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 11s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m30s
Rust / fmt (pull_request) Successful in 30s
Rust / clippy (pull_request) Successful in 48s
2026-10-05 09:58:00 +02:00
zach f90e9bb1a0 fix: all apps icon size and suppress warnings 2026-10-05 09:57:47 +02:00
122 changed files with 5537 additions and 3418 deletions
+5
View File
@@ -0,0 +1,5 @@
CompileFlags:
Remove:
# GCC-only flags from the system's default spec that clang/clangd cannot
# parse. They appear in compile_commands.json but are not set by the project.
- -mno-direct-extern-access
-1
View File
@@ -38,7 +38,6 @@ body:
attributes:
label: OS
options:
- NixOS
- Arch
- Debian / Ubuntu
- Fedora
+1
View File
@@ -30,6 +30,7 @@ body:
- Refactor / style
- CI / build / tooling
- Documentation
- Other (Describe in description box with header "Issue type")
validations:
required: true
+11 -2
View File
@@ -61,10 +61,12 @@ set(CMAKE_INSTALL_MESSAGE NEVER)
add_compile_options(
-Wall -Wextra -Wpedantic -Wshadow -Wconversion
-Wold-style-cast -Wnull-dereference -Wdouble-promotion
-Wformat=2 -Wfloat-equal -Woverloaded-virtual
-Wnull-dereference -Wdouble-promotion
-Wformat=2 -Wfloat-equal
-Wsign-conversion -Wredundant-decls -Wswitch
-Wunreachable-code
$<$<COMPILE_LANGUAGE:CXX>:-Wold-style-cast>
$<$<COMPILE_LANGUAGE:CXX>:-Woverloaded-virtual>
)
@@ -188,4 +190,11 @@ if("m3shapes" IN_LIST ENABLE_MODULES)
if(TARGET m3shapesplugin)
set_target_properties(m3shapesplugin PROPERTIES INSTALL_RPATH "$ORIGIN")
endif()
# Silence warnings that come from m3shapes files we do not manage.
foreach(m3shapes_target m3shapes_core m3shapes_morph m3shapes_shapes m3shapes m3shapesplugin)
if(TARGET ${m3shapes_target})
target_compile_options(${m3shapes_target} PRIVATE -Wno-shadow -Wno-float-equal)
endif()
endforeach()
endif()
+5 -3
View File
@@ -16,11 +16,12 @@ Text {
color: Colors.palette.m3onSurface
font: Tokens.font.body.small
linkColor: Colors.palette.m3onPrimaryFixedVariant
renderType: Text.NativeRendering
renderType: Text.QtRendering
textFormat: Text.PlainText
Behavior on color {
CAnim {}
CAnim {
}
}
Behavior on text {
enabled: root.animate
@@ -32,7 +33,8 @@ Text {
type: Anim.FastEffects
}
PropertyAction {}
PropertyAction {
}
TAnim {
target: root
+1
View File
@@ -9,5 +9,6 @@ CustomText {
property int grade: Colors.light ? 0 : -25
property font fontStyle: Tokens.font.icon.small
renderType: Text.NativeRendering
font: Tokens.font.icon.size(fontStyle.pointSize).weight(fontStyle.weight).vaxes(fontStyle.variableAxes).fill(fill.toFixed(1)).grade(grade).build()
}
+13 -12
View File
@@ -124,7 +124,7 @@ MouseArea {
model: root.items
CustomRect {
id: item
id: menuItem
readonly property bool active: modelData === root?.active
required property int index
@@ -142,13 +142,13 @@ MouseArea {
}
StateLayer {
color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
enabled: root.expanded
onClicked: {
root.itemSelected(item.modelData);
root.active = item.modelData;
item.modelData.clicked();
root.itemSelected(menuItem.modelData);
root.active = menuItem.modelData;
menuItem.modelData.clicked();
root.expanded = false;
}
}
@@ -162,26 +162,27 @@ MouseArea {
MaterialIcon {
Layout.alignment: Qt.AlignVCenter
color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant
text: item.modelData?.icon ?? ""
color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant
text: menuItem.modelData?.icon ?? ""
visible: text !== ""
}
CustomText {
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
text: item.modelData?.text ?? ""
color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
text: menuItem.modelData?.text ?? ""
}
Loader {
Layout.alignment: Qt.AlignVCenter
active: item.modelData?.trailingIcon.length > 0
active: menuItem.modelData?.trailingIcon.length > 0
asynchronous: true
visible: active
sourceComponent: MaterialIcon {
color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant
text: item.modelData.trailingIcon
color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant
text: menuItem.modelData.trailingIcon
}
}
}
+1 -1
View File
@@ -151,7 +151,7 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
clip: true
clip: offsetScale > 0
implicitHeight: dashboard.implicitHeight * (1 - offsetScale)
implicitWidth: dashboard.implicitWidth
+4
View File
@@ -246,6 +246,7 @@ CustomWindow {
PanelBg {
id: osdBg
atRestOverride: panels.osd.offsetScale < 0.99
deformAmount: 0.1
implicitHeight: panels.osd.height
implicitWidth: panels.osd.width
@@ -257,6 +258,7 @@ CustomWindow {
PanelBg {
id: notifsBg
atRestOverride: panel.implicitHeight > 0
panel: panels.notifications
radius: Tokens.rounding.large
}
@@ -293,6 +295,7 @@ CustomWindow {
property real extraExtent: 0.2
atRestOverride: panels.resources.offsetScale < 0.99
deformAmount: 0.05
implicitHeight: panels.resources.height * (1 + extraExtent)
implicitWidth: panels.resources.width * (1 + extraExtent)
@@ -446,6 +449,7 @@ CustomWindow {
property real deformAmount: 0.15
required property Item panel
atRestOverride: panel?.offsetScale < 0.99 ?? false
damping: 28.3
deformScale: (deformAmount * Config.appearance.deform.scale) / 10000
group: blobGroup
+38
View File
@@ -5,7 +5,9 @@ import Quickshell
import Quickshell.Io
import QtQuick
import ZShell.Models
import ZShell.Internal
import ZShell.Config
import qs.Components
import qs.Services
import qs.Helpers
import qs.Paths
@@ -140,4 +142,40 @@ Searcher {
}
}
}
WallpaperService {
id: wallpaper
path: root.current
}
Variants {
model: Quickshell.screens
WallpaperObject {
readonly property var crops: root.getCrop(modelData.name)
required property var modelData
cropHeight: crops?.height ?? 1.0
cropWidth: crops?.width ?? 1.0
cropX: crops?.x ?? 0.0
cropY: crops?.y ?? 0.0
screen: modelData.name
service: wallpaper
zoom: crops?.zoom ?? 1.0
Behavior on cropHeight {
Anim {}
}
Behavior on cropWidth {
Anim {}
}
Behavior on cropX {
Anim {}
}
Behavior on cropY {
Anim {}
}
}
}
}
+4 -3
View File
@@ -17,12 +17,12 @@ GridLayout {
required property Wrapper popouts
required property ClipWrapper popoutsWrapper
required property ShellScreen screen
readonly property int vPadding: 6
required property ScreenState screenState
readonly property int vPadding: 6
function axisCenterOf(item: Item): real {
const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2);
return horizontal ? c.x : c.y;
return horizontal ? Math.floor(c.x) : Math.floor(c.y);
}
function checkPopout(pos: real): void {
@@ -210,7 +210,8 @@ GridLayout {
roleValue: "network"
delegate: EntryWrapper {
NetworkWidget {}
NetworkWidget {
}
}
}
+31 -17
View File
@@ -1,6 +1,6 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Shapes
import Quickshell
import qs.Components
import ZShell.Config
import qs.Services
@@ -12,6 +12,7 @@ GridLayout {
property real animatedPercentage: 0
readonly property real arcStartAngle: 0.75 * Math.PI
readonly property real arcSweep: 1.5 * Math.PI
readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1
required property bool horizontal
property string icon
required property color iconColor
@@ -22,12 +23,17 @@ GridLayout {
property bool warning: percentage * 100 >= warningThreshold
property int warningThreshold: 80
function snap(v) {
return Math.round(v * dpr) / dpr;
}
columnSpacing: Tokens.spacing.medium
columns: horizontal ? -1 : 1
percentage: 0
Behavior on animatedPercentage {
Anim {}
Anim {
}
}
Component.onCompleted: animatedPercentage = percentage
@@ -47,27 +53,35 @@ GridLayout {
text: root.icon
}
CustomClippingRect {
CustomRect {
id: track
Layout.preferredHeight: root.horizontal ? icon.implicitHeight - Tokens.padding.extraSmall : 4
Layout.preferredWidth: root.horizontal ? 4 : icon.implicitWidth
color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 2)
radius: Tokens.rounding.full
radius: Math.min(width, height) / 2
CustomRect {
id: fill
Item {
id: clipper
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: root.horizontal ? parent.right : undefined
anchors.top: root.horizontal ? undefined : parent.top
color: root.mainColor
implicitHeight: root.horizontal ? Math.ceil(root.percentage * parent.height) : 0
implicitWidth: root.horizontal ? 0 : Math.ceil(root.percentage * parent.width)
anchors.bottom: root.horizontal ? parent.bottom : undefined
anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined
anchors.left: root.horizontal ? undefined : parent.left
anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter
clip: true
height: root.horizontal ? root.snap(root.percentage * track.height) : track.height * 2
width: root.horizontal ? track.width * 2 : root.snap(root.percentage * track.width)
// Behavior on implicitHeight {
// Anim {
// }
// }
CustomRect {
anchors.bottom: root.horizontal ? parent.bottom : undefined
anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined
anchors.left: root.horizontal ? undefined : parent.left
anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter
color: root.mainColor
height: track.height
radius: track.radius
width: track.width
}
}
}
}
+41 -65
View File
@@ -1,7 +1,6 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
import ZShell.Config
@@ -11,11 +10,6 @@ import qs.Components
Item {
id: root
readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2
readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins + viewWrapper.anchors.topMargin
required property ScreenState screenState
readonly property var dashboardTabs: {
const allTabs = [
{
@@ -45,9 +39,12 @@ Item {
];
return allTabs.filter(tab => tab.enabled);
}
readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins + viewWrapper.anchors.topMargin
readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2
required property ScreenState screenState
implicitWidth: nonAnimWidth
implicitHeight: nonAnimHeight
implicitWidth: nonAnimWidth
Behavior on implicitHeight {
Anim {
@@ -63,15 +60,14 @@ Item {
Tabs {
id: tabs
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: Tokens.padding.small
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins)
nonAnimWidth: view.implicitWidth
screenState: root.screenState
tabs: root.dashboardTabs
anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins)
anchors.margins: Tokens.padding.small
}
Item {
@@ -79,15 +75,15 @@ Item {
property int radius: Tokens.rounding.extraLarge - anchors.margins
anchors.top: tabs.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.topMargin: Tokens.spacing.small
anchors.left: parent.left
anchors.margins: Tokens.padding.small
clip: true
anchors.right: parent.right
anchors.top: tabs.bottom
anchors.topMargin: Tokens.spacing.small
clip: view.currentIndex !== 3 || (!view.currentItem?.settled ?? true)
Flickable {
Item {
id: view
readonly property int currentIndex: root.screenState.dashboardTab
@@ -97,64 +93,50 @@ Item {
}
anchors.fill: parent
implicitWidth: currentItem?.implicitWidth ?? 0
implicitHeight: currentItem?.implicitHeight ?? 0
implicitWidth: currentItem?.implicitWidth ?? 0
contentX: currentItem?.x ?? 0
contentWidth: row.implicitWidth
contentHeight: row.implicitHeight
Repeater {
id: repeater
interactive: false
delegate: Loader {
id: paneLoader
onContentXChanged: {
if (!moving || !currentItem)
return;
required property int index
required property var modelData
const x = contentX - currentItem.x;
if (x > currentItem.implicitWidth / 2)
root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1);
else if (x < -currentItem.implicitWidth / 2)
root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0);
}
readonly property real targetX: index < view.currentIndex ? -view.implicitWidth : index > view.currentIndex ? view.implicitWidth : 0
readonly property bool settled: x === targetX
RowLayout {
id: row
active: index === view.currentIndex || opacity > 0
opacity: index === view.currentIndex ? 1 : 0
sourceComponent: modelData.component
visible: opacity > 0
x: targetX
y: 0
Repeater {
id: repeater
model: ScriptModel {
values: root.dashboardTabs
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
delegate: Loader {
id: paneLoader
required property int index
required property var modelData
Layout.alignment: Qt.AlignTop
sourceComponent: modelData.component
Component.onCompleted: active = Qt.binding(() => {
if (index === view.currentIndex)
return true;
const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth);
const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth);
return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth);
})
Behavior on x {
Anim {
type: Anim.DefaultEffects
}
}
}
model: ScriptModel {
values: root.dashboardTabs
}
}
Component {
id: dashComponent
Dash {
screenState: root.screenState
radius: viewWrapper.radius
screenState: root.screenState
}
}
@@ -170,8 +152,8 @@ Item {
id: mediaComponent
Media {
screenState: root.screenState
radius: viewWrapper.radius
screenState: root.screenState
}
}
@@ -182,12 +164,6 @@ Item {
radius: viewWrapper.radius
}
}
Behavior on contentX {
Anim {
type: Anim.DefaultEffects
}
}
}
}
}
@@ -171,7 +171,7 @@ Item {
Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter
elide: Text.ElideRight
font: Tokens.font.title.medium
text: qsTr(root.chatData.title)
text: qsTr(root.chatData?.title || ChatState.title)
}
}
@@ -540,14 +540,17 @@ Item {
anchors.margins: Tokens.padding.medium
bg.border.color: Colors.palette.m3outlineVariant
bg.color: Colors.tPalette.m3surfaceContainerLowest
fastGlow: Chat.busy
fastGlow: LlmClient.busy
glow.opacity: input.activeFocus && (!LlmClient.busy || ChatState.isWindow)
font: Tokens.font.body.small
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
focus: true
placeholderText: qsTr("Send a message")
sendIcon.font: Tokens.font.icon.medium
sendIcon.icon: "arrow_upward"
sendIcon.icon: LlmClient.busy ? "stop" : "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall
sendIcon.opacity: text || LlmClient.busy ? 1.0 : 0.5
sendIcon.enabled: text || LlmClient.busy
Component.onCompleted: {
root.focusInput();
@@ -568,6 +571,11 @@ Item {
}
}
}
onSendPressed: root.send(text)
onButtonPressed: {
if (LlmClient.busy)
LlmClient.stop();
else
root.send(text);
}
}
}
@@ -161,10 +161,6 @@ CustomClippingRect {
CustomRect {
id: fadeMask
// anchors.top: titleText.top
// anchors.bottom: titleText.bottom
// anchors.left: titleText.left
// anchors.right: parent.right
anchors.fill: titleWrapper
layer.enabled: true
@@ -259,7 +255,7 @@ CustomClippingRect {
font: Tokens.font.body.small
maximumLineCount: 1
opacity: !root.expanded && !root.slim ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
text: root.modelData.messagesModel.lastMessage?.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
Behavior on y {
Anim {}
@@ -284,7 +280,7 @@ CustomClippingRect {
color: Colors.palette.m3onSurfaceVariant
maximumLineCount: 5
opacity: root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? qsTr("No messages yet")
text: root.modelData.messagesModel.lastMessage?.content ?? qsTr("No messages yet")
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on y {
@@ -8,10 +8,11 @@ TextAreaBase {
readonly property alias bg: bg
readonly property alias sendIcon: sendIcon
readonly property alias glow: glow
property bool fastGlow: false
signal sendPressed
signal buttonPressed
bottomPadding: Tokens.padding.large
leftPadding: Tokens.padding.extraLarge
@@ -39,6 +40,7 @@ TextAreaBase {
GeminiGlow {
id: glow
glowSize: 32
z: 5
opacity: root.activeFocus ? 1 : 0
visible: opacity > 0
@@ -131,7 +133,7 @@ TextAreaBase {
}
}
onClicked: root.sendPressed()
onClicked: root.buttonPressed()
}
TapHandler {
+17 -152
View File
@@ -5,6 +5,7 @@ import QtQuick
import QtQuick.Layouts
import ZShell.Llm
import ZShell.Config
import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Services
import qs.Components
@@ -21,8 +22,13 @@ Item {
signal loadChatRequest(content: ChatSession, index: int)
signal newChatRequest
onSlimChanged: {
if (slim)
modelsContainer.open = false;
}
CustomText {
id: header
id: chatHeader
anchors.left: parent.left
anchors.margins: Tokens.padding.extraSmall
@@ -41,7 +47,7 @@ Item {
anchors.bottom: modelsContainer.top
anchors.left: parent.left
anchors.right: parent.right
anchors.top: header.bottom
anchors.top: chatHeader.bottom
anchors.topMargin: Tokens.spacing.medium
clip: true
spacing: Tokens.spacing.medium
@@ -98,156 +104,24 @@ Item {
}
}
CustomClippingRect {
ModelsContainer {
id: modelsContainer
property bool expanded: false
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: fabRoot.left
anchors.margins: Tokens.padding.small
anchors.rightMargin: Tokens.spacing.small
implicitHeight: expanded ? 400 : fabRoot.implicitHeight
radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2
rootParent: root
fabRoot: fabRoot
function prettyModelName(): string {
if (modelsList.count < 1)
return qsTr("No models found");
opacity: root.slim ? 0 : 1
visible: opacity > 0
const pathList = Chat.model.split("\/");
const name = pathList[pathList.length - 1];
if (!name)
return qsTr("No model selected");
return name;
}
Behavior on implicitHeight {
Anim {}
}
Behavior on radius {
Anim {}
}
Item {
id: modelName
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
implicitHeight: fabRoot.implicitHeight
CustomText {
id: label
text: modelsContainer.prettyModelName()
color: Colors.palette.m3onSurfaceVariant
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter
}
MaterialIcon {
color: Colors.palette.m3onSurfaceVariant
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter
text: modelsContainer.expanded ? "unfold_less" : "unfold_more"
animate: true
fontStyle: Tokens.font.icon.large
visible: modelsList.count > 0
}
StateLayer {
id: modelLayer
enabled: modelsList.count > 0
onClicked: {
ChatState.fabExpanded = false;
modelsContainer.expanded = !modelsContainer.expanded;
}
radius: modelsContainer.radius
}
}
VerticalFadeListView {
id: modelsList
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.top: modelName.bottom
anchors.margins: Tokens.padding.medium
anchors.topMargin: Tokens.spacing.medium
model: Chat.availableModels
opacity: modelsContainer.expanded ? 1 : 0
currentIndex: Chat.availableModels.indexOf(Chat.model)
visible: opacity > 0
Behavior on opacity {
Anim {}
}
CustomScrollBar.vertical: CustomScrollBar {
flickable: modelsList
}
delegate: CustomRect {
id: model
required property var modelData
required property int index
readonly property bool selected: model.ListView.view.currentIndex === model.index
implicitWidth: ListView.view.width
implicitHeight: layout.implicitHeight + Tokens.padding.small * 2
radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium
color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0)
Behavior on radius {
Anim {
type: Anim.SlowEffects
}
}
RowLayout {
id: layout
anchors.fill: parent
anchors.margins: Tokens.padding.small
anchors.leftMargin: Tokens.padding.large
anchors.rightMargin: Tokens.padding.large
CustomText {
text: model.modelData
color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
}
MaterialIcon {
Layout.alignment: Qt.AlignRight
visible: model.selected
color: Colors.palette.m3onTertiaryContainer
fontStyle: Tokens.font.icon.normal
opacity: model.selected ? 1 : 0
text: "check"
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
}
}
StateLayer {
id: stateLayer
onClicked: {
Chat.selectModel(model.modelData);
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
@@ -262,19 +136,11 @@ Item {
font.pointSize: Math.round(18 * 1.2)
icon: root.slim ? "left_panel_open" : "add"
isRound: ChatState.fabExpanded
// opacity: root.slim ? 0 : 1
// visible: opacity > 0
//
// Behavior on opacity {
// Anim {
// type: Anim.DefaultEffects
// }
// }
label.transform: Rotation {
origin.y: fabRoot.label.height / 2
origin.x: fabRoot.label.width / 2
angle: ChatState.fabExpanded ? 135 : 0
angle: ChatState.fabExpanded && (ChatState.narrowSidebarExpanded && !ChatState.isWide || !ChatState.isWindow) ? 135 : 0
Behavior on angle {
Anim {}
@@ -287,7 +153,6 @@ Item {
return;
}
modelsContainer.expanded = false;
ChatState.fabExpanded = !ChatState.fabExpanded;
}
@@ -17,15 +17,26 @@ Item {
anchors.margins: Tokens.padding.small
Component.onCompleted: {
if (ChatState.firstOpen) {
ChatState.openDraft();
stack.push(chatList);
stack.push(chatContent);
ChatState.firstOpen = false;
return;
}
if (ChatState.inChat) {
stack.push(chatList);
stack.push(chatContent, {
"chatData": ChatState.chatSession
});
stack.push(chatContent);
} else
stack.push(chatList);
}
function openChat(): void {
if (stack.depth < 2)
stack.push(chatContent);
}
StackView {
id: stack
anchors.fill: parent
@@ -35,27 +46,23 @@ Item {
id: chatList
ChatList {
model: ScriptModel {
values: Chat.chats.values
}
model: LlmClient.chats
onDeleteChatRequest: chat => {
Chat.chats.remove(chat);
const wasCurrent = chat === ChatState.chatSession;
LlmClient.chats.remove(chat);
if (wasCurrent)
ChatState.openDraft();
}
onLoadChatRequest: (chat, index) => {
stack.push(chatContent, {
"chatData": chat
});
ChatState.inChat = true;
ChatState.chatSession = chat;
openChat();
ChatState.inChat = true;
}
onNewChatRequest: {
const data = Chat.chats.insert();
stack.push(chatContent, {
"chatData": data
});
ChatState.openDraft();
openChat();
ChatState.inChat = true;
ChatState.chatSession = data;
}
}
}
@@ -64,9 +71,12 @@ Item {
id: chatContent
ChatContent {
chatData: ChatState.chatSession
onRequestClose: {
stack.pop();
ChatState.inChat = false;
ChatState.openDraft();
}
}
}
@@ -8,9 +8,10 @@ import qs.Services
Singleton {
id: root
property ChatSession chatSession
readonly property string title: chatSession ? chatSession.title : "Chat"
property ChatSession chatSession: LlmClient.chats.draft()
readonly property string title: chatSession?.title || "Chat"
property int currentIdx: -1
property bool firstOpen: true
property bool inChat: false
property bool fabExpanded: false
property bool isWindow: false
@@ -22,6 +23,11 @@ Singleton {
property ShellScreen screen
property bool sidebarVisible: ShellState.forActive().sidebar
function openDraft(): void {
root.chatSession = LlmClient.chats.draft();
root.currentIdx = -1;
}
onSidebarVisibleChanged: if (!sidebarVisible)
fabExpanded = false
}
@@ -9,12 +9,11 @@ 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
required property bool running
implicitHeight: editButton.implicitHeight
implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small
@@ -63,7 +62,7 @@ Item {
id: editTools
anchors.right: parent.right
opacity: !root.current.streaming && root.hovered ? 1 : 0
opacity: !root.running && root.hovered ? 1 : 0
spacing: Tokens.spacing.small
visible: opacity > 0
@@ -99,7 +98,7 @@ Item {
Anim {}
}
onClicked: Quickshell.clipboardText = root.current.content
onClicked: Quickshell.clipboardText = root.message.content
}
IconButton {
@@ -0,0 +1,400 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import ZShell.Components
import ZShell.Llm
import ZShell.Blobs
import qs.Components
import qs.Services
Item {
id: root
required property Item rootParent
required property ChatGeneration current
implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2
CustomRect {
id: approvalBg
anchors.fill: parent
radius: Tokens.rounding.medium
color: Colors.palette.m3surface
border.width: 1
border.color: Colors.palette.m3outlineVariant
ColumnLayout {
id: approvalLayout
anchors.fill: parent
anchors.margins: Tokens.padding.medium
spacing: Tokens.spacing.small
CustomText {
id: requestHeader
text: qsTr("Tool call request")
font: Tokens.font.headline.medium
color: Colors.palette.m3onSurface
}
Item {
id: toolsBg
property bool open: false
property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2
property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight)
property int openWidth: root.rootParent.width - Tokens.padding.medium * 2
property real openHeightProg: openHeight / maxHeight
property int collapsedY: 0
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
z: open || dialogTransition.running ? 2 : 0
Layout.fillWidth: true
function reparentWrapper(): void {
const newParent = open ? root.rootParent : toolsBg;
const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2);
toolsBg.collapsedY = toolsWrapper.y;
toolsWrapper.parent = newParent;
toolsWrapper.x = c.x - toolsWrapper.width / 2;
toolsWrapper.y = c.y - toolsWrapper.height / 2;
}
onOpenChanged: {
if (open)
root.rootParent.interactive = false;
else
root.rootParent.interactive = true;
}
BlobGroup {
id: blobGroup
color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer
Behavior on color {
CAnim {}
}
}
MouseArea {
id: backdrop
anchors.fill: parent
enabled: false
hoverEnabled: enabled
preventStealing: true
parent: toolsBg.open ? root.rootParent : toolsBg
onClicked: toolsBg.open = false
}
Item {
id: toolsWrapper
width: toolsBg.width
height: toolBox.implicitHeight
rotation: parent === root.rootParent ? 180 : 0
z: 1
states: State {
name: "open"
when: toolsBg.open
PropertyChanges {
backdrop.enabled: true
toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased
toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased
toolsContent.opacity: 1
toolsWrapper.height: toolsBg.openHeight
toolsWrapper.width: toolsBg.openWidth
toolsWrapper.x: (root.rootParent.width - toolsBg.openWidth) / 2
toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium
elevation.opacity: 1
toolBox.opacity: 0
}
}
transitions: Transition {
id: dialogTransition
SequentialAnimation {
ScriptAction {
script: toolsBg.reparentWrapper()
}
Anim {
properties: "x,y"
}
}
PropertyAction {
property: "enabled"
}
Anim {
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
type: Anim.DefaultEffects
}
Anim {
properties: "width,height"
}
}
Elevation {
id: elevation
anchors.fill: parent
bottomLeftRadius: toolsBackground.bottomLeftRadius
bottomRightRadius: toolsBackground.bottomRightRadius
level: 4
opacity: 0
radius: toolsBackground.radius
transform: Matrix4x4 {
matrix: toolsBackground.deformMatrix
}
}
BlobRect {
id: toolsBackground
anchors.fill: parent
bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
deformScale: 0
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
}
CustomRect {
id: toolBox
anchors.left: parent.left
anchors.right: parent.right
height: Math.min(implicitHeight, parent.height)
implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2
radius: Tokens.rounding.small
StateLayer {
onClicked: {
toolsBg.open = true;
}
}
ColumnLayout {
id: toolList
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Tokens.padding.medium
Repeater {
id: repeater
model: root.current.pendingToolCalls?.slice(0, 4) ?? []
delegate: RowLayout {
id: toolText
required property var modelData
required property int index
spacing: Tokens.spacing.medium
function toolTarget(seg: var): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
MaterialIcon {
color: Colors.palette.m3onPrimaryContainer
text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet"
fill: 1
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("%1").arg(toolText.modelData.name)
}
MaterialIcon {
color: Colors.palette.m3onPrimaryContainer
text: "arrow_right_alt"
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData))
}
}
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length)
visible: repeater.count < root.current.pendingToolCalls.length
}
}
}
Loader {
id: toolsContent
active: opacity > 0
anchors.fill: parent
asynchronous: false
opacity: 0
sourceComponent: MouseArea {
onWheel: event => event.accepted = true
preventStealing: true
implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2
VerticalFadeListView {
id: innerLayout
anchors.fill: parent
anchors.margins: Tokens.padding.small
cacheBuffer: height * 10
fadeAmount: 0.1
fadeEnabled: interactive
spacing: 0
clip: true
interactive: contentHeight > toolsBg.maxHeight
model: root.current.pendingToolCalls ?? []
delegate: Item {
id: toolCall
required property var modelData
property bool expanded: false
implicitWidth: CustomListView.view.width
implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins)
function toolTarget(seg: var): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
Behavior on implicitHeight {
Anim {}
}
RowLayout {
id: collapsedRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
IconButton {
id: toolExpandBtn
font: Tokens.font.icon.medium
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
rotation: toolCall.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {}
}
onClicked: toolCall.expanded = !toolCall.expanded
}
CustomText {
id: preview
Layout.fillWidth: true
text: toolCall.modelData.name
}
}
CustomClippingRect {
id: expandedTool
anchors.top: collapsedRow.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
anchors.topMargin: Tokens.spacing.medium
implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2
radius: Tokens.rounding.medium
color: Colors.palette.m3surfaceContainerLow
CustomText {
id: fullToolCall
anchors.fill: parent
anchors.margins: Tokens.padding.medium
text: toolCall.toolTarget(toolCall.modelData)
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
}
}
}
}
}
}
}
}
ButtonRow {
id: confirmationRow
Layout.alignment: Qt.AlignRight
spacing: Tokens.spacing.medium
IconTextButton {
icon: "check_circle"
text: qsTr("Allow")
horizontalPadding: Tokens.padding.large
verticalPadding: Tokens.padding.small
isRound: true
shapeMorph: true
onClicked: root.current.approveTools()
}
IconTextButton {
icon: "cancel"
text: qsTr("Deny")
isRound: true
shapeMorph: true
inactiveColor: Colors.palette.m3error
inactiveOnColor: Colors.palette.m3onError
horizontalPadding: Tokens.padding.large
verticalPadding: Tokens.padding.small
onClicked: root.current.denyTools()
}
}
}
}
}
@@ -34,8 +34,12 @@ Item {
});
const attach = () => {
incubator.object.anchors.fill = container;
incubator.object.anchors.top = container.top;
incubator.object.anchors.bottom = container.bottom;
incubator.object.anchors.margins = Tokens.padding.medium;
incubator.object.anchors.horizontalCenter = container.horizontalCenter;
incubator.object.requestClose.connect(root.requestClose);
incubator.object.implicitWidth = Qt.binding(() => Math.max(Math.min(container.width - Tokens.padding.medium * 2, 800), Config.sidebar.sizes.width));
currentItem = incubator.object;
root.loading = false;
if (!noAnim)
@@ -10,12 +10,13 @@ Item {
id: root
required property bool isUser
required property LlmSegment segment
required property string text
required property var blocks
required property bool running
required property bool hovered
required property Repeater repeater
required property int index
required property ChatMessage message
required property ChatGeneration current
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
@@ -34,7 +35,7 @@ Item {
radius: Tokens.rounding.medium
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitWidth: layoutFrozen ? frozenWidth : layoutWidth
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : markdown.implicitHeight + markdown.anchors.topMargin * 2
anchors.right: root.isUser ? parent.right : undefined
layer.enabled: false
layer.smooth: true
@@ -74,7 +75,7 @@ Item {
TextEditBase {
id: msgText
property string cachedText: root.segment.text
property string cachedText: root.text
property bool cancelled: false
visible: root.isUser
@@ -88,7 +89,7 @@ Item {
font: Tokens.font.body.small
readOnly: true
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
text: root.isUser ? root.segment.text : ""
text: root.isUser ? root.text : ""
textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap
@@ -129,23 +130,22 @@ Item {
}
}
// Assistant content: parsed markdown blocks.
MarkdownBlocks {
id: blocks
id: markdown
visible: !root.isUser
anchors.topMargin: Tokens.padding.medium
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
blocks: root.segment.markdown
blocks: root.blocks
}
}
Actions {
id: actionsRow
readonly property bool shouldBeActive: (root.segment.type === LlmSegment.Type.Content) && !root.segment.running && root.repeater.count === (root.index + 1)
readonly property bool shouldBeActive: !root.running && root.repeater.count === (root.index + 1)
anchors.left: parent.left
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
@@ -156,8 +156,7 @@ Item {
edit: msgText
hovered: root.hovered
isUser: root.isUser
segment: root.segment
current: root.current
running: root.running
message: root.message
Behavior on opacity {
@@ -5,28 +5,28 @@ import qs.Components
import qs.Services
ColumnLayout {
id: root
id: root
spacing: Tokens.spacing.small
spacing: Tokens.spacing.small
Item {
Layout.fillHeight: true
}
Item {
Layout.fillHeight: true
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant
font.pointSize: 36
text: "chat"
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant
fontStyle: Tokens.font.icon.extraLarge
text: "chat"
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("Send a message to get started")
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("Send a message to get started")
}
Item {
Layout.fillHeight: true
}
Item {
Layout.fillHeight: true
}
}
@@ -108,7 +108,7 @@ Item {
wrapMode: CustomText.WordWrap
color: Colors.palette.m3onErrorContainer
text: root.current.errorMessage || Chat.serverError
text: root.current.errorMessage || LlmClient.serverError
}
}
@@ -23,10 +23,10 @@ Column {
role: "type"
DelegateChoice {
roleValue: LlmMarkdown.Type.Code
roleValue: MarkdownBlock.Type.Code
delegate: CodeBlockView {
required property var modelData
required property MarkdownBlock modelData
language: modelData.language
code: modelData.code
@@ -37,10 +37,10 @@ Column {
}
DelegateChoice {
roleValue: LlmMarkdown.Type.Math
roleValue: MarkdownBlock.Type.Math
delegate: MathBlockView {
required property var modelData
required property MarkdownBlock modelData
latex: modelData.latex
anchors.right: parent.right
@@ -50,10 +50,10 @@ Column {
}
DelegateChoice {
roleValue: LlmMarkdown.Type.Heading
roleValue: MarkdownBlock.Type.Heading
delegate: TextEditBase {
required property var modelData
required property MarkdownBlock modelData
color: Colors.palette.m3onSurface
anchors.right: parent.right
@@ -74,10 +74,10 @@ Column {
}
DelegateChoice {
roleValue: LlmMarkdown.Type.Text
roleValue: MarkdownBlock.Type.Text
delegate: BubbleEdit {
required property var modelData
required property MarkdownBlock modelData
anchors.right: parent.right
anchors.left: parent.left
@@ -19,7 +19,16 @@ MouseArea {
required property ChatListView chatList
readonly property bool isUser: modelData.role === ChatMessage.Role.User
readonly property var blocks: blockify(current.segments, current.error)
readonly property var blocks: root.isUser ? [
{
kind: "content",
id: 0,
text: root.modelData.content,
markdown: [],
running: false
}
] : blockify(current.segments, current.error)
property ChatGeneration current: modelData.activeGeneration
property bool reasoningExpanded: false
@@ -54,7 +63,9 @@ MouseArea {
blocks.push({
kind: "content",
id: i,
segments: [seg]
text: seg.text,
markdown: seg.markdown,
running: seg.running
});
} else {
const last = blocks.length ? blocks[blocks.length - 1] : null;
@@ -330,11 +341,12 @@ MouseArea {
required property var modelData
isUser: root.isUser
segment: modelData.segments[0]
text: modelData.text
blocks: modelData.markdown
running: modelData.running
width: root.width
repeater: segmentRep
message: root.modelData
current: root.current
hovered: root.containsMouse
onEdit: text => {
@@ -0,0 +1,351 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import ZShell.Components
import ZShell.Llm
import ZShell.Blobs
import qs.Modules.Notifications.Sidebar.Chat
import qs.Components
import qs.Services
Item {
id: root
required property Item rootParent
required property Item fabRoot
property bool open: false
property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2
property int openHeight: Math.min(modelsContent.item?.implicitHeight, maxHeight)
property int openWidth: root.rootParent.width - Tokens.padding.medium * 2
property real openHeightProg: openHeight / maxHeight
property int collapsedY: 0
property int horizontalContentMargin
property string header: qsTr("Available models")
property string acceptLabel: qsTr("Accept")
z: open || dialogTransition.running ? 2 : 0
Layout.fillWidth: true
implicitHeight: fabRoot.implicitHeight
function prettyModelName(): string {
if (modelsContent.item?.modelsList.count < 1)
return qsTr("No models found");
const pathList = LlmClient.model.split("\/");
const name = pathList[pathList.length - 1];
if (!name)
return qsTr("No model selected");
return name;
}
Behavior on implicitHeight {
Anim {}
}
function reparentWrapper(): void {
const newParent = open ? root.rootParent : root;
const c = modelsWrapper.parent.mapToItem(newParent, modelsWrapper.x + modelsWrapper.width / 2, modelsWrapper.y + modelsWrapper.height / 2);
root.collapsedY = modelsWrapper.y;
modelsWrapper.parent = newParent;
modelsWrapper.x = c.x - modelsWrapper.width / 2;
modelsWrapper.y = c.y - modelsWrapper.height / 2;
}
BlobGroup {
id: blobGroup
color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer
Behavior on color {
CAnim {}
}
}
MouseArea {
id: backdrop
anchors.fill: parent
enabled: false
hoverEnabled: enabled
preventStealing: true
parent: root.open ? root.rootParent : root
onClicked: root.open = false
}
Item {
id: modelsWrapper
width: root.width
height: modelBox.implicitHeight
z: 1
states: State {
name: "open"
when: root.open
PropertyChanges {
backdrop.enabled: true
modelsBackground.radius: Tokens.rounding.largeIncreased
modelsContent.opacity: 1
modelsWrapper.height: root.openHeight
modelsWrapper.width: root.openWidth
modelsWrapper.x: (root.rootParent.width - root.openWidth) / 2
modelsWrapper.y: root.rootParent.height - root.openHeight - Tokens.spacing.medium - Tokens.padding.medium - root.fabRoot.implicitHeight
elevation.opacity: 1
modelBox.opacity: 0
}
}
transitions: Transition {
id: dialogTransition
SequentialAnimation {
ScriptAction {
script: root.reparentWrapper()
}
Anim {
properties: "x,y"
}
}
PropertyAction {
property: "enabled"
}
Anim {
properties: "opacity,radius"
type: Anim.DefaultEffects
}
Anim {
properties: "width,height"
}
}
Elevation {
id: elevation
anchors.fill: parent
level: 4
opacity: 0
radius: modelsBackground.radius
transform: Matrix4x4 {
matrix: modelsBackground.deformMatrix
}
}
BlobRect {
id: modelsBackground
anchors.fill: parent
radius: modelLayer.pressed ? Tokens.rounding.small : Tokens.rounding.extraLarge
deformScale: 0
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
Behavior on radius {
Anim {
type: Anim.DefaultEffects
}
}
}
CustomRect {
id: modelBox
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
implicitHeight: root.fabRoot.implicitHeight
radius: modelsBackground.radius
CustomText {
id: label
text: root.prettyModelName()
color: Colors.palette.m3onSurfaceVariant
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter
}
MaterialIcon {
color: Colors.palette.m3onSurfaceVariant
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter
text: "unfold_more"
fontStyle: Tokens.font.icon.large
}
StateLayer {
id: modelLayer
onClicked: {
ChatState.fabExpanded = false;
root.open = true;
}
}
}
Loader {
id: modelsContent
active: opacity > 0
anchors.fill: parent
asynchronous: false
opacity: 0
sourceComponent: MouseArea {
readonly property alias modelsList: modelsList
onWheel: event => event.accepted = true
preventStealing: true
clip: true
implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins * 2
ColumnLayout {
id: innerLayout
anchors.bottomMargin: Tokens.padding.largeIncreased
anchors.fill: parent
anchors.margins: Tokens.padding.extraLarge
spacing: 0
CustomText {
font: Tokens.font.title.builders.large.weight(Font.Normal).build()
text: root.header
}
VerticalFadeListView {
id: modelsList
Layout.fillWidth: true
Layout.leftMargin: root.horizontalContentMargin
Layout.topMargin: Tokens.spacing.medium
Layout.bottomMargin: Tokens.spacing.medium
Layout.rightMargin: root.horizontalContentMargin
Layout.preferredHeight: 400
Layout.maximumHeight: 400
model: LlmClient.availableModels
currentIndex: LlmClient.availableModels.indexOf(LlmClient.model)
visible: opacity > 0
Behavior on opacity {
Anim {}
}
CustomScrollBar.vertical: CustomScrollBar {
flickable: modelsList
}
delegate: CustomRect {
id: model
required property var modelData
required property int index
readonly property bool selected: ListView.view.currentIndex === model.index
implicitWidth: ListView.view.width
implicitHeight: name.implicitHeight + Tokens.padding.small * 2
radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium
color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0)
Behavior on radius {
Anim {
type: Anim.SlowEffects
}
}
RowLayout {
id: layout
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Tokens.padding.small
anchors.leftMargin: Tokens.padding.large
anchors.rightMargin: Tokens.padding.large
CustomText {
id: name
text: model.modelData
elide: CustomText.ElideRight
color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
Layout.fillWidth: true
}
MaterialIcon {
id: check
color: Colors.palette.m3onTertiaryContainer
fontStyle: Tokens.font.icon.medium
opacity: model.selected ? 1 : 0
visible: opacity > 0
text: "check"
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
}
}
StateLayer {
id: stateLayer
onClicked: {
model.ListView.view.currentIndex = model.index;
}
}
}
}
RowLayout {
Layout.alignment: Qt.AlignRight
spacing: Tokens.spacing.extraSmall
TextButton {
horizontalPadding: Tokens.padding.largeIncreased
isRound: true
text: qsTr("Cancel")
type: TextButton.Text
verticalPadding: Tokens.padding.small
stateLayer.preventStealing: true
onClicked: {
root.open = false;
}
}
TextButton {
horizontalPadding: Tokens.padding.largeIncreased
isRound: true
text: root.acceptLabel
type: TextButton.Text
stateLayer.preventStealing: true
verticalPadding: Tokens.padding.small
onClicked: {
LlmClient.selectModel(modelsList.currentItem.modelData);
root.open = false;
}
}
}
}
}
}
}
}
@@ -18,9 +18,9 @@ Item {
required property ChatGeneration current
readonly property bool isActive: index === blocks.length - 1
property bool expanded: false
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1]
readonly property var lastSegment: root.segments[root.segments.length - 1]
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s))
readonly property bool pendingApproval: current.toolApprovalPending && segments.some(s => s.type === LlmSegment.Type.ToolCall && s.status === LlmToolCall.Status.Pending)
implicitHeight: {
const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin;
@@ -112,396 +112,17 @@ Item {
}
}
Item {
ApprovalPrompt {
id: approvalPrompt
anchors.top: layout.bottom
anchors.topMargin: Tokens.spacing.small
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2
visible: root.pendingApproval
CustomRect {
id: approvalBg
anchors.fill: parent
radius: Tokens.rounding.medium
color: Colors.palette.m3surface
border.width: 1
border.color: Colors.palette.m3outlineVariant
ColumnLayout {
id: approvalLayout
anchors.fill: parent
anchors.margins: Tokens.padding.medium
spacing: Tokens.spacing.small
CustomText {
id: requestHeader
text: qsTr("Tool call request")
font: Tokens.font.headline.medium
color: Colors.palette.m3onSurface
}
Item {
id: toolsBg
property bool open: false
property Item rootParent: root.rootParent
property int maxHeight: rootParent.height - Tokens.padding.medium * 2
property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight)
property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2
property real openHeightProg: openHeight / maxHeight
property int collapsedY: 0
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
z: open || dialogTransition.running ? 2 : 0
Layout.fillWidth: true
function reparentWrapper(): void {
const newParent = open ? toolsBg.rootParent : toolsBg;
const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2);
toolsBg.collapsedY = toolsWrapper.y;
toolsWrapper.parent = newParent;
toolsWrapper.x = c.x - toolsWrapper.width / 2;
toolsWrapper.y = c.y - toolsWrapper.height / 2;
}
onOpenChanged: {
if (open)
toolsBg.rootParent.interactive = false;
else
toolsBg.rootParent.interactive = true;
}
BlobGroup {
id: blobGroup
color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer
Behavior on color {
CAnim {}
}
}
MouseArea {
id: backdrop
anchors.fill: parent
enabled: false
hoverEnabled: enabled
preventStealing: true
parent: toolsBg.open ? toolsBg.rootParent : toolsBg
onClicked: toolsBg.open = false
}
Item {
id: toolsWrapper
width: toolsBg.width
height: toolBox.implicitHeight
rotation: parent === toolsBg.rootParent ? 180 : 0
z: 1
states: State {
name: "open"
when: toolsBg.open
PropertyChanges {
backdrop.enabled: true
toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased
toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased
toolsContent.opacity: 1
toolsWrapper.height: toolsBg.openHeight
toolsWrapper.width: toolsBg.openWidth
toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2
toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium
elevation.opacity: 1
toolBox.opacity: 0
}
}
transitions: Transition {
id: dialogTransition
SequentialAnimation {
ScriptAction {
script: toolsBg.reparentWrapper()
}
Anim {
properties: "x,y"
}
}
PropertyAction {
property: "enabled"
}
Anim {
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
type: Anim.DefaultEffects
}
Anim {
properties: "width,height"
}
}
Elevation {
id: elevation
anchors.fill: parent
bottomLeftRadius: toolsBackground.bottomLeftRadius
bottomRightRadius: toolsBackground.bottomRightRadius
level: 4
opacity: 0
radius: toolsBackground.radius
transform: Matrix4x4 {
matrix: toolsBackground.deformMatrix
}
}
BlobRect {
id: toolsBackground
anchors.fill: parent
bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
deformScale: 0
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
}
CustomRect {
id: toolBox
anchors.left: parent.left
anchors.right: parent.right
height: Math.min(implicitHeight, parent.height)
implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2
radius: Tokens.rounding.small
StateLayer {
onClicked: {
toolsBg.open = true;
}
}
ColumnLayout {
id: toolList
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Tokens.padding.medium
Repeater {
id: repeater
model: root.current.pendingToolCalls?.slice(0, 4) ?? []
delegate: RowLayout {
id: toolText
required property LlmSegment modelData
required property int index
spacing: Tokens.spacing.medium
function toolTarget(seg: LlmSegment): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
MaterialIcon {
color: Colors.palette.m3onPrimaryContainer
text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet"
fill: 1
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("%1").arg(toolText.modelData.name)
}
MaterialIcon {
color: Colors.palette.m3onPrimaryContainer
text: "arrow_right_alt"
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData))
}
}
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length)
visible: repeater.count < root.current.pendingToolCalls.length
}
}
}
Loader {
id: toolsContent
active: opacity > 0
anchors.fill: parent
asynchronous: false
opacity: 0
sourceComponent: MouseArea {
onWheel: event => event.accepted = true
preventStealing: true
implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2
VerticalFadeListView {
id: innerLayout
anchors.fill: parent
anchors.margins: Tokens.padding.small
cacheBuffer: height * 10
fadeAmount: 0.1
fadeEnabled: interactive
spacing: 0
clip: true
interactive: contentHeight > toolsBg.maxHeight
model: root.current.pendingToolCalls ?? []
delegate: Item {
id: toolCall
required property LlmSegment modelData
property bool expanded: false
implicitWidth: CustomListView.view.width
implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins)
function toolTarget(seg: LlmSegment): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
Behavior on implicitHeight {
Anim {}
}
RowLayout {
id: collapsedRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
IconButton {
id: toolExpandBtn
font: Tokens.font.icon.medium
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
rotation: toolCall.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {}
}
onClicked: toolCall.expanded = !toolCall.expanded
}
CustomText {
id: preview
Layout.fillWidth: true
text: toolCall.modelData.name
}
}
CustomClippingRect {
id: expandedTool
anchors.top: collapsedRow.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
anchors.topMargin: Tokens.spacing.medium
implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2
radius: Tokens.rounding.medium
color: Colors.palette.m3surfaceContainerLow
CustomText {
id: fullToolCall
anchors.fill: parent
anchors.margins: Tokens.padding.medium
text: toolCall.toolTarget(toolCall.modelData)
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
}
}
}
}
}
}
}
}
ButtonRow {
id: confirmationRow
Layout.alignment: Qt.AlignRight
spacing: Tokens.spacing.medium
IconTextButton {
icon: "check_circle"
text: qsTr("Allow")
horizontalPadding: Tokens.padding.large
verticalPadding: Tokens.padding.small
isRound: true
shapeMorph: true
onClicked: root.current.approveTools()
}
IconTextButton {
icon: "cancel"
text: qsTr("Deny")
isRound: true
shapeMorph: true
inactiveColor: Colors.palette.m3error
inactiveOnColor: Colors.palette.m3onError
horizontalPadding: Tokens.padding.large
verticalPadding: Tokens.padding.small
onClicked: root.current.denyTools()
}
}
}
}
rootParent: root.rootParent
current: root.current
}
CustomClippingRect {
@@ -533,7 +154,7 @@ Item {
delegate: Item {
id: segment
required property LlmSegment modelData
required property var modelData
required property int index
implicitWidth: ListView.view.width
@@ -560,7 +181,7 @@ Item {
CustomText {
id: content
function toolTarget(seg: LlmSegment): string {
function toolTarget(seg: var): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
@@ -573,29 +194,29 @@ Item {
return String(seg.arguments);
}
function prettyTool(seg: LlmSegment): string {
function prettyTool(seg: var): string {
if (seg.name === "webfetch") {
switch (seg.status) {
case LlmSegment.Status.Running:
case LlmToolCall.Status.Running:
return qsTr("Fetching");
case LlmSegment.Status.Pending:
case LlmToolCall.Status.Pending:
return qsTr("Pending fetch");
case LlmSegment.Status.Success:
case LlmToolCall.Status.Success:
return qsTr("Fetched");
case LlmSegment.Status.Error:
case LlmToolCall.Status.Error:
return qsTr("Failed to fetch");
default:
return qsTr("Fetched website");
}
} else if (seg.name === "readfile") {
switch (seg.status) {
case LlmSegment.Status.Running:
case LlmToolCall.Status.Running:
return qsTr("Reading");
case LlmSegment.Status.Pending:
case LlmToolCall.Status.Pending:
return qsTr("Pending read");
case LlmSegment.Status.Success:
case LlmToolCall.Status.Success:
return qsTr("Read");
case LlmSegment.Status.Error:
case LlmToolCall.Status.Error:
return qsTr("Failed to read");
default:
return qsTr("Read file");
@@ -65,9 +65,7 @@ Item {
}
}
model: ScriptModel {
values: Chat.chats.values
}
model: LlmClient.chats
onLoadChatRequest: (chat, index) => {
ChatState.currentIdx = index;
@@ -75,17 +73,13 @@ Item {
}
onDeleteChatRequest: chat => {
if (chat === ChatState.chatSession) {
ChatState.chatSession = null;
ChatState.currentIdx = -1;
}
Chat.chats.remove(chat);
}
onNewChatRequest: {
const data = Chat.chats.insert();
ChatState.currentIdx = 0;
ChatState.chatSession = data;
const wasCurrent = chat === ChatState.chatSession;
LlmClient.chats.remove(chat);
if (wasCurrent)
ChatState.openDraft();
}
onNewChatRequest: ChatState.openDraft()
onRequestExpand: {
ChatState.narrowSidebarExpanded = true;
@@ -126,9 +120,8 @@ Item {
z: 0
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins * 2
implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2
states: State {
name: "wide"
@@ -151,40 +144,13 @@ Item {
ChatHost {
id: convHost
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: Math.max(Math.min(parent.width, 800), Config.sidebar.sizes.width)
anchors.fill: parent
clip: true
onLoadingChanged: if (!loading)
root.chatOpen = true
}
ColumnLayout {
id: noChatLayout
anchors.centerIn: parent
opacity: ChatState.chatSession ? 0 : 1
visible: opacity > 0
Behavior on opacity {
Anim {}
}
MaterialIcon {
Layout.alignment: Qt.AlignCenter
text: "chat_bubble_off"
fontStyle: Tokens.font.icon.extraLarge
color: Colors.tPalette.m3onSurfaceVariant
}
CustomText {
Layout.alignment: Qt.AlignCenter
text: qsTr("Start or create a chat")
color: Colors.tPalette.m3onSurfaceVariant
}
}
MouseArea {
id: dimArea
anchors.fill: parent
@@ -196,8 +162,10 @@ Item {
const x = mouseX;
const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2;
if (x > invertedRect.borderLeft - margin)
if (x > invertedRect.borderLeft - margin) {
ChatState.narrowSidebarExpanded = false;
ChatState.fabExpanded = false;
}
}
HoverHandler {
+57 -3
View File
@@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound
import ZShell.Config
import ZShell.Llm
import QtQuick
import QtQuick.Layouts
import qs.Components
@@ -35,10 +36,10 @@ Item {
visible: height > 0
}
CustomClippingRect {
Item {
Layout.fillHeight: true
Layout.fillWidth: true
radius: Tokens.rounding.small
clip: true
Item {
id: pages
@@ -58,6 +59,7 @@ Item {
}
CustomRect {
id: bg
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
@@ -96,11 +98,63 @@ Item {
CustomRect {
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
radius: Tokens.rounding.large
ChatPanel {
anchors.fill: parent
}
GeminiGlow {
innerGlow: 50
z: 5
opacity: LlmClient.busy ? 1.0 : 0.0
glowSize: 0
visible: opacity > 0 && !ChatState.isWindow
layer.enabled: true
layer.effect: OpacityMask {
maskSource: fadeMask
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Rectangle {
id: fadeMask
readonly property real fadeStartPos: {
const h = Math.max(1, height);
const nh = (h - h / 3) / h;
return nh;
}
anchors.fill: parent
layer.enabled: true
visible: false
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop {
color: Qt.rgba(0, 0, 0, 0)
position: 0
}
GradientStop {
color: Qt.rgba(0, 0, 0, 0)
position: fadeMask.fadeStartPos
}
GradientStop {
color: Qt.rgba(0, 0, 0, 1.0)
position: 1.0
}
}
}
}
}
}
+2 -2
View File
@@ -30,9 +30,9 @@ Item {
}
Connections {
target: Chat
target: LlmClient
function onErrorOccurred(message: string): void {
function onGenerationFailed(session: ChatSession, message: string): void {
if (root.shouldBeActive && root.chatActive)
return;
+2 -1
View File
@@ -51,7 +51,8 @@ CustomRect {
value: Memory.percentage
Behavior on clampedVal {
Anim {}
Anim {
}
}
ColumnLayout {
+1 -1
View File
@@ -121,6 +121,6 @@ CustomRect {
activeIcon: "storage"
icon: modelData === Storage.primaryDisk ? "check" : ""
text: modelData.mount
text: modelData.name
}
}
+9
View File
@@ -0,0 +1,9 @@
import Quickshell
import QtQuick
import qs.Helpers
Scope {
Component.onCompleted: {
Wallpapers;
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ CustomRect {
bottomLeftRadius: last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
bottomRightRadius: last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
color: Colors.tPalette.m3surfaceContainer
opacity: enabled && parent.enabled ? 1 : 0.5
opacity: enabled && (parent?.enabled ?? false) ? 1 : 0.5
topLeftRadius: first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
topRightRadius: first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
+4 -4
View File
@@ -141,11 +141,11 @@ Item {
anchors.fill: parent
bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
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
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
}
RowButton {
@@ -154,11 +154,11 @@ Item {
anchors.left: parent.left
anchors.right: parent.right
color: "transparent"
first: root.first
height: Math.min(implicitHeight, parent.height)
icon: root.icon
subtext: root.subtext
last: root.last
first: root.first
subtext: root.subtext
text: root.label
transform: Matrix4x4 {
@@ -272,6 +272,10 @@ Item {
retainWhileLoading: true
smooth: true
source: Wallpapers.current
sourceSize.height: {
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1;
return imageSlot.height * dpr;
}
Behavior on opacity {
Anim {
-12
View File
@@ -42,18 +42,6 @@ Item {
objectName: "PageContainer"
Component.onCompleted: root.loadPage(root.sState.currentPageIdx)
// CustomRect {
// anchors.fill: parent
// color: Qt.alpha(Colors.palette.m3shadow, 0.3)
// opacity: root.sState.dimmed ? 1 : 0
// visible: opacity > 0
// z: 0
//
// Behavior on opacity {
// Anim {}
// }
// }
}
Connections {
+2 -2
View File
@@ -52,11 +52,11 @@ PageBase {
anchors.leftMargin: Tokens.padding.largeIncreased
anchors.margins: Tokens.padding.small
anchors.rightMargin: Tokens.padding.largeIncreased
spacing: Tokens.spacing.small
spacing: Tokens.spacing.medium
IconImage {
asynchronous: true
implicitSize: Math.round(Tokens.font.icon.large * 1.8)
implicitSize: Math.round(Tokens.font.icon.large.pointSize * 1.8)
source: Quickshell.iconPath(appItem.modelData.icon, "image-missing")
}
@@ -88,6 +88,7 @@ PageBase {
id: selectScheme
acceptLabel: qsTr("Confirm")
settingAnchor: "panels-sidebar-llm-scheme"
enabled: Object.keys(model).length > 0
header: qsTr("Select scheme")
first: true
-51
View File
@@ -1,51 +0,0 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Hyprland
import QtQuick
import qs.Components
import qs.Helpers
import ZShell.Internal
Item {
id: root
readonly property var displayData: Wallpapers.getCrop(screen.name)
required property ShellScreen screen
property size screenResolution: Qt.size(screen.width * screenScale, screen.height * screenScale)
property real screenScale: Hyprland.monitorFor(screen).scale
anchors.fill: parent
Component.onCompleted: Hyprland.refreshMonitors()
WallpaperImage {
id: img
anchors.fill: parent
cropHeight: root.displayData?.height ?? 1.0
cropWidth: root.displayData?.width ?? 1.0
cropX: root.displayData?.x ?? 0.0
cropY: root.displayData?.y ?? 0.0
fadeDuration: 150
screenResolution: root.screenResolution
source: Wallpapers.current
Behavior on cropHeight {
Anim {
}
}
Behavior on cropWidth {
Anim {
}
}
Behavior on cropX {
Anim {
}
}
Behavior on cropY {
Anim {
}
}
}
}
-48
View File
@@ -1,48 +0,0 @@
import Quickshell
import QtQuick
import Quickshell.Wayland
import ZShell.Config
import qs.Modules.DesktopIcons
Loader {
active: Config.background.enabled
asynchronous: false
sourceComponent: Variants {
model: Quickshell.screens
PanelWindow {
id: root
required property var modelData
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.namespace: "ZShell-Wallpaper"
color: "transparent"
screen: modelData
surfaceFormat.opaque: true
anchors {
bottom: true
left: true
right: true
top: true
}
WallBackground {
screen: root.screen
}
Loader {
id: loader
active: Config.general.desktopIcons
anchors.fill: parent
sourceComponent: DesktopIcons {
}
}
}
}
}
+22 -1
View File
@@ -1,5 +1,25 @@
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick QuickPrivate Concurrent Sql Network DBus CanvasPainter)
find_package(Qt6 REQUIRED COMPONENTS
ShaderTools
Core
Qml
Gui
GuiPrivate
Quick
CanvasPainter
QuickPrivate
Concurrent
Sql
Network
DBus
CanvasPainter
WaylandClient
WaylandClientPrivate
)
find_package(PkgConfig REQUIRED)
pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl)
pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl)
pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl)
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
@@ -17,6 +37,7 @@ qt_standard_project_setup(REQUIRES 6.9)
include(cmake/pch.cmake)
include(cmake/qml-module.cmake)
include(cmake/wayland-protocol.cmake)
add_library(zshell-util INTERFACE)
target_include_directories(zshell-util INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include")
+6 -1
View File
@@ -138,11 +138,16 @@ void BlobGroup2::ensureDataBuilt() {
const double extent = static_cast<double>(smoothFactor) * 1.5 +
static_cast<double>(m_shadowRadius);
// Group order, no pruning: the shader sees this list for every pixel.
// Group order, no pruning by proximity: the shader sees this list for every pixel. A
// shape that is not pushing is not in the list at all: shapes here are data for the
// group draw, so leaving a panel at rest out means it cannot define any part of the
// edge. A closing panel is still in the list while its curve runs out, even with no
// size left, which is what keeps the bounce.
// entryShapes maps a shared entry back to the shape it came from.
QVector<BlobShape2*> entryShapes;
for (auto* shape : std::as_const(m_shapes)) {
if (!shape->isMoving()) continue;
if (shape->width() <= 0 || shape->height() <= 0) continue;
if (static_cast<int>(m_data.rects.size()) >= BlobGroupData::kMaxRects)
continue;
+3 -7
View File
@@ -50,9 +50,9 @@ class BlobRect : public BlobShape {
[[nodiscard]] qreal damping() const { return m_damping; }
void setDamping(qreal d) {
if (!qFuzzyCompare(m_damping, d)) {
m_damping = d;
void setDamping(qreal value) {
if (!qFuzzyCompare(m_damping, value)) {
m_damping = value;
emit dampingChanged();
}
}
@@ -110,7 +110,6 @@ class BlobRect : public BlobShape {
void checkAtRest(float speed);
void beginRetire();
// Physics state
QPointF m_prevScenePos;
QElapsedTimer m_elapsed;
bool m_physicsActive = false;
@@ -122,13 +121,10 @@ class BlobRect : public BlobShape {
float m_retire01 = 0.0f;
float m_retire11 = 1.0f;
// Symmetric 2x2 deformation matrix components (3 independent: m00, m01,
// m11) Rest state is identity: m00=1, m01=0, m11=1
float m_dm00 = 1.0f;
float m_dm01 = 0.0f;
float m_dm11 = 1.0f;
// Spring velocities for each component
float m_dmVel00 = 0.0f;
float m_dmVel01 = 0.0f;
float m_dmVel11 = 0.0f;
+24
View File
@@ -22,6 +22,9 @@ class BlobRect2 : public BlobShape2 {
Q_PROPERTY(
QQmlListProperty<BlobRect2> excludeCorners READ excludeCorners NOTIFY
excludeCornersChanged)
Q_PROPERTY(
bool atRestOverride READ atRestOverride WRITE setAtRestOverride NOTIFY
atRestOverrideChanged)
Q_PROPERTY(
qreal topLeftRadius READ topLeftRadius WRITE setTopLeftRadius NOTIFY
topLeftRadiusChanged)
@@ -69,8 +72,24 @@ class BlobRect2 : public BlobShape2 {
QQmlListProperty<BlobRect2> exclude();
QQmlListProperty<BlobRect2> excludeCorners();
[[nodiscard]] bool atRestOverride() const { return m_atRestOverride; }
void setAtRestOverride(bool o) {
if (m_atRestOverride == o) return;
m_atRestOverride = o;
emit atRestOverrideChanged();
if (m_group) m_group->markDirty();
}
bool isExcluded(const BlobShape2* other) const override;
bool isCornerExcluded(const BlobShape2* other) const override;
// The shape that is pushing is the one that can shape the bar edge. A shape that is
// retiring still counts, because the retire curve is still moving the surface.
// atRestOverride keeps the shape in the group's data even at rest; when it is false
// the actual spring state decides, so it is not an override of the state itself.
bool isMoving() const override {
return m_atRestOverride || m_physicsActive || m_retiring;
}
void cornerRadii(float out[4]) const override;
[[nodiscard]] qreal topLeftRadius() const { return m_topLeftRadius; }
@@ -97,6 +116,7 @@ class BlobRect2 : public BlobShape2 {
void deformScaleChanged();
void excludeChanged();
void excludeCornersChanged();
void atRestOverrideChanged();
void topLeftRadiusChanged();
void topRightRadiusChanged();
void bottomLeftRadiusChanged();
@@ -116,6 +136,10 @@ class BlobRect2 : public BlobShape2 {
bool m_physicsActive = false;
bool m_hasPrevPos = false;
// Set from QML. Keeps the shape in the group's data while it is at rest; when it is
// false the spring state decides on its own.
bool m_atRestOverride = false;
bool m_retiring = false;
QElapsedTimer m_retireElapsed;
float m_retire00 = 1.0f;
+6 -10
View File
@@ -168,10 +168,6 @@ void BlobShape::updatePolish() {
if (other->width() <= 0 || other->height() <= 0) continue;
// Excluded shapes stay in the rect data. Exclusion only gates the smooth
// blend (via excludeMask below); ownership must still see every shape,
// otherwise two nodes can both believe they own a pixel and the pixel is
// blended twice.
const QPointF otherScene = other->mapToScene(QPointF(0, 0));
bool include = false;
@@ -196,7 +192,7 @@ void BlobShape::updatePolish() {
const QMatrix4x4& dm = other->m_deformMatrix;
const float a = dm(0, 0), b = dm(1, 0);
const float c = dm(0, 1), d = dm(1, 1);
const float c = dm(0, 1), e = dm(1, 1);
BlobRectData r;
r.cx = static_cast<float>(otherScene.x() + other->width() / 2.0);
@@ -207,19 +203,19 @@ void BlobShape::updatePolish() {
r.offsetX = dm(0, 3);
r.offsetY = dm(1, 3);
const float det = a * d - c * b;
const float det = a * e - c * b;
const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f;
r.invDeform[0] = d * invDet;
r.invDeform[0] = e * invDet;
r.invDeform[1] = -b * invDet;
r.invDeform[2] = -c * invDet;
r.invDeform[3] = a * invDet;
const float halfTr = 0.5f * (a + d);
const float halfDiff = 0.5f * (a - d);
const float halfTr = 0.5f * (a + e);
const float halfDiff = 0.5f * (a - e);
r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c);
r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh;
r.screenHalfY = std::abs(b) * r.hw + std::abs(d) * r.hh;
r.screenHalfY = std::abs(b) * r.hw + std::abs(e) * r.hh;
m_cachedRects.append(r);
rectShapes.append(other);
+4
View File
@@ -56,6 +56,10 @@ class BlobShape2 : public QQuickItem {
virtual bool isExcluded(const BlobShape2*) const { return false; }
// Whether this shape is part of the group's data. Only a pushing shape is, so a shape
// at rest has no part in the draw. BlobRect2 also honours the QML atRestOverride.
virtual bool isMoving() const { return false; }
virtual bool isCornerExcluded(const BlobShape2* /*other*/) const {
return false;
}
+2
View File
@@ -4,6 +4,7 @@ qml_module(ZShell-components
lazylistview.hpp lazylistview.cpp
chatlistview.hpp chatlistview.cpp
wavyline.hpp wavyline.cpp
wavylinerenderer.hpp wavylinerenderer.cpp
buttonrow.hpp buttonrow.cpp
carouselview.hpp carouselview.cpp
sparklinecontent.hpp sparklinecontent.cpp
@@ -13,5 +14,6 @@ qml_module(ZShell-components
LIBRARIES
Qt::Quick
Qt::QuickPrivate
Qt::CanvasPainter
ZShell-internal
)
+11 -11
View File
@@ -187,7 +187,7 @@ void CarouselView::setModel(const QVariantList& m) {
emit modelChanged();
const int newCount = m_model.size();
const int newCount = static_cast<int>(m_model.size());
if (newCount == 0) {
m_currentRealIndex = -1;
@@ -647,7 +647,7 @@ qreal CarouselView::sampleSize(qreal childLoc) const {
}
int CarouselView::realIndexOf(int virtualIndex) const {
const int count = m_model.size();
const int count = static_cast<int>(m_model.size());
if (count == 0) return -1;
@@ -668,7 +668,7 @@ qreal CarouselView::snapTargetX(qreal x) const {
}
void CarouselView::wrapContentIfNeeded() {
const int count = m_model.size();
const int count = static_cast<int>(m_model.size());
if (count == 0) return;
@@ -707,7 +707,7 @@ void CarouselView::wrapContentIfNeeded() {
}
void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) {
const int count = m_model.size();
const int count = static_cast<int>(m_model.size());
if (count == 0) return;
@@ -856,8 +856,8 @@ void CarouselView::positionSlot(const Slot& slot) const {
return rightIndex == n ? m_arrangement.outerSpacing : m_itemSpacing;
};
const auto leftGap = [this, n](int leftIndex) {
return leftIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing;
const auto leftGap = [this, n](int gapIndex) {
return gapIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing;
};
qreal center = 0;
@@ -974,7 +974,7 @@ void CarouselView::releaseAllItems() {
}
void CarouselView::relayout() {
const int count = m_model.size();
const int count = static_cast<int>(m_model.size());
if (count == 0 || width() <= 0 || m_focalWidth <= 0) {
for (Slot& s : m_slots) {
@@ -1038,7 +1038,7 @@ void CarouselView::relayout() {
m_slots.append(Slot{});
slotUsedThisPass.append(false);
freeSlot = m_slots.size() - 1;
freeSlot = static_cast<int>(m_slots.size()) - 1;
}
updateSlotContent(m_slots[freeSlot], v);
@@ -1092,7 +1092,7 @@ void CarouselView::snapToNearest() {
}
void CarouselView::goToIndex(int realIndex) {
const int count = m_model.size();
const int count = static_cast<int>(m_model.size());
if (count == 0) return;
@@ -1126,7 +1126,7 @@ void CarouselView::decrementCurrentIndex() {
}
void CarouselView::centerInstantlyOnReal(int realIndex) {
const int count = m_model.size();
const int count = static_cast<int>(m_model.size());
if (count == 0) return;
@@ -1220,7 +1220,7 @@ qreal CarouselView::sampledVelocity() const {
const VelocitySample& oldest = m_velocitySamples.first();
const VelocitySample& newest = m_velocitySamples.last();
const qreal dt = newest.t - oldest.t;
const qreal dt = static_cast<qreal>(newest.t - oldest.t);
if (dt < 8) return 0;
@@ -252,8 +252,6 @@ int ChatListView::count() const {
}
bool ChatListView::event(QEvent* e) {
// qDebug() << "ChatListView::event() runs";
return QQuickItem::event(e);
}
@@ -366,7 +364,6 @@ void ChatListView::updatePolish() {
m_animateY = false;
const int layoutSize = static_cast<int>(m_layout.size());
const qint64 now = m_clock.elapsed();
bool wrote = false;
for (auto& entry : m_delegates) {
if (!entry.item || entry.pendingRemoval || entry.pendingInsert)
continue;
@@ -397,7 +394,6 @@ void ChatListView::updatePolish() {
entry.item->setY(target);
entry.hasPropY = false;
}
wrote = true;
}
applyAnchor();
@@ -50,8 +50,6 @@ void SparklineContent::setLine2(internal::CircularBuffer* buffer) {
void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) {
if (!buffer) return;
// New data, or the buffer's own smoothingRadius changed (it re-emits
// valuesChanged in that case too) -> geometry is stale.
connect(
buffer,
&internal::CircularBuffer::valuesChanged,
@@ -67,7 +65,7 @@ void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) {
void SparklineContent::setLine1Color(const QColor& color) {
if (m_line1Color == color) return;
m_line1Color = color;
update(); // visual-only, no geometry rebuild needed
update();
}
void SparklineContent::setLine2Color(const QColor& color) {
@@ -173,7 +171,7 @@ SparklineContent::LineGeometry SparklineContent::buildGeometry(
if (m_curveSmoothing && points.size() > 2) {
const qreal tension = 1.0;
const qreal k = tension / 6.0;
const int n = points.size();
const int n = static_cast<int>(points.size());
for (int i = 0; i < n - 1; ++i) {
const QPointF& p0 = points[qMax(i - 1, 0)];
@@ -84,7 +84,6 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
points.reserve(len);
for (int i = 0; i < len; ++i) {
// Already smoothed once, incrementally, by the buffer itself.
const qreal value =
qBound<qreal>(0.0, buffer->smoothedAt(i), m_maxValue);
@@ -99,7 +98,7 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
if (m_curveSmoothing && points.size() > 2) {
const qreal tension = 1.0;
const qreal k = tension / 6.0;
const int n = points.size();
const int n = static_cast<int>(points.size());
for (int i = 0; i < n - 1; ++i) {
const QPointF& p0 = points[qMax(i - 1, 0)];
const QPointF& p1 = points[i];
@@ -125,9 +124,6 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
}
}
// Flatten the curve to a polyline once, here, on data change only.
// This is CPU math only -- no QPainter draw calls, no image, no
// texture upload happen as a result of this.
const auto subpaths = path.toSubpathPolygons();
if (subpaths.isEmpty()) return sample;
@@ -139,7 +135,7 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
void SparklineSceneItem::writeFillGeometry(
QSGGeometryNode* node, const LineSample& sample) {
const int n = sample.polyline.size();
const int n = static_cast<int>(sample.polyline.size());
auto* geometry = node->geometry();
geometry->allocate(n * 2);
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
@@ -147,8 +143,10 @@ void SparklineSceneItem::writeFillGeometry(
auto* vertices = geometry->vertexDataAsPoint2D();
for (int i = 0; i < n; ++i) {
const QPointF& p = sample.polyline[i];
vertices[i * 2].set(p.x(), p.y());
vertices[i * 2 + 1].set(p.x(), sample.fillBottom);
vertices[i * 2].set(
static_cast<float>(p.x()), static_cast<float>(p.y()));
vertices[i * 2 + 1].set(
static_cast<float>(p.x()), static_cast<float>(sample.fillBottom));
}
node->markDirty(QSGNode::DirtyGeometry);
@@ -156,7 +154,7 @@ void SparklineSceneItem::writeFillGeometry(
void SparklineSceneItem::writeStrokeGeometry(
QSGGeometryNode* node, const LineSample& sample, qreal lineWidth) {
const int n = sample.polyline.size();
const int n = static_cast<int>(sample.polyline.size());
auto* geometry = node->geometry();
geometry->allocate(n * 2);
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
@@ -179,8 +177,12 @@ void SparklineSceneItem::writeStrokeGeometry(
const QPointF& p = sample.polyline[i];
const QPointF offset = normal * half;
vertices[i * 2].set(p.x() + offset.x(), p.y() + offset.y());
vertices[i * 2 + 1].set(p.x() - offset.x(), p.y() - offset.y());
vertices[i * 2].set(
static_cast<float>(p.x() + offset.x()),
static_cast<float>(p.y() + offset.y()));
vertices[i * 2 + 1].set(
static_cast<float>(p.x() - offset.x()),
static_cast<float>(p.y() - offset.y()));
}
node->markDirty(QSGNode::DirtyGeometry);
@@ -243,7 +245,7 @@ QSGNode* SparklineSceneItem::updatePaintNode(
}
QMatrix4x4 matrix;
matrix.translate(-stepX() * m_slideProgress, 0);
matrix.translate(static_cast<float>(-stepX() * m_slideProgress), 0.0f);
transformNode->setMatrix(matrix);
const bool needsGeometryWrite = samplesRebuilt || justCreated;
+9 -96
View File
@@ -1,12 +1,10 @@
#include "wavyline.hpp"
#include <qpainter.h>
#include <qpainterpath.h>
#include "wavylinerenderer.hpp"
namespace ZShell::controls {
WavyLine::WavyLine(QQuickItem* parent)
: QQuickPaintedItem(parent)
: QCanvasPainterItem(parent)
, m_lineWidth(4)
, m_amplitudeMultiplier(0.5)
, m_frequency(6)
@@ -18,10 +16,13 @@ WavyLine::WavyLine(QQuickItem* parent)
, m_startAngle(0)
, m_fullAngle(360)
, m_radius(-1)
, m_value(1)
, m_startAngleRad(0)
, m_fullAngleRad(2 * M_PI) {
setAntialiasing(true);
, m_value(1) {
setFillColor(Qt::transparent);
setAlphaBlending(true);
}
QCanvasPainterItemRenderer* WavyLine::createItemRenderer() const {
return new WavyLineRenderer;
}
int WavyLine::lineWidth() const {
@@ -127,7 +128,6 @@ qreal WavyLine::startAngle() const {
void WavyLine::setStartAngle(qreal startAngle) {
if (!qFuzzyCompare(m_startAngle + 1.0, startAngle + 1.0)) {
m_startAngle = startAngle;
m_startAngleRad = startAngle * M_PI / 180.0;
emit startAngleChanged();
update();
}
@@ -140,7 +140,6 @@ qreal WavyLine::fullAngle() const {
void WavyLine::setFullAngle(qreal fullAngle) {
if (!qFuzzyCompare(m_fullAngle + 1.0, fullAngle + 1.0)) {
m_fullAngle = fullAngle;
m_fullAngleRad = fullAngle * M_PI / 180.0;
emit fullAngleChanged();
update();
}
@@ -170,90 +169,4 @@ void WavyLine::setValue(qreal value) {
}
}
void WavyLine::paint(QPainter* painter) {
painter->setRenderHint(QPainter::Antialiasing);
painter->setPen(QPen(m_color, m_lineWidth, Qt::SolidLine, Qt::RoundCap));
if (m_pathType == Arc) {
paintArc(painter);
} else {
paintLinear(painter);
}
}
void WavyLine::paintLinear(QPainter* painter) {
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const auto phase = m_waveProgress * 2 * M_PI;
const auto centerY = height() / 2;
const auto len = m_fullLength > 0 ? m_fullLength : 1;
const auto start = m_lineWidth / 2.0;
const auto fullEnd = width() - m_lineWidth / 2.0;
const auto drawEnd = start + (fullEnd - start) * m_value;
QPainterPath path;
bool first = true;
for (int x = m_lineWidth / 2; x <= drawEnd; ++x) {
const auto theta =
m_frequency * 2 * M_PI * (x + m_startX) / len + phase;
const auto waveY = centerY + amplitude * qSin(theta);
if (first) {
path.moveTo(x, waveY);
first = false;
} else {
path.lineTo(x, waveY);
}
}
painter->drawPath(path);
}
void WavyLine::paintArc(QPainter* painter) {
if (m_fullAngleRad <= 0) {
return;
}
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const auto cx = width() / 2.0;
const auto cy = height() / 2.0;
const auto radius =
m_radius > 0
? m_radius
: (qMin(width(), height()) - m_lineWidth - 2 * amplitude) / 2.0;
if (radius <= 0) {
return;
}
const auto phase = m_waveProgress * 2 * M_PI;
const auto arcLen = radius * m_fullAngleRad;
const auto len = m_fullLength > 0 ? m_fullLength : arcLen;
const auto drawAngleRad = m_fullAngleRad * m_value;
if (drawAngleRad <= 0) {
return;
}
const auto N = qMax(64, qCeil(radius * drawAngleRad));
const auto dTheta = drawAngleRad / N;
QPainterPath path;
for (int i = 0; i <= N; ++i) {
const auto theta = m_startAngleRad + i * dTheta;
const auto s = i * dTheta * radius;
const auto phi = m_frequency * 2 * M_PI * (s + m_startX) / len + phase;
const auto r = radius + amplitude * qSin(phi);
const auto px = cx + r * qCos(theta);
const auto py = cy + r * qSin(theta);
if (i == 0) {
path.moveTo(px, py);
} else {
path.lineTo(px, py);
}
}
painter->drawPath(path);
}
} // namespace ZShell::controls
+10 -8
View File
@@ -1,11 +1,14 @@
#pragma once
#include <qqmlintegration.h>
#include <qquickpainteditem.h>
#include <QCanvasPainterItem>
#include <QColor>
namespace ZShell::controls {
class WavyLine : public QQuickPaintedItem {
class WavyLineRenderer;
class WavyLine : public QCanvasPainterItem {
Q_OBJECT
QML_ELEMENT
@@ -82,8 +85,6 @@ class WavyLine : public QQuickPaintedItem {
[[nodiscard]] qreal value() const;
void setValue(qreal value);
void paint(QPainter* painter) override;
signals:
void lineWidthChanged();
void amplitudeMultiplierChanged();
@@ -98,9 +99,12 @@ class WavyLine : public QQuickPaintedItem {
void radiusChanged();
void valueChanged();
protected:
[[nodiscard]] QCanvasPainterItemRenderer* createItemRenderer()
const override;
private:
void paintLinear(QPainter* painter);
void paintArc(QPainter* painter);
friend class WavyLineRenderer;
int m_lineWidth;
qreal m_amplitudeMultiplier;
@@ -114,8 +118,6 @@ class WavyLine : public QQuickPaintedItem {
qreal m_fullAngle;
qreal m_radius;
qreal m_value;
qreal m_startAngleRad;
qreal m_fullAngleRad;
};
} // namespace ZShell::controls
@@ -0,0 +1,116 @@
#include "wavylinerenderer.hpp"
#include "wavyline.hpp"
#include <qcanvaspainter.h>
namespace ZShell::controls {
void WavyLineRenderer::synchronizeData(QCanvasPainterItem* item) {
auto* wavyLine = static_cast<WavyLine*>(item);
m_lineWidth = wavyLine->m_lineWidth;
m_amplitudeMultiplier = wavyLine->m_amplitudeMultiplier;
m_frequency = wavyLine->m_frequency;
m_startX = wavyLine->m_startX;
m_fullLength = wavyLine->m_fullLength;
m_color = wavyLine->m_color;
m_waveProgress = wavyLine->m_waveProgress;
m_pathType = wavyLine->m_pathType;
m_startAngle = wavyLine->m_startAngle;
m_fullAngle = wavyLine->m_fullAngle;
m_radius = wavyLine->m_radius;
m_value = wavyLine->m_value;
}
void WavyLineRenderer::paint(QCanvasPainter* painter) {
painter->clearRect(0, 0, width(), height());
painter->setStrokeStyle(m_color);
painter->setLineWidth(m_lineWidth);
painter->setLineCap(QCanvasPainter::LineCap::Round);
if (m_pathType == WavyLine::Arc) {
paintArc(painter);
} else {
paintLinear(painter);
}
}
void WavyLineRenderer::paintLinear(QCanvasPainter* painter) {
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const auto phase = m_waveProgress * 2 * M_PI;
const auto centerY = height() / 2;
const auto len = m_fullLength > 0 ? m_fullLength : 1;
const auto start = m_lineWidth / 2.0;
const auto fullEnd = width() - m_lineWidth / 2.0;
const auto drawEnd = start + (fullEnd - start) * m_value;
painter->beginPath();
bool first = true;
for (int x = m_lineWidth / 2; x <= drawEnd; ++x) {
const auto theta =
m_frequency * 2 * M_PI * (x + m_startX) / len + phase;
const auto waveY = centerY + amplitude * qSin(theta);
if (first) {
painter->moveTo(x, waveY);
first = false;
} else {
painter->lineTo(x, waveY);
}
}
painter->stroke();
}
void WavyLineRenderer::paintArc(QCanvasPainter* painter) {
if (m_fullAngle <= 0) {
return;
}
const auto startAngleRad = m_startAngle * M_PI / 180.0;
const auto fullAngleRad = m_fullAngle * M_PI / 180.0;
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const auto cx = width() / 2.0;
const auto cy = height() / 2.0;
const auto radius =
m_radius > 0
? m_radius
: (qMin(width(), height()) - m_lineWidth - 2 * amplitude) / 2.0;
if (radius <= 0) {
return;
}
const auto phase = m_waveProgress * 2 * M_PI;
const auto arcLen = radius * fullAngleRad;
const auto len = m_fullLength > 0 ? m_fullLength : arcLen;
const auto drawAngleRad = fullAngleRad * m_value;
if (drawAngleRad <= 0) {
return;
}
const auto N = qMax(64, qCeil(radius * drawAngleRad));
const auto dTheta = drawAngleRad / N;
painter->beginPath();
for (int i = 0; i <= N; ++i) {
const auto theta = startAngleRad + i * dTheta;
const auto s = i * dTheta * radius;
const auto phi = m_frequency * 2 * M_PI * (s + m_startX) / len + phase;
const auto r = radius + amplitude * qSin(phi);
const auto px = cx + r * qCos(theta);
const auto py = cy + r * qSin(theta);
if (i == 0) {
painter->moveTo(px, py);
} else {
painter->lineTo(px, py);
}
}
painter->stroke();
}
} // namespace ZShell::controls
@@ -0,0 +1,32 @@
#pragma once
#include <QCanvasPainterItemRenderer>
#include <QColor>
#include "wavyline.hpp"
namespace ZShell::controls {
class WavyLineRenderer final : public QCanvasPainterItemRenderer {
public:
void synchronizeData(QCanvasPainterItem* item) override;
void paint(QCanvasPainter* painter) override;
private:
void paintLinear(QCanvasPainter* painter);
void paintArc(QCanvasPainter* painter);
int m_lineWidth;
qreal m_amplitudeMultiplier;
int m_frequency;
qreal m_startX;
qreal m_fullLength;
QColor m_color;
qreal m_waveProgress;
WavyLine::PathType m_pathType;
qreal m_startAngle;
qreal m_fullAngle;
qreal m_radius;
qreal m_value;
};
} // namespace ZShell::controls
+12 -20
View File
@@ -37,12 +37,10 @@ static std::filesystem::path g_runtimeDir{"/tmp"};
static std::filesystem::path g_cacheBase{"/tmp/.cache"};
static void initEnvPaths() {
if (const char* env = getenv("XDG_RUNTIME_DIR"))
g_runtimeDir = env;
if (const char* env = getenv("XDG_RUNTIME_DIR")) g_runtimeDir = env;
if (const char* env = getenv("XDG_CACHE_HOME")) {
if (*env)
g_cacheBase = env;
if (*env) g_cacheBase = env;
} else if (const char* home = getenv("HOME")) {
g_cacheBase = std::filesystem::path(home) / ".cache";
}
@@ -50,8 +48,7 @@ static void initEnvPaths() {
static std::ofstream& debugLog() {
static std::ofstream log(
g_runtimeDir / "zshell-nightlight-debug.log",
std::ios::app);
g_runtimeDir / "zshell-nightlight-debug.log", std::ios::app);
return log;
}
@@ -173,15 +170,14 @@ static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) {
static void installShader() {
auto* gl = g_pHyprOpenGL.get();
if (!gl)
return;
if (!gl) return;
ensureShaderFile(shaderPath(), defaultShaderSource());
gl->applyScreenShader(shaderPath().string());
auto* shader = gl->m_finalScreenShader.get();
if (shader && shader->program() >= 1 &&
shader->getUniformLocation(SHADER_TINT) != -1) {
shader->getUniformLocation(SHADER_TINT) != -1) {
g_uniformMode = true;
} else {
g_uniformMode = false;
@@ -196,8 +192,7 @@ static void installShader() {
static void setUniformGain(const std::array<float, 3>& gain) {
auto* gl = g_pHyprOpenGL.get();
auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr;
if (!gl || !shader)
return;
if (!gl || !shader) return;
gl->useShader(gl->m_finalScreenShader);
shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]);
@@ -214,8 +209,7 @@ static void hkBegin(
if (fb) {
auto* renderer = g_pHyprRenderer.get();
if (renderer)
renderer->m_renderData.blockScreenShader = true;
if (renderer) renderer->m_renderData.blockScreenShader = true;
}
if (!g_active) return;
@@ -223,17 +217,15 @@ static void hkBegin(
auto* gl = g_pHyprOpenGL.get();
if (!gl) return;
if (gl->m_finalScreenShader.get() &&
gl->m_finalScreenShader->program() < 1)
if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1)
installShader();
if (!gl->m_finalScreenShader.get() ||
gl->m_finalScreenShader->program() < 1)
gl->m_finalScreenShader->program() < 1)
return;
if (g_transition.active) {
auto* monPtr = mon.get();
if (!monPtr)
return;
if (!monPtr) return;
const float elapsed =
std::chrono::duration<float>(
std::chrono::steady_clock::now() - g_transition.start)
@@ -255,9 +247,9 @@ static void hkBegin(
ensureShaderFile(
bakedShaderPath(g_currentGain),
bakedShaderSource(g_currentGain));
gl->applyScreenShader(bakedShaderPath(g_currentGain).string());
gl->applyScreenShader(bakedShaderPath(g_currentGain).string());
}
}
}
if (t >= 1.f) {
g_transition.active = false;
+16
View File
@@ -1,3 +1,8 @@
wl_proto(wlp-layer-shell wlr-layer-shell-unstable-v1 "${CMAKE_CURRENT_SOURCE_DIR}/protocols")
wl_proto(wlp-viewporter viewporter "${WAYLAND_PROTOCOLS}/stable/viewporter")
wl_proto(wlp-fractional-scale fractional-scale-v1 "${WAYLAND_PROTOCOLS}/staging/fractional-scale")
wl_proto(wlp-xdg-shell xdg-shell "${WAYLAND_PROTOCOLS}/stable/xdg-shell")
qml_module(ZShell-internal
URI ZShell.Internal
INCLUDE_PREFIX internal
@@ -9,6 +14,9 @@ qml_module(ZShell-internal
circularbuffer.hpp circularbuffer.cpp
arcgauge.hpp arcgauge.cpp
wallpaperimage.hpp wallpaperimage.cpp
wallpaperservice.hpp wallpaperservice.cpp
wallpapersurface.hpp wallpapersurface.cpp
wallpaperobject.hpp wallpaperobject.cpp
lidwatcher.hpp lidwatcher.cpp
visualizerbars.hpp visualizerbars.cpp
linearindicatormanager.hpp linearindicatormanager.cpp
@@ -23,4 +31,12 @@ qml_module(ZShell-internal
Qt::Network
Qt::DBus
Qt::CanvasPainter
Qt::GuiPrivate
wlp-layer-shell
wlp-xdg-shell
wlp-viewporter
wlp-fractional-scale
PkgConfig::EGL
PkgConfig::WAYLAND_EGL
PkgConfig::GL
)
+4 -4
View File
@@ -182,10 +182,10 @@ static QString buildHlConfigCall(const QString& key, const QVariant& value) {
}
static QColor colorFromInt(quint32 value) {
const int a = (value >> 24) & 0xFF;
const int r = (value >> 16) & 0xFF;
const int g = (value >> 8) & 0xFF;
const int b = value & 0xFF;
const int a = static_cast<int>(value >> 24) & 0xFF;
const int r = static_cast<int>(value >> 16) & 0xFF;
const int g = static_cast<int>(value >> 8) & 0xFF;
const int b = static_cast<int>(value) & 0xFF;
return QColor(r, g, b, a);
}
@@ -0,0 +1,407 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="wlr_layer_shell_unstable_v1">
<copyright>
Copyright © 2017 Drew DeVault
Permission to use, copy, modify, distribute, and sell this
software and its documentation for any purpose is hereby granted
without fee, provided that the above copyright notice appear in
all copies and that both that copyright notice and this permission
notice appear in supporting documentation, and that the name of
the copyright holders not be used in advertising or publicity
pertaining to distribution of the software without specific,
written prior permission. The copyright holders make no
representations about the suitability of this software for any
purpose. It is provided "as is" without express or implied
warranty.
THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
</copyright>
<interface name="zwlr_layer_shell_v1" version="5">
<description summary="create surfaces that are layers of the desktop">
Clients can use this interface to assign the surface_layer role to
wl_surfaces. Such surfaces are assigned to a "layer" of the output and
rendered with a defined z-depth respective to each other. They may also be
anchored to the edges and corners of a screen and specify input handling
semantics. This interface should be suitable for the implementation of
many desktop shell components, and a broad number of other applications
that interact with the desktop.
</description>
<request name="get_layer_surface">
<description summary="create a layer_surface from a surface">
Create a layer surface for an existing surface. This assigns the role of
layer_surface, or raises a protocol error if another role is already
assigned.
Creating a layer surface from a wl_surface which has a buffer attached
or committed is a client error, and any attempts by a client to attach
or manipulate a buffer prior to the first layer_surface.configure call
must also be treated as errors.
After creating a layer_surface object and setting it up, the client
must perform an initial commit without any buffer attached.
The compositor will reply with a layer_surface.configure event.
The client must acknowledge it and is then allowed to attach a buffer
to map the surface.
You may pass NULL for output to allow the compositor to decide which
output to use. Generally this will be the one that the user most
recently interacted with.
Clients can specify a namespace that defines the purpose of the layer
surface.
</description>
<arg name="id" type="new_id" interface="zwlr_layer_surface_v1"/>
<arg name="surface" type="object" interface="wl_surface"/>
<arg name="output" type="object" interface="wl_output" allow-null="true"/>
<arg name="layer" type="uint" enum="layer" summary="layer to add this surface to"/>
<arg name="namespace_" type="string" summary="namespace for the layer surface"/>
</request>
<enum name="error">
<entry name="role" value="0" summary="wl_surface has another role"/>
<entry name="invalid_layer" value="1" summary="layer value is invalid"/>
<entry name="already_constructed" value="2" summary="wl_surface has a buffer attached or committed"/>
</enum>
<enum name="layer">
<description summary="available layers for surfaces">
These values indicate which layers a surface can be rendered in. They
are ordered by z depth, bottom-most first. Traditional shell surfaces
will typically be rendered between the bottom and top layers.
Fullscreen shell surfaces are typically rendered at the top layer.
Multiple surfaces can share a single layer, and ordering within a
single layer is undefined.
</description>
<entry name="background" value="0"/>
<entry name="bottom" value="1"/>
<entry name="top" value="2"/>
<entry name="overlay" value="3"/>
</enum>
<!-- Version 3 additions -->
<request name="destroy" type="destructor" since="3">
<description summary="destroy the layer_shell object">
This request indicates that the client will not use the layer_shell
object any more. Objects that have been created through this instance
are not affected.
</description>
</request>
</interface>
<interface name="zwlr_layer_surface_v1" version="5">
<description summary="layer metadata interface">
An interface that may be implemented by a wl_surface, for surfaces that
are designed to be rendered as a layer of a stacked desktop-like
environment.
Layer surface state (layer, size, anchor, exclusive zone,
margin, interactivity) is double-buffered, and will be applied at the
time wl_surface.commit of the corresponding wl_surface is called.
Attaching a null buffer to a layer surface unmaps it.
Unmapping a layer_surface means that the surface cannot be shown by the
compositor until it is explicitly mapped again. The layer_surface
returns to the state it had right after layer_shell.get_layer_surface.
The client can re-map the surface by performing a commit without any
buffer attached, waiting for a configure event and handling it as usual.
</description>
<request name="set_size">
<description summary="sets the size of the surface">
Sets the size of the surface in surface-local coordinates. The
compositor will display the surface centered with respect to its
anchors.
If you pass 0 for either value, the compositor will assign it and
inform you of the assignment in the configure event. You must set your
anchor to opposite edges in the dimensions you omit; not doing so is a
protocol error. Both values are 0 by default.
Size is double-buffered, see wl_surface.commit.
</description>
<arg name="width" type="uint"/>
<arg name="height" type="uint"/>
</request>
<request name="set_anchor">
<description summary="configures the anchor point of the surface">
Requests that the compositor anchor the surface to the specified edges
and corners. If two orthogonal edges are specified (e.g. 'top' and
'left'), then the anchor point will be the intersection of the edges
(e.g. the top left corner of the output); otherwise the anchor point
will be centered on that edge, or in the center if none is specified.
Anchor is double-buffered, see wl_surface.commit.
</description>
<arg name="anchor" type="uint" enum="anchor"/>
</request>
<request name="set_exclusive_zone">
<description summary="configures the exclusive geometry of this surface">
Requests that the compositor avoids occluding an area with other
surfaces. The compositor's use of this information is
implementation-dependent - do not assume that this region will not
actually be occluded.
A positive value is only meaningful if the surface is anchored to one
edge or an edge and both perpendicular edges. If the surface is not
anchored, anchored to only two perpendicular edges (a corner), anchored
to only two parallel edges or anchored to all edges, a positive value
will be treated the same as zero.
A positive zone is the distance from the edge in surface-local
coordinates to consider exclusive.
Surfaces that do not wish to have an exclusive zone may instead specify
how they should interact with surfaces that do. If set to zero, the
surface indicates that it would like to be moved to avoid occluding
surfaces with a positive exclusive zone. If set to -1, the surface
indicates that it would not like to be moved to accommodate for other
surfaces, and the compositor should extend it all the way to the edges
it is anchored to.
For example, a panel might set its exclusive zone to 10, so that
maximized shell surfaces are not shown on top of it. A notification
might set its exclusive zone to 0, so that it is moved to avoid
occluding the panel, but shell surfaces are shown underneath it. A
wallpaper or lock screen might set their exclusive zone to -1, so that
they stretch below or over the panel.
The default value is 0.
Exclusive zone is double-buffered, see wl_surface.commit.
</description>
<arg name="zone" type="int"/>
</request>
<request name="set_margin">
<description summary="sets a margin from the anchor point">
Requests that the surface be placed some distance away from the anchor
point on the output, in surface-local coordinates. Setting this value
for edges you are not anchored to has no effect.
The exclusive zone includes the margin.
Margin is double-buffered, see wl_surface.commit.
</description>
<arg name="top" type="int"/>
<arg name="right" type="int"/>
<arg name="bottom" type="int"/>
<arg name="left" type="int"/>
</request>
<enum name="keyboard_interactivity">
<description summary="types of keyboard interaction possible for a layer shell surface">
Types of keyboard interaction possible for layer shell surfaces. The
rationale for this is twofold: (1) some applications are not interested
in keyboard events and not allowing them to be focused can improve the
desktop experience; (2) some applications will want to take exclusive
keyboard focus.
</description>
<entry name="none" value="0">
<description summary="no keyboard focus is possible">
This value indicates that this surface is not interested in keyboard
events and the compositor should never assign it the keyboard focus.
This is the default value, set for newly created layer shell surfaces.
This is useful for e.g. desktop widgets that display information or
only have interaction with non-keyboard input devices.
</description>
</entry>
<entry name="exclusive" value="1">
<description summary="request exclusive keyboard focus">
Request exclusive keyboard focus if this surface is above the shell surface layer.
For the top and overlay layers, the seat will always give
exclusive keyboard focus to the top-most layer which has keyboard
interactivity set to exclusive. If this layer contains multiple
surfaces with keyboard interactivity set to exclusive, the compositor
determines the one receiving keyboard events in an implementation-
defined manner. In this case, no guarantee is made when this surface
will receive keyboard focus (if ever).
For the bottom and background layers, the compositor is allowed to use
normal focus semantics.
This setting is mainly intended for applications that need to ensure
they receive all keyboard events, such as a lock screen or a password
prompt.
</description>
</entry>
<entry name="on_demand" value="2" since="4">
<description summary="request regular keyboard focus semantics">
This requests the compositor to allow this surface to be focused and
unfocused by the user in an implementation-defined manner. The user
should be able to unfocus this surface even regardless of the layer
it is on.
Typically, the compositor will want to use its normal mechanism to
manage keyboard focus between layer shell surfaces with this setting
and regular toplevels on the desktop layer (e.g. click to focus).
Nevertheless, it is possible for a compositor to require a special
interaction to focus or unfocus layer shell surfaces (e.g. requiring
a click even if focus follows the mouse normally, or providing a
keybinding to switch focus between layers).
This setting is mainly intended for desktop shell components (e.g.
panels) that allow keyboard interaction. Using this option can allow
implementing a desktop shell that can be fully usable without the
mouse.
</description>
</entry>
</enum>
<request name="set_keyboard_interactivity">
<description summary="requests keyboard events">
Set how keyboard events are delivered to this surface. By default,
layer shell surfaces do not receive keyboard events; this request can
be used to change this.
This setting is inherited by child surfaces set by the get_popup
request.
Layer surfaces receive pointer, touch, and tablet events normally. If
you do not want to receive them, set the input region on your surface
to an empty region.
Keyboard interactivity is double-buffered, see wl_surface.commit.
</description>
<arg name="keyboard_interactivity" type="uint" enum="keyboard_interactivity"/>
</request>
<request name="get_popup">
<description summary="assign this layer_surface as an xdg_popup parent">
This assigns an xdg_popup's parent to this layer_surface. This popup
should have been created via xdg_surface::get_popup with the parent set
to NULL, and this request must be invoked before committing the popup's
initial state.
See the documentation of xdg_popup for more details about what an
xdg_popup is and how it is used.
</description>
<arg name="popup" type="object" interface="xdg_popup"/>
</request>
<request name="ack_configure">
<description summary="ack a configure event">
When a configure event is received, if a client commits the
surface in response to the configure event, then the client
must make an ack_configure request sometime before the commit
request, passing along the serial of the configure event.
If the client receives multiple configure events before it
can respond to one, it only has to ack the last configure event.
A client is not required to commit immediately after sending
an ack_configure request - it may even ack_configure several times
before its next surface commit.
A client may send multiple ack_configure requests before committing, but
only the last request sent before a commit indicates which configure
event the client really is responding to.
</description>
<arg name="serial" type="uint" summary="the serial from the configure event"/>
</request>
<request name="destroy" type="destructor">
<description summary="destroy the layer_surface">
This request destroys the layer surface.
</description>
</request>
<event name="configure">
<description summary="suggest a surface change">
The configure event asks the client to resize its surface.
Clients should arrange their surface for the new states, and then send
an ack_configure request with the serial sent in this configure event at
some point before committing the new surface.
The client is free to dismiss all but the last configure event it
received.
The width and height arguments specify the size of the window in
surface-local coordinates.
The size is a hint, in the sense that the client is free to ignore it if
it doesn't resize, pick a smaller size (to satisfy aspect ratio or
resize in steps of NxM pixels). If the client picks a smaller size and
is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the
surface will be centered on this axis.
If the width or height arguments are zero, it means the client should
decide its own window dimension.
</description>
<arg name="serial" type="uint"/>
<arg name="width" type="uint"/>
<arg name="height" type="uint"/>
</event>
<event name="closed">
<description summary="surface should be closed">
The closed event is sent by the compositor when the surface will no
longer be shown. The output may have been destroyed or the user may
have asked for it to be removed. Further changes to the surface will be
ignored. The client should destroy the resource after receiving this
event, and create a new surface if they so choose.
</description>
</event>
<enum name="error">
<entry name="invalid_surface_state" value="0" summary="provided surface state is invalid"/>
<entry name="invalid_size" value="1" summary="size is invalid"/>
<entry name="invalid_anchor" value="2" summary="anchor bitfield is invalid"/>
<entry name="invalid_keyboard_interactivity" value="3" summary="keyboard interactivity is invalid"/>
<entry name="invalid_exclusive_edge" value="4" summary="exclusive edge is invalid given the surface anchors"/>
</enum>
<enum name="anchor" bitfield="true">
<entry name="top" value="1" summary="the top edge of the anchor rectangle"/>
<entry name="bottom" value="2" summary="the bottom edge of the anchor rectangle"/>
<entry name="left" value="4" summary="the left edge of the anchor rectangle"/>
<entry name="right" value="8" summary="the right edge of the anchor rectangle"/>
</enum>
<!-- Version 2 additions -->
<request name="set_layer" since="2">
<description summary="change the layer of the surface">
Change the layer that the surface is rendered on.
Layer is double-buffered, see wl_surface.commit.
</description>
<arg name="layer" type="uint" enum="zwlr_layer_shell_v1.layer" summary="layer to move this surface to"/>
</request>
<!-- Version 5 additions -->
<request name="set_exclusive_edge" since="5">
<description summary="set the edge the exclusive zone will be applied to">
Requests an edge for the exclusive zone to apply. The exclusive
edge will be automatically deduced from anchor points when possible,
but when the surface is anchored to a corner, it will be necessary
to set it explicitly to disambiguate, as it is not possible to deduce
which one of the two corner edges should be used.
The edge must be one the surface is anchored to, otherwise the
invalid_exclusive_edge protocol error will be raised.
</description>
<arg name="edge" type="uint" enum="anchor"/>
</request>
</interface>
</protocol>
+1 -1
View File
@@ -72,7 +72,7 @@ static QCanvasPath buildStrokePath(
void StrokeCanvasItem::undoLastGroup() {
if (m_strokes.isEmpty()) return;
const int lastGroup = m_strokes.last().groupId;
for (int i = m_strokes.size() - 1;
for (int i = static_cast<int>(m_strokes.size()) - 1;
i >= 0 && m_strokes[i].groupId == lastGroup;
--i)
m_strokes.remove(i);
@@ -154,7 +154,7 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) {
for (const auto& stroke : canvas->m_strokes)
canvasGroupIds.insert(stroke.groupId);
for (int i = m_strokes.size() - 1; i >= 0; --i) {
for (int i = static_cast<int>(m_strokes.size()) - 1; i >= 0; --i) {
if (!canvasGroupIds.contains(m_strokes[i].groupId)) {
if (m_strokes[i].groupId >= 0)
m_pendingGroupRemovals.append(m_strokes[i].groupId);
@@ -0,0 +1,83 @@
#include "wallpaperobject.hpp"
namespace ZShell::internal {
WallpaperObject::WallpaperObject(QObject* parent) : QObject(parent) {}
WallpaperObject::~WallpaperObject() {
if (m_service && !m_screen.isEmpty()) m_service->removeScreen(m_screen);
}
void WallpaperObject::registerScreen() {
if (!m_service || m_screen.isEmpty()) return;
m_service->addScreen(m_screen);
}
void WallpaperObject::setService(WallpaperService* service) {
if (m_service == service) return;
m_service = service;
emit serviceChanged();
registerScreen();
push();
}
void WallpaperObject::setScreen(const QString& screen) {
if (m_screen == screen) return;
m_screen = screen;
emit screenChanged();
registerScreen();
push();
}
void WallpaperObject::setCropX(qreal x) {
if (qFuzzyCompare(m_cropX, x)) return;
m_cropX = x;
emit cropXChanged();
push();
}
void WallpaperObject::setCropY(qreal y) {
if (qFuzzyCompare(m_cropY, y)) return;
m_cropY = y;
emit cropYChanged();
push();
}
void WallpaperObject::setCropWidth(qreal w) {
if (qFuzzyCompare(m_cropWidth, w)) return;
m_cropWidth = w;
emit cropWidthChanged();
push();
}
void WallpaperObject::setCropHeight(qreal h) {
if (qFuzzyCompare(m_cropHeight, h)) return;
m_cropHeight = h;
emit cropHeightChanged();
push();
}
void WallpaperObject::setZoom(qreal zoom) {
if (qFuzzyCompare(m_zoom, zoom)) return;
m_zoom = zoom;
emit zoomChanged();
push();
}
void WallpaperObject::push() {
if (!m_service || m_screen.isEmpty()) return;
QVariantMap crop;
crop.insert("x", m_cropX);
crop.insert("y", m_cropY);
crop.insert("width", m_cropWidth);
crop.insert("height", m_cropHeight);
crop.insert("zoom", m_zoom);
m_service->setCrop(m_screen, crop);
}
} // namespace ZShell::internal
@@ -0,0 +1,82 @@
#pragma once
#include <QObject>
#include <QPointer>
#include <QString>
#include <QVariantMap>
#include <QtQml/qqml.h>
#include <qtmetamacros.h>
#include "wallpaperservice.hpp"
namespace ZShell::internal {
class WallpaperObject : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
WallpaperService* service READ service WRITE setService NOTIFY
serviceChanged)
Q_PROPERTY(QString screen READ screen WRITE setScreen NOTIFY screenChanged)
Q_PROPERTY(qreal cropX READ cropX WRITE setCropX NOTIFY cropXChanged)
Q_PROPERTY(qreal cropY READ cropY WRITE setCropY NOTIFY cropYChanged)
Q_PROPERTY(
qreal cropWidth READ cropWidth WRITE setCropWidth NOTIFY
cropWidthChanged)
Q_PROPERTY(
qreal cropHeight READ cropHeight WRITE setCropHeight NOTIFY
cropHeightChanged)
Q_PROPERTY(qreal zoom READ zoom WRITE setZoom NOTIFY zoomChanged)
public:
explicit WallpaperObject(QObject* parent = nullptr);
~WallpaperObject() override;
WallpaperService* service() const { return m_service; }
void setService(WallpaperService* service);
QString screen() const { return m_screen; }
void setScreen(const QString& screen);
qreal cropX() const { return m_cropX; }
void setCropX(qreal x);
qreal cropY() const { return m_cropY; }
void setCropY(qreal y);
qreal cropWidth() const { return m_cropWidth; }
void setCropWidth(qreal w);
qreal cropHeight() const { return m_cropHeight; }
void setCropHeight(qreal h);
qreal zoom() const { return m_zoom; }
void setZoom(qreal zoom);
signals:
void serviceChanged();
void screenChanged();
void cropXChanged();
void cropYChanged();
void cropWidthChanged();
void cropHeightChanged();
void zoomChanged();
private:
void registerScreen();
void push();
QPointer<WallpaperService> m_service;
QString m_screen;
qreal m_cropX = 0.0;
qreal m_cropY = 0.0;
qreal m_cropWidth = 1.0;
qreal m_cropHeight = 1.0;
qreal m_zoom = 1.0;
};
} // namespace ZShell::internal
@@ -0,0 +1,743 @@
#define EGL_EGLEXT_PROTOTYPES
#define GL_GLEXT_PROTOTYPES
#include "wallpaperservice.hpp"
#include <EGL/eglext.h>
#include <GL/gl.h>
#include <GL/glext.h>
#include <fcntl.h>
#include <poll.h>
#include <unistd.h>
#include <QDebug>
#include <QGuiApplication>
#include <QImageReader>
#include <QScreen>
#include <qguiapplication_platform.h>
#include <private/qwaylandscreen_p.h>
#include <cstring>
namespace ZShell::internal {
namespace {
const char* kVertexShader = R"(
#version 300 es
void main() {
// The corners are (-1,-1), (3,-1) and (-1,3), which cover the whole buffer.
vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}
)";
const char* kFragmentShader = R"(
#version 300 es
precision highp float;
uniform sampler2D source;
uniform vec2 size;
uniform vec4 crop;
out vec4 outColor;
void main() {
// The pixels are stored top-down, so the fragment coordinate is flipped.
vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size;
outColor = texture(source, crop.xy + p * crop.zw);
}
)";
constexpr int kWaitTimeoutMs = 20;
void logShader(uint32_t object, bool program) {
int ok = 0;
if (program)
glGetProgramiv(object, GL_LINK_STATUS, &ok);
else
glGetShaderiv(object, GL_COMPILE_STATUS, &ok);
if (ok) return;
char info[512] = {};
if (program)
glGetProgramInfoLog(object, int(sizeof(info)), nullptr, info);
else
glGetShaderInfoLog(object, int(sizeof(info)), nullptr, info);
qWarning() << "WallpaperService:" << (program ? "linking" : "compiling")
<< "failed:" << info;
}
QVariantMap cropFor(const QVariantMap& crops, const QString& screen) {
QVariantMap crop = crops.value(screen).toMap();
if (crop.isEmpty()) {
crop.insert("x", 0.0);
crop.insert("y", 0.0);
crop.insert("width", 1.0);
crop.insert("height", 1.0);
crop.insert("zoom", 1.0);
}
return crop;
}
} // namespace
WallpaperService::WallpaperService(QObject* parent) : QObject(parent) {}
WallpaperService::~WallpaperService() {
m_stop = true;
wake();
if (m_thread.joinable()) m_thread.join();
for (auto* surface : m_surfaces)
delete surface;
m_surfaces.clear();
if (m_eglDisplay != EGL_NO_DISPLAY && m_context != EGL_NO_CONTEXT) {
if (eglMakeCurrent(
m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) {
if (m_texture) glDeleteTextures(1, &m_texture);
if (m_program) glDeleteProgram(m_program);
}
eglDestroyContext(m_eglDisplay, m_context);
}
if (m_queue) wl_event_queue_destroy(m_queue);
if (m_wake[0] >= 0) {
close(m_wake[0]);
close(m_wake[1]);
}
if (m_layerShell) m_layerShell->destroy();
if (m_viewporter) m_viewporter->destroy();
if (m_scaleManager) m_scaleManager->destroy();
if (m_registry) wl_registry_destroy(m_registry);
}
QString WallpaperService::path() const {
std::lock_guard<std::recursive_mutex> lock(m_mutex);
return m_path;
}
void WallpaperService::setPath(const QString& path) {
if (m_path == path) return;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_path = path;
m_forceDecode = true;
m_imageChanged = true;
}
emit pathChanged();
start();
queueAll();
}
void WallpaperService::addScreen(const QString& screen) {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (m_screens.contains(screen)) return;
m_screens.append(screen);
}
start();
queueAll();
}
void WallpaperService::removeScreen(const QString& screen) {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (!m_screens.removeOne(screen)) return;
}
queueAll();
}
const QVariantMap& WallpaperService::crops() const {
std::lock_guard<std::recursive_mutex> lock(m_mutex);
return m_crops;
}
void WallpaperService::setCrops(const QVariantMap& crops) {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (m_crops == crops) return;
m_crops = crops;
}
emit cropsChanged();
queueAll();
}
void WallpaperService::setCrop(const QString& screen, const QVariantMap& crop) {
WallpaperSurface* surface = nullptr;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (m_crops.value(screen) == crop) return;
m_crops.insert(screen, crop);
for (auto* candidate : m_surfaces) {
if (candidate->screen() == screen) {
surface = candidate;
break;
}
}
}
emit cropsChanged();
if (surface) queue(surface);
}
void WallpaperService::redraw() {
queueAll();
}
void WallpaperService::start() {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (started || m_path.isEmpty() || m_screens.isEmpty()) return;
started = true;
}
auto* interface =
qApp->nativeInterface<QNativeInterface::QWaylandApplication>();
if (!interface) {
qWarning() << "WallpaperService: this is not a Wayland session";
return;
}
m_display = interface->display();
m_compositor = interface->compositor();
if (!m_display || !m_compositor) return;
m_queue = wl_display_create_queue(m_display);
if (!m_queue) return;
if (pipe2(m_wake, O_CLOEXEC) < 0) {
qWarning() << "WallpaperService: could not make the pipe to wake on";
return;
}
bindGlobals();
if (!setupGl()) return;
m_drawing = true;
m_thread = std::thread([this] { loop(); });
wake();
}
void WallpaperService::wake() {
if (m_wake[1] < 0) return;
char byte = 0;
if (::write(m_wake[1], &byte, 1) < 0)
qWarning() << "WallpaperService: could not wake the drawing thread";
}
void WallpaperService::queue(WallpaperSurface* surface) {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_pending.push_back(surface);
}
wake();
}
void WallpaperService::queueAll() {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
for (auto* surface : m_surfaces)
m_pending.push_back(surface);
}
wake();
}
void WallpaperService::loop() {
while (!m_stop) {
present();
if (wl_display_prepare_read(m_display) == 0) {
wl_display_flush(m_display);
struct pollfd fds[2] = {
{wl_display_get_fd(m_display), POLLIN, 0},
{m_wake[0], POLLIN, 0},
};
int ready = poll(fds, 2, kWaitTimeoutMs);
if (ready > 0 && fds[0].revents) {
wl_display_read_events(m_display);
continue;
}
if (ready > 0 && fds[1].revents) {
char byte = 0;
::read(m_wake[0], &byte, 1);
}
wl_display_cancel_read(m_display);
continue;
}
struct pollfd fds[1] = {{m_wake[0], POLLIN, 0}};
if (poll(fds, 1, kWaitTimeoutMs) <= 0) continue;
char byte = 0;
::read(m_wake[0], &byte, 1);
}
}
void WallpaperService::present() {
wl_display_dispatch_queue_pending(m_display, m_queue);
addSurfaces();
ensureDecodeSize();
std::vector<WallpaperSurface*> pending;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
pending.swap(m_pending);
}
for (auto* surface : pending)
drawSurface(surface);
}
void WallpaperService::addSurfaces() {
if (!started || !m_layerShell || !m_viewporter || !m_scaleManager) return;
QList<WallpaperSurface*> gone;
QList<QString> needed;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
QList<WallpaperSurface*> kept;
for (auto* surface : m_surfaces) {
if (m_screens.contains(surface->screen()))
kept.append(surface);
else
gone.append(surface);
}
m_surfaces = kept;
for (const auto& screen : m_screens) {
bool exists = false;
for (auto* surface : m_surfaces) {
if (surface->screen() == screen) {
exists = true;
break;
}
}
if (!exists) needed.append(screen);
}
}
for (auto* surface : gone)
delete surface;
for (const auto& screen : needed)
addSurface(screen);
}
void WallpaperService::registryGlobal(
void* data,
struct wl_registry* registry,
uint32_t name,
const char* interface,
uint32_t version) {
auto* service = static_cast<WallpaperService*>(data);
void* proxy = nullptr;
if (strcmp(interface, "zwlr_layer_shell_v1") == 0) {
proxy = new QtWayland::zwlr_layer_shell_v1(
registry, int(name), int(version));
} else if (strcmp(interface, "wp_viewporter") == 0) {
proxy = new QtWayland::wp_viewporter(registry, int(name), int(version));
} else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) {
proxy = new QtWayland::wp_fractional_scale_manager_v1(
registry, int(name), int(version));
} else {
return;
}
service->storeGlobal(interface, proxy);
service->queueAll();
}
void WallpaperService::storeGlobal(const char* interface, void* proxy) {
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (strcmp(interface, "zwlr_layer_shell_v1") == 0)
m_layerShell = static_cast<QtWayland::zwlr_layer_shell_v1*>(proxy);
else if (strcmp(interface, "wp_viewporter") == 0)
m_viewporter = static_cast<QtWayland::wp_viewporter*>(proxy);
else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0)
m_scaleManager =
static_cast<QtWayland::wp_fractional_scale_manager_v1*>(proxy);
}
void WallpaperService::bindGlobals() {
m_registry = wl_display_get_registry(m_display);
static const struct wl_registry_listener listener = {
registryGlobal,
nullptr,
};
wl_registry_add_listener(m_registry, &listener, this);
}
bool WallpaperService::setupGl() {
const char* extensions = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS);
if (extensions && strstr(extensions, "EGL_EXT_platform_wayland")) {
m_eglDisplay =
eglGetPlatformDisplay(EGL_PLATFORM_WAYLAND_EXT, m_display, nullptr);
} else {
m_eglDisplay =
eglGetDisplay(reinterpret_cast<EGLNativeDisplayType>(m_display));
}
if (m_eglDisplay == EGL_NO_DISPLAY) {
qWarning() << "WallpaperService: no EGL display";
return false;
}
EGLint major = 0, minor = 0;
if (!eglInitialize(m_eglDisplay, &major, &minor)) {
qWarning() << "WallpaperService: eglInitialize failed";
return false;
}
const EGLint configAttributes[] = {
EGL_SURFACE_TYPE,
EGL_WINDOW_BIT,
EGL_RENDERABLE_TYPE,
EGL_OPENGL_ES2_BIT,
EGL_RED_SIZE,
8,
EGL_GREEN_SIZE,
8,
EGL_BLUE_SIZE,
8,
EGL_ALPHA_SIZE,
8,
EGL_NONE};
EGLint count = 0;
if (!eglChooseConfig(m_eglDisplay, configAttributes, &m_config, 1, &count) ||
count == 0) {
qWarning() << "WallpaperService: no EGL config for window surfaces";
return false;
}
const EGLint contextAttributes[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE};
m_context = eglCreateContext(
m_eglDisplay, m_config, EGL_NO_CONTEXT, contextAttributes);
if (m_context == EGL_NO_CONTEXT) {
qWarning() << "WallpaperService: eglCreateContext failed";
return false;
}
return true;
}
uint32_t WallpaperService::createProgram() {
uint32_t vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &kVertexShader, nullptr);
glCompileShader(vertex);
logShader(vertex, false);
uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment, 1, &kFragmentShader, nullptr);
glCompileShader(fragment);
logShader(fragment, false);
uint32_t program = glCreateProgram();
glAttachShader(program, vertex);
glAttachShader(program, fragment);
glLinkProgram(program);
logShader(program, true);
if (glGetUniformLocation(program, "size") < 0 ||
glGetUniformLocation(program, "crop") < 0) {
qWarning() << "WallpaperService: the program does not have the "
"uniforms it needs";
glDeleteProgram(program);
return 0;
}
m_sizeUniform = glGetUniformLocation(program, "size");
m_cropUniform = glGetUniformLocation(program, "crop");
glDeleteShader(vertex);
glDeleteShader(fragment);
return program;
}
void WallpaperService::uploadImage() {
if (m_image.isNull()) return;
if (m_texture == 0) {
glGenTextures(1, &m_texture);
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
} else {
glBindTexture(GL_TEXTURE_2D, m_texture);
}
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_RGBA8,
m_image.width(),
m_image.height(),
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
m_image.constBits());
}
bool WallpaperService::decode(const QString& path) {
if (path.isEmpty()) return false;
QImageReader reader(path);
reader.setAutoTransform(true);
QSize size = reader.size();
if (size.isValid()) m_sourceSize = size;
if (!m_decodeSize.isEmpty() && !m_sourceSize.isEmpty())
reader.setScaledSize(
m_sourceSize.scaled(m_decodeSize, Qt::KeepAspectRatioByExpanding));
QImage image = reader.read();
if (image.isNull()) {
qWarning() << "WallpaperService: could not read the image" << path;
return false;
}
m_image = image.convertToFormat(QImage::Format_RGBA8888);
return !m_image.isNull();
}
bool WallpaperService::ensureDecodeSize() {
QSize largest;
QString path;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
largest = largestBufferSize();
if (largest.isEmpty()) return false;
if (!m_forceDecode && !m_decodeSize.isEmpty() &&
qint64(largest.width()) * largest.height() <=
qint64(m_decodeSize.width()) * m_decodeSize.height())
return false;
QSize target =
m_sourceSize.isEmpty()
? largest
: m_sourceSize.scaled(largest, Qt::KeepAspectRatioByExpanding);
if (!m_forceDecode && !m_image.isNull() && target == m_image.size())
return false;
m_forceDecode = false;
m_decodeSize = largest;
path = m_path;
}
if (!decode(path)) return false;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_imageChanged = true;
}
return true;
}
QSize WallpaperService::largestBufferSize() {
QSize largest;
for (auto* surface : m_surfaces) {
QSize buffer = surface->bufferSize();
if (buffer.isEmpty()) continue;
if (largest.isEmpty() || qint64(buffer.width()) * buffer.height() >
qint64(largest.width()) * largest.height())
largest = buffer;
}
return largest;
}
QRectF WallpaperService::sourceRect(
const QVariantMap& crops, const QString& screen, const QSize& size) const {
QVariantMap crop = cropFor(crops, screen);
QRectF rect(
crop.value("x", 0.0).toDouble(),
crop.value("y", 0.0).toDouble(),
crop.value("width", 1.0).toDouble(),
crop.value("height", 1.0).toDouble());
if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect;
qreal imageWidth = qreal(m_image.width());
qreal imageHeight = qreal(m_image.height());
QRectF pixels(
rect.x() * imageWidth,
rect.y() * imageHeight,
rect.width() * imageWidth,
rect.height() * imageHeight);
qreal targetRatio = qreal(size.width()) / qreal(size.height());
qreal rectRatio = pixels.width() / pixels.height();
if (rectRatio > targetRatio) {
qreal newWidth = pixels.height() * targetRatio;
pixels.setX(pixels.x() + (pixels.width() - newWidth) / 2.0);
pixels.setWidth(newWidth);
} else if (rectRatio < targetRatio) {
qreal newHeight = pixels.width() / targetRatio;
pixels.setY(pixels.y() + (pixels.height() - newHeight) / 2.0);
pixels.setHeight(newHeight);
}
return QRectF(
pixels.x() / imageWidth,
pixels.y() / imageHeight,
pixels.width() / imageWidth,
pixels.height() / imageHeight);
}
void WallpaperService::drawSurface(WallpaperSurface* surface) {
EGLSurface target = surface->eglSurface();
if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE ||
surface->bufferSize().isEmpty())
return;
if (!eglMakeCurrent(m_eglDisplay, target, target, m_context)) {
qWarning() << "WallpaperService: the context cannot be made current";
return;
}
QVariantMap crops;
QSize size;
bool upload = false;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
crops = m_crops;
size = surface->bufferSize();
upload = m_imageChanged;
m_imageChanged = false;
}
if (size.isEmpty()) return;
if (m_program == 0) m_program = createProgram();
if (m_texture == 0 || upload) uploadImage();
if (m_program == 0 || m_texture == 0 || m_image.isNull()) return;
glUseProgram(m_program);
glBindTexture(GL_TEXTURE_2D, m_texture);
glUniform2f(
GLint(m_sizeUniform), GLfloat(size.width()), GLfloat(size.height()));
QRectF rect = sourceRect(crops, surface->screen(), size);
glUniform4f(
GLint(m_cropUniform),
GLfloat(rect.x()),
GLfloat(rect.y()),
GLfloat(rect.width()),
GLfloat(rect.height()));
glViewport(0, 0, size.width(), size.height());
glDrawArrays(GL_TRIANGLES, 0, 3);
if (!eglSwapBuffers(m_eglDisplay, target))
qWarning() << "WallpaperService: eglSwapBuffers failed";
}
void WallpaperService::addSurface(const QString& screen) {
if (!m_layerShell || !m_viewporter || !m_scaleManager) return;
QScreen* qscreen = nullptr;
for (auto* candidate : QGuiApplication::screens()) {
if (candidate->name() == screen) {
qscreen = candidate;
break;
}
}
if (!qscreen) {
qWarning() << "WallpaperService:" << screen << "is not a screen";
return;
}
auto* waylandScreen =
dynamic_cast<QtWaylandClient::QWaylandScreen*>(qscreen->handle());
if (!waylandScreen || !waylandScreen->output()) {
qWarning() << "WallpaperService:" << screen
<< "is not a Wayland screen";
return;
}
wl_surface* wlSurface = wl_compositor_create_surface(m_compositor);
if (!wlSurface) return;
auto* surface =
new WallpaperSurface(this, wlSurface, waylandScreen->output(), screen);
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_surfaces.append(surface);
}
}
} // namespace ZShell::internal
@@ -0,0 +1,147 @@
#pragma once
#include <EGL/egl.h>
#include <cstdint>
#include <mutex>
#include <thread>
#include <vector>
#include <QImage>
#include <QList>
#include <QRectF>
#include <QSize>
#include <QString>
#include <QStringList>
#include <QVariantMap>
#include <QtQml/qqml.h>
#include <qtmetamacros.h>
#include <qwayland-wlr-layer-shell-unstable-v1.h>
#include <wayland-client-protocol.h>
#include "wallpapersurface.hpp"
namespace ZShell::internal {
class WallpaperSurface;
class WallpaperService : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED)
Q_PROPERTY(QVariantMap crops READ crops WRITE setCrops NOTIFY cropsChanged)
public:
explicit WallpaperService(QObject* parent = nullptr);
~WallpaperService() override;
[[nodiscard]] QString path() const;
void setPath(const QString& path);
void addScreen(const QString& screen);
void removeScreen(const QString& screen);
[[nodiscard]] const QVariantMap& crops() const;
void setCrops(const QVariantMap& crops);
Q_INVOKABLE void redraw();
Q_INVOKABLE void setCrop(const QString& screen, const QVariantMap& crop);
signals:
void pathChanged();
void cropsChanged();
private:
friend class WallpaperSurface;
void bindGlobals();
static void registryGlobal(
void* data,
struct wl_registry* registry,
uint32_t name,
const char* interface,
uint32_t version);
void storeGlobal(const char* interface, void* proxy);
[[nodiscard]] bool setupGl();
[[nodiscard]] uint32_t createProgram();
void uploadImage();
bool decode(const QString& path);
bool ensureDecodeSize();
[[nodiscard]] QSize largestBufferSize();
void queue(WallpaperSurface* surface);
void queueAll();
void startDrawing();
void loop();
void present();
void drawSurface(WallpaperSurface* surface);
void wake();
[[nodiscard]] wl_event_queue* eventQueue() const { return m_queue; }
[[nodiscard]] QRectF sourceRect(
const QVariantMap& crops,
const QString& screen,
const QSize& size) const;
void start();
void addSurfaces();
void addSurface(const QString& screen);
QString m_path;
QStringList m_screens;
QVariantMap m_crops;
QImage m_image;
QSize m_sourceSize;
QSize m_decodeSize;
wl_display* m_display = nullptr;
wl_registry* m_registry = nullptr;
wl_compositor* m_compositor = nullptr;
QtWayland::zwlr_layer_shell_v1* m_layerShell = nullptr;
QtWayland::wp_viewporter* m_viewporter = nullptr;
QtWayland::wp_fractional_scale_manager_v1* m_scaleManager = nullptr;
EGLDisplay m_eglDisplay = EGL_NO_DISPLAY;
EGLConfig m_config = nullptr;
EGLContext m_context = EGL_NO_CONTEXT;
uint32_t m_texture = 0;
uint32_t m_program = 0;
int m_sizeUniform = -1;
int m_cropUniform = -1;
QList<WallpaperSurface*> m_surfaces;
std::thread m_thread;
mutable std::recursive_mutex m_mutex;
std::vector<WallpaperSurface*> m_pending;
wl_event_queue* m_queue = nullptr;
int m_wake[2] = {-1, -1};
bool m_stop = false;
bool m_drawing = false;
bool started = false;
bool m_forceDecode = false;
bool m_imageChanged = false;
};
} // namespace ZShell::internal
@@ -0,0 +1,147 @@
#include "wallpapersurface.hpp"
#include <QDebug>
#include <cmath>
#include "wallpaperservice.hpp"
namespace ZShell::internal {
const QString kNamespace = "ZShell-Wallpaper";
void setQueue(void* proxy, struct wl_event_queue* queue) {
wl_proxy_set_queue(static_cast<struct wl_proxy*>(proxy), queue);
}
WallpaperSurface::WallpaperSurface(
WallpaperService* service,
wl_surface* surface,
wl_output* output,
const QString& screen)
: m_service(service), m_screen(screen), m_surface(surface) {
if (!surface) {
qWarning() << "WallpaperSurface: no surface for" << screen;
return;
}
setQueue(m_surface, m_service->eventQueue());
auto* layer = m_service->m_layerShell->get_layer_surface(
m_surface,
output,
QtWayland::zwlr_layer_shell_v1::layer_background,
kNamespace);
setQueue(layer, m_service->eventQueue());
QtWayland::zwlr_layer_surface_v1::init(layer);
set_size(0, 0);
set_anchor(anchor_top | anchor_bottom | anchor_left | anchor_right);
set_exclusive_zone(-1);
set_keyboard_interactivity(keyboard_interactivity_none);
set_margin(0, 0, 0, 0);
auto* viewport = m_service->m_viewporter->get_viewport(m_surface);
setQueue(viewport, m_service->eventQueue());
QtWayland::wp_viewport::init(viewport);
auto* scale = m_service->m_scaleManager->get_fractional_scale(m_surface);
setQueue(scale, m_service->eventQueue());
QtWayland::wp_fractional_scale_v1::init(scale);
wl_surface_commit(m_surface);
}
WallpaperSurface::~WallpaperSurface() {
if (m_eglSurface != EGL_NO_SURFACE)
eglDestroySurface(m_service->m_eglDisplay, m_eglSurface);
if (m_window) wl_egl_window_destroy(m_window);
if (QtWayland::wp_viewport::isInitialized())
QtWayland::wp_viewport::destroy();
if (QtWayland::wp_fractional_scale_v1::isInitialized())
QtWayland::wp_fractional_scale_v1::destroy();
if (QtWayland::zwlr_layer_surface_v1::isInitialized())
QtWayland::zwlr_layer_surface_v1::destroy();
if (m_surface) wl_surface_destroy(m_surface);
}
void WallpaperSurface::redraw() {
m_service->queue(this);
}
void WallpaperSurface::zwlr_layer_surface_v1_configure(
uint32_t serial, uint32_t width, uint32_t height) {
ack_configure(serial);
if (width == 0 || height == 0) return;
m_size = QSize(int(width), int(height));
if (!configured) configured = true;
updateBuffer();
}
void WallpaperSurface::wp_fractional_scale_v1_preferred_scale(uint32_t scale) {
if (scale == 0) return;
qreal newScale = qreal(scale) / 120.0;
if (qFuzzyCompare(newScale, m_scale)) return;
m_scale = newScale;
updateBuffer();
}
void WallpaperSurface::zwlr_layer_surface_v1_closed() {}
void WallpaperSurface::updateBuffer() {
if (m_size.isEmpty()) return;
QSize buffer = QSize(
int(std::llround(m_size.width() * m_scale)),
int(std::llround(m_size.height() * m_scale)));
if (buffer.isEmpty()) return;
if (m_bufferSize != buffer) {
m_bufferSize = buffer;
if (m_window) {
wl_egl_window_resize(
m_window, buffer.width(), buffer.height(), 0, 0);
} else {
m_window =
wl_egl_window_create(m_surface, buffer.width(), buffer.height());
if (!m_window) {
qWarning()
<< "WallpaperSurface: could not make a window surface for"
<< m_screen;
return;
}
}
if (m_eglSurface == EGL_NO_SURFACE) {
m_eglSurface = eglCreateWindowSurface(
m_service->m_eglDisplay,
m_service->m_config,
reinterpret_cast<EGLNativeWindowType>(m_window),
nullptr);
if (m_eglSurface == EGL_NO_SURFACE) {
qWarning()
<< "WallpaperSurface: eglCreateWindowSurface failed for"
<< m_screen;
return;
}
}
}
if (QtWayland::wp_viewport::isInitialized())
set_destination(m_size.width(), m_size.height());
m_service->queue(this);
}
} // namespace ZShell::internal
@@ -0,0 +1,58 @@
#pragma once
#include <EGL/egl.h>
#include <wayland-egl.h>
#include <QSize>
#include <QString>
#include <qwayland-fractional-scale-v1.h>
#include <qwayland-viewporter.h>
#include <qwayland-wlr-layer-shell-unstable-v1.h>
#include <wayland-client-protocol.h>
namespace ZShell::internal {
class WallpaperService;
class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1,
public QtWayland::wp_viewport,
public QtWayland::wp_fractional_scale_v1 {
public:
WallpaperSurface(
WallpaperService* service,
wl_surface* surface,
wl_output* output,
const QString& screen);
~WallpaperSurface() override;
[[nodiscard]] const QString& screen() const { return m_screen; }
[[nodiscard]] const QSize& size() const { return m_size; }
[[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; }
[[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; }
void redraw();
private:
void zwlr_layer_surface_v1_configure(
uint32_t serial, uint32_t width, uint32_t height) override;
void zwlr_layer_surface_v1_closed() override;
void wp_fractional_scale_v1_preferred_scale(uint32_t scale) override;
void updateBuffer();
WallpaperService* m_service;
QString m_screen;
wl_surface* m_surface;
wl_egl_window* m_window = nullptr;
EGLSurface m_eglSurface = EGL_NO_SURFACE;
QSize m_size;
QSize m_bufferSize;
qreal m_scale = 1.0;
bool configured = false;
};
} // namespace ZShell::internal
+5 -2
View File
@@ -232,22 +232,25 @@ qml_module(ZShell-llm
URI ZShell.Llm
SOURCES
attachment.hpp attachment.cpp
chat.hpp chat.cpp
chatstore.hpp chatstore.cpp
clipboard.hpp clipboard.cpp
codehighlighter.hpp codehighlighter.cpp
filetool.hpp filetool.cpp
generation.hpp generation.cpp
llmclient.hpp llmclient.cpp
markdownblock.hpp
markdownblock.hpp markdownblock.cpp
markdownparser.hpp markdownparser.cpp
mathtext.hpp mathtext.cpp
message.hpp message.cpp
messagemodel.hpp messagemodel.cpp
prompts.hpp prompts.cpp
segment.hpp segment.cpp
session.hpp session.cpp
shelltool.hpp shelltool.cpp
tool.hpp tool.cpp
turn.hpp turn.cpp
webfetchtool.hpp webfetchtool.cpp
workspace.hpp workspace.cpp
LIBRARIES
Qt::Network
Qt::Sql
-201
View File
@@ -1,201 +0,0 @@
#include "chat.hpp"
#include "filetool.hpp"
#include "llmclient.hpp"
#include "shelltool.hpp"
#include "webfetchtool.hpp"
#include "config/rootnodes.hpp"
#include "config/llm.hpp"
#include <QDebug>
namespace ZShell::llm {
Chat::Chat(QObject* parent)
: QObject(parent)
, m_store(new ChatStore(this))
, m_client(new LlmClient(this)) {
m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
m_client->tools()->registerTool(new ShellTool(m_client, m_client->tools()));
const auto* llm = config::ConfigSingleton::instance()->llm();
m_client->setEndpoint(llm->endpoint());
m_client->setModel(llm->model());
m_client->setTemperature(llm->temperature());
m_client->setToolsEnabled(llm->tools());
m_client->setSandboxEnabled(llm->sandbox());
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(llm, &config::Llm::sandboxChanged, this, [this, llm]() {
m_client->setSandboxEnabled(llm->sandbox());
Q_EMIT sandboxEnabledChanged();
});
connect(m_client, &LlmClient::busyChanged, this, [this]() {
if (m_client->busy() && (m_hasError || !m_errorMessage.isEmpty())) {
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
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_hasError = !message.isEmpty();
m_errorMessage = message;
Q_EMIT errorChanged();
Q_EMIT errorOccurred(message);
});
connect(
m_client,
&LlmClient::serverErrorChanged,
this,
&Chat::serverErrorChanged);
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::ConfigSingleton::instance())
config->llm()->set_tools(value);
}
bool Chat::sandboxEnabled() const {
return m_client->sandboxEnabled();
}
void Chat::setSandboxEnabled(bool value) {
m_client->setSandboxEnabled(value);
if (auto* config = config::ConfigSingleton::instance())
config->llm()->set_sandbox(value);
}
QString Chat::serverError() const {
return m_client->serverError();
}
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_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
void Chat::refreshModels() {
m_client->refreshModels();
}
void Chat::refreshFromServer() {
m_client->refreshAllStates();
}
void Chat::selectModel(const QString& id) {
if (id.isEmpty()) return;
m_client->setModel(id);
if (auto* config = config::ConfigSingleton::instance())
config->llm()->set_model(id);
}
} // namespace ZShell::llm
-91
View File
@@ -1,91 +0,0 @@
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include <QtQml>
#include "chatstore.hpp"
class QQmlEngine;
class QJSEngine;
namespace ZShell::llm {
class LlmClient;
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(
bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY
sandboxEnabledChanged)
Q_PROPERTY(bool error READ error NOTIFY errorChanged)
Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged)
Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged)
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]] bool sandboxEnabled() const;
void setSandboxEnabled(bool value);
[[nodiscard]] bool error() const { return m_hasError; }
[[nodiscard]] QString errorMessage() const { return m_errorMessage; }
[[nodiscard]] QString serverError() const;
[[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 refreshFromServer();
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 sandboxEnabledChanged();
void errorOccurred(const QString& message);
void errorChanged();
void serverErrorChanged();
void streamingChatIdChanged();
private:
ChatStore* m_store = nullptr;
LlmClient* m_client = nullptr;
bool m_hasError = false;
QString m_errorMessage;
static Chat* s_instance;
};
} // namespace ZShell::llm
+240 -146
View File
@@ -1,6 +1,8 @@
#include "chatstore.hpp"
#include "attachment.hpp"
#include "llmclient.hpp"
#include "markdownblock.hpp"
#include "message.hpp"
#include "segment.hpp"
@@ -11,15 +13,12 @@
#include <QFileInfo>
#include <QPointer>
#include <QStandardPaths>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QThreadPool>
#include <QVector>
#include <QUuid>
#include <algorithm>
namespace ZShell::llm {
namespace {
@@ -76,22 +75,25 @@ struct AttachmentRow {
struct MessageRow {
bool user = false;
qint64 timestamp = 0;
QString content;
QVector<GenerationRow> generations;
QVector<AttachmentRow> attachments;
};
} // namespace
ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
ChatStore::ChatStore(LlmClient* client, QObject* parent)
: QAbstractListModel(parent)
, m_client(client)
, m_connectionName(QUuid::createUuid().toString()) {
openDb();
load();
}
ChatStore::~ChatStore() {
if (m_connectionName.isEmpty()) return;
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
db.close();
QSqlDatabase handle = QSqlDatabase::database(m_connectionName, false);
handle.close();
QSqlDatabase::removeDatabase(m_connectionName);
}
@@ -105,20 +107,20 @@ void ChatStore::openDb() {
QStringLiteral("/zshell/chats.sqlite");
QDir().mkpath(QFileInfo(m_dbPath).absolutePath());
QSqlDatabase db =
QSqlDatabase handle =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
db.setDatabaseName(m_dbPath);
if (!db.open()) {
handle.setDatabaseName(m_dbPath);
if (!handle.open()) {
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
<< db.lastError().text();
<< handle.lastError().text();
return;
}
{
QSqlQuery pragma(db);
QSqlQuery pragma(handle);
pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS sessions (\n"
" id TEXT PRIMARY KEY,\n"
@@ -130,18 +132,23 @@ void ChatStore::openDb() {
")"));
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
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"
" content TEXT NOT NULL DEFAULT '',\n"
" timestamp INTEGER NOT NULL\n"
")"));
if (!query.exec(QStringLiteral(
"ALTER TABLE messages ADD COLUMN content TEXT "
"NOT NULL DEFAULT ''"))) {
}
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS generations (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
@@ -152,7 +159,7 @@ void ChatStore::openDb() {
")"));
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS segments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
@@ -170,7 +177,7 @@ void ChatStore::openDb() {
")"));
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS attachments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
@@ -184,7 +191,7 @@ void ChatStore::openDb() {
")"));
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_messages_session "
"ON messages (session_id)"));
@@ -200,93 +207,156 @@ void ChatStore::openDb() {
}
}
int ChatStore::count() const {
int ChatStore::rowCount(const QModelIndex& parent) const {
if (parent.isValid()) return 0;
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;
QVariant ChatStore::data(const QModelIndex& index, int role) const {
if (role != Qt::UserRole || !index.isValid() || index.row() < 0 ||
index.row() >= m_sessions.size())
return {};
return QVariant::fromValue(m_sessions.at(index.row()));
}
ChatSession* ChatStore::at(int index) const {
if (index < 0 || index >= m_sessions.size()) return nullptr;
return m_sessions.at(index);
QHash<int, QByteArray> ChatStore::roleNames() const {
return {{Qt::UserRole, "modelData"}};
}
ChatSession* ChatStore::insert(int index) {
int ChatStore::rowOf(const ChatSession* session) const {
for (int i = 0; i < m_sessions.size(); ++i)
if (m_sessions.at(i) == session) return i;
return -1;
}
bool ChatStore::firstAfter(const ChatSession* a, const ChatSession* b) {
if (a->pinned() != b->pinned()) return a->pinned();
return a->updatedAtMs() > b->updatedAtMs();
}
int ChatStore::sortedRow(const ChatSession* session) const {
int row = 0;
while (row < m_sessions.size() && !firstAfter(session, m_sessions.at(row)))
++row;
return row;
}
void ChatStore::insertRow(ChatSession* session) {
const int row = sortedRow(session);
beginInsertRows(QModelIndex(), row, row);
m_sessions.insert(row, session);
endInsertRows();
}
void ChatStore::moveRow(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 ||
to > m_sessions.size() || from == to || to == from + 1)
return;
if (!beginMoveRows(QModelIndex(), from, from, QModelIndex(), to)) return;
m_sessions.move(from, to > from ? to - 1 : to);
endMoveRows();
}
void ChatStore::reposition(ChatSession* session) {
if (m_loading) return;
const int row = rowOf(session);
if (row < 0) return;
const int target = sortedRow(session);
if (target == row) return;
moveRow(row, target);
}
ChatSession* ChatStore::draft() {
if (m_draft) return m_draft;
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();
m_draft = new ChatSession(this, QUuid::createUuid().toString(), this);
m_draft->hydrate(QString(), QString(), now, now, 0, false);
return m_draft;
}
void ChatStore::commitDraft(ChatSession* session) {
if (!session || session->isCommitted()) return;
if (m_draft == session) m_draft = nullptr;
if (!writeSessionRow(session)) return;
session->setCommitted(true);
insertRow(session);
}
bool ChatStore::writeSessionRow(ChatSession* session) {
QSqlQuery query(db());
query.prepare(
"INSERT INTO sessions (id, title, icon, created_at, updated_at, "
"pinned) VALUES (:id, :title, :icon, :created_at, :updated_at, "
":pinned)");
query.bindValue(":id", session->id());
query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":created_at", session->createdAtMs());
query.bindValue(":updated_at", session->updatedAtMs());
query.bindValue(":pinned", session->pinned() ? 1 : 0);
if (!query.exec()) {
qWarning() << "ChatStore: failed to insert session" << session->id()
<< ":" << query.lastError().text();
return false;
}
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;
return true;
}
void ChatStore::remove(int index) {
removeSession(at(index));
}
void ChatStore::removeRow(ChatSession* session) {
if (!session) return;
const int row = rowOf(session);
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();
}
QDir(ChatSession::sandboxDirFor(session->id())).removeRecursively();
m_sessions.removeOne(session);
if (row >= 0) {
beginRemoveRows(QModelIndex(), row, row);
m_sessions.removeAt(row);
endRemoveRows();
}
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)
void ChatStore::remove(ChatSession* chat) {
if (!chat) return;
m_pendingPersists.remove(chat);
m_pendingClears.remove(chat);
if (m_draft == chat) m_draft = nullptr;
if (!chat->isCommitted()) {
Q_EMIT sessionRemoved(chat);
chat->deleteLater();
return;
m_sessions.move(from, to);
Q_EMIT valuesChanged();
}
removeRow(chat);
}
void ChatStore::clear() {
const QList<ChatSession*> sessions = m_sessions;
for (ChatSession* session : sessions)
removeSession(session);
remove(session);
}
ChatSession* ChatStore::sessionById(const QString& id) {
for (auto* session : m_sessions)
if (session->id() == id) return session;
return nullptr;
void ChatStore::requestClear(ChatSession* session) {
if (!session) return;
m_pendingClears.insert(session);
if (session->isLoaded() && m_client->streamingSession() != session)
applyPendingClear(session);
}
void ChatStore::setLlmClient(LlmClient* client) {
m_llmClient = client;
void ChatStore::applyPendingClear(ChatSession* session) {
if (!session || !m_pendingClears.remove(session)) return;
if (!session->isLoaded()) return;
session->clearMessages();
persist(session);
}
void ChatStore::persist(ChatSession* session) {
@@ -297,21 +367,23 @@ void ChatStore::persist(ChatSession* session) {
}
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
if (!saveSession(session)) return;
sortAndNotify();
reposition(session);
}
void ChatStore::saveMeta(ChatSession* session) {
void ChatStore::saveFlags(ChatSession* session) {
if (!session || !m_sessions.contains(session)) return;
QSqlQuery query(db());
query.prepare(
"UPDATE sessions SET title = :title, icon = :icon "
"WHERE id = :id");
"UPDATE sessions SET title = :title, icon = :icon, "
"pinned = :pinned WHERE id = :id");
query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":pinned", session->pinned() ? 1 : 0);
query.bindValue(":id", session->id());
if (!query.exec())
qWarning() << "ChatStore: failed to save meta for" << session->id()
qWarning() << "ChatStore: failed to save flags for" << session->id()
<< ":" << query.lastError().text();
reposition(session);
}
bool ChatStore::saveSession(ChatSession* session) {
@@ -326,10 +398,12 @@ bool ChatStore::saveSession(ChatSession* session) {
{
QSqlQuery query(handle);
query.prepare(
"UPDATE sessions SET title = :title, updated_at = :updated_at "
"WHERE id = :id");
"UPDATE sessions SET title = :title, icon = :icon, "
"updated_at = :updated_at, pinned = :pinned WHERE id = :id");
query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":updated_at", session->updatedAtMs());
query.bindValue(":pinned", session->pinned() ? 1 : 0);
query.bindValue(":id", session->id());
ok = query.exec();
}
@@ -342,8 +416,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (ok) {
QSqlQuery messageInsert(handle);
ok = messageInsert.prepare(
"INSERT INTO messages (session_id, role, timestamp) "
"VALUES (:id, :role, :timestamp)");
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (:id, :role, :content, :timestamp)");
QSqlQuery generationInsert(handle);
ok = ok &&
generationInsert.prepare(
@@ -371,6 +445,7 @@ bool ChatStore::saveSession(ChatSession* session) {
message->role() == ChatMessage::Role::User
? QStringLiteral("user")
: QStringLiteral("assistant"));
messageInsert.bindValue(":content", sqlText(message->content()));
messageInsert.bindValue(":timestamp", message->timestamp());
if (!messageInsert.exec()) {
ok = false;
@@ -401,16 +476,24 @@ bool ChatStore::saveSession(ChatSession* session) {
segmentInsert.bindValue(":gid", generationId);
segmentInsert.bindValue(
":type", segmentTypeName(segment->type()));
segmentInsert.bindValue(":text", sqlText(segment->text()));
segmentInsert.bindValue(":name", sqlText(segment->name()));
const auto* text =
qobject_cast<const LlmTextSegment*>(segment);
segmentInsert.bindValue(
":tool_call_id", sqlText(segment->toolCallId()));
":text", sqlText(text ? text->text() : QString()));
const auto* call =
qobject_cast<const LlmToolCall*>(segment);
segmentInsert.bindValue(
":arguments", sqlText(segment->arguments()));
":name", sqlText(call ? call->name() : QString()));
segmentInsert.bindValue(
":result", sqlText(segment->result()));
":tool_call_id",
sqlText(call ? call->toolCallId() : QString()));
segmentInsert.bindValue(
":status", static_cast<int>(segment->status()));
":arguments",
sqlText(call ? call->arguments() : QString()));
segmentInsert.bindValue(
":result", sqlText(call ? call->result() : QString()));
segmentInsert.bindValue(
":status", call ? static_cast<int>(call->status()) : 0);
segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
segmentInsert.bindValue(":timestamp", segment->timestamp());
if (!segmentInsert.exec()) {
@@ -448,12 +531,13 @@ bool ChatStore::saveSession(ChatSession* session) {
qWarning() << "ChatStore: saveSession" << id
<< "commit failed, rolling back";
handle.rollback();
ok = false;
qWarning() << "ChatStore: failed to save session" << session->id()
<< ":" << handle.lastError().text();
return false;
}
return ok;
return true;
}
void ChatStore::loadMessagesInto(ChatSession* session) {
if (!session) return;
const QString sessionId = session->id();
@@ -467,15 +551,15 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
QList<MessageRow> rows;
const QString connName = QUuid::createUuid().toString();
{
QSqlDatabase db =
QSqlDatabase handle =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
db.setDatabaseName(path);
if (db.open()) {
QSqlQuery busy(db);
handle.setDatabaseName(path);
if (handle.open()) {
QSqlQuery busy(handle);
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
QSqlQuery query(db);
QSqlQuery query(handle);
query.prepare(
"SELECT id, role, timestamp FROM messages "
"SELECT id, role, content, timestamp FROM messages "
"WHERE session_id = :id ORDER BY rowid DESC");
query.bindValue(":id", sessionId);
if (!query.exec()) {
@@ -487,8 +571,9 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
MessageRow message;
message.user = query.value(1).toString() ==
QLatin1String("user");
message.timestamp = query.value(2).toLongLong();
QSqlQuery generationQuery(db);
message.content = query.value(2).toString();
message.timestamp = query.value(3).toLongLong();
QSqlQuery generationQuery(handle);
generationQuery.prepare(
"SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid "
@@ -501,7 +586,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
generationQuery.value(1).toLongLong();
generation.active =
generationQuery.value(2).toInt() != 0;
QSqlQuery segmentQuery(db);
QSqlQuery segmentQuery(handle);
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, "
"arguments, result, status, elapsed_ms, "
@@ -549,7 +634,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
<< messageId << ":"
<< generationQuery.lastError().text();
}
QSqlQuery attachmentQuery(db);
QSqlQuery attachmentQuery(handle);
attachmentQuery.prepare(
"SELECT name, path, kind, mime, size FROM "
"attachments WHERE message_id = :mid "
@@ -580,10 +665,10 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
rows.append(message);
}
}
db.close();
handle.close();
} else {
qWarning() << "ChatStore: failed to open database for load:"
<< db.lastError().text();
<< handle.lastError().text();
}
}
QSqlDatabase::removeDatabase(connName);
@@ -595,7 +680,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
ChatSession* s = session;
if (!st || !s) return;
auto* model = s->model();
auto* model = s->messagesModel();
if (!model) return;
QList<ChatMessage*> messages;
for (const MessageRow& row : rows) {
@@ -603,6 +688,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
row.timestamp);
if (!row.attachments.isEmpty()) {
QList<Attachment*> attachments;
for (const AttachmentRow& attachmentRow :
@@ -619,27 +705,54 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
}
message->setAttachments(attachments);
}
if (row.user) {
QString content = row.content;
if (content.isEmpty() && !row.generations.isEmpty()) {
for (const SegmentRow& segment :
row.generations.first().segments)
if (segmentTypeFromName(segment.type) ==
LlmSegment::Type::Content) {
content = segment.text;
break;
}
}
if (!content.isEmpty()) message->setContent(content);
messages.append(message);
continue;
}
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);
if (!generation) continue;
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);
const LlmSegment::Type type =
segmentTypeFromName(segmentRow.type);
LlmSegment* segment = nullptr;
if (type == LlmSegment::Type::ToolCall) {
auto* call = new LlmToolCall(
segmentRow.timestamp, generation);
call->setName(segmentRow.name);
call->setToolCallId(segmentRow.toolCallId);
call->appendArguments(segmentRow.arguments);
call->setResult(segmentRow.result);
call->setStatus(
static_cast<LlmToolCall::Status>(
segmentRow.status));
call->restore(segmentRow.elapsedMs);
segment = call;
} else {
auto* text = new LlmTextSegment(
type, segmentRow.timestamp, generation);
text->setText(segmentRow.text);
text->restore(segmentRow.elapsedMs);
segment = text;
}
generation->addSegment(segment);
}
if (generationRow.active) activeIndex = i;
@@ -658,54 +771,35 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
s->markLoaded();
if (s->takeClearPending()) {
s->clear();
} else if (st->m_pendingPersists.remove(s)) {
st->persist(s);
}
st->applyPendingClear(s);
if (st->m_pendingPersists.remove(s)) st->persist(s);
},
Qt::QueuedConnection);
});
}
void ChatStore::load() {
m_loading = true;
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(
auto* session = new ChatSession(this, query.value(0).toString(), this);
session->hydrate(
query.value(1).toString(),
query.value(2).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);
query.value(6).toInt(),
query.value(5).toInt() != 0);
m_sessions.append(session);
}
sortAndNotify();
}
m_loading = false;
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();
beginResetModel();
endResetModel();
}
} // namespace ZShell::llm
+40 -29
View File
@@ -2,65 +2,76 @@
#include "session.hpp"
#include <QObject>
#include <QAbstractListModel>
#include <QHash>
#include <QList>
#include <QSet>
#include <QSqlDatabase>
#include <QString>
#include <QVariantList>
#include <QtQml>
namespace ZShell::llm {
class LlmClient;
class ChatStore : public QObject {
class ChatStore : public QAbstractListModel {
Q_OBJECT
QML_ANONYMOUS
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged)
QML_ELEMENT
QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton")
public:
explicit ChatStore(QObject* parent = nullptr);
explicit ChatStore(LlmClient* client, QObject* parent = nullptr);
~ChatStore() override;
[[nodiscard]] int count() const;
[[nodiscard]] QVariantList values() const;
[[nodiscard]] ChatSession* at(int index) const;
[[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;
Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1);
Q_INVOKABLE void remove(int index);
[[nodiscard]] LlmClient* client() const { return m_client; }
[[nodiscard]] int rowOf(const ChatSession* session) const;
Q_INVOKABLE ZShell::llm::ChatSession* draft();
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 commitDraft(ZShell::llm::ChatSession* session);
void persist(ChatSession* session);
void saveMeta(ChatSession* session);
void loadMessagesInto(ChatSession* session);
void persist(ZShell::llm::ChatSession* session);
void saveFlags(ZShell::llm::ChatSession* session);
void loadMessagesInto(ZShell::llm::ChatSession* session);
void requestClear(ZShell::llm::ChatSession* session);
void applyPendingClear(ZShell::llm::ChatSession* session);
void reposition(ZShell::llm::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);
[[nodiscard]] int sortedRow(const ChatSession* session) const;
[[nodiscard]] static bool firstAfter(
const ChatSession* a, const ChatSession* b);
void insertRow(ChatSession* session);
void moveRow(int from, int to);
void removeRow(ChatSession* session);
[[nodiscard]] bool saveSession(ChatSession* session);
[[nodiscard]] bool writeSessionRow(ChatSession* session);
[[nodiscard]] QSqlDatabase db() const;
LlmClient* m_client;
QList<ChatSession*> m_sessions;
LlmClient* m_llmClient = nullptr;
ChatSession* m_draft = nullptr;
QString m_connectionName;
QString m_dbPath;
QSet<ChatSession*> m_pendingPersists;
[[nodiscard]] QSqlDatabase db() const;
QSet<ChatSession*> m_pendingClears;
bool m_loading = false;
};
} // namespace ZShell::llm
+61
View File
@@ -0,0 +1,61 @@
#include "clipboard.hpp"
#include <QDebug>
#include <QClipboard>
#include <QDateTime>
#include <QDir>
#include <QGuiApplication>
#include <QImage>
#include <QMimeData>
#include <QUrl>
namespace ZShell::llm::clipboard {
bool hasAttachment() {
QClipboard* clipboard = QGuiApplication::clipboard();
if (!clipboard) return false;
const QMimeData* data = clipboard->mimeData();
if (!data) return false;
if (data->hasImage()) return true;
for (const QUrl& url : data->urls())
if (url.isLocalFile()) return true;
return false;
}
QStringList attachmentPaths(QString& error) {
error.clear();
QClipboard* clipboard = QGuiApplication::clipboard();
const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr;
if (!data) {
qWarning() << "clipboard: no clipboard data";
return {};
}
QStringList paths;
if (data->hasImage()) {
const QImage image = clipboard->image();
if (image.isNull()) {
error = QStringLiteral("Clipboard image could not be decoded");
return {};
}
const QString tempPath =
QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png")
.arg(QDateTime::currentMSecsSinceEpoch());
if (!image.save(tempPath)) {
error = QStringLiteral("Could not save the pasted image");
return {};
}
paths.append(tempPath);
} else if (data->hasUrls()) {
for (const QUrl& url : data->urls())
if (url.isLocalFile()) paths.append(url.toLocalFile());
}
if (paths.isEmpty() && (data->hasImage() || data->hasUrls()))
error = QStringLiteral(
"Nothing usable on the clipboard (expected an image "
"or copied files)");
return paths;
}
} // namespace ZShell::llm::clipboard
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <QString>
#include <QStringList>
namespace ZShell::llm::clipboard {
[[nodiscard]] bool hasAttachment();
[[nodiscard]] QStringList attachmentPaths(QString& error);
} // namespace ZShell::llm::clipboard
+27 -44
View File
@@ -6,23 +6,8 @@
namespace ZShell::llm {
namespace {
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
FileReadTool::FileReadTool(QObject* parent) : LlmTool(parent) {}
FileReadTool::FileReadTool(ToolRegistry* registry, QObject* parent)
: LlmTool(registry, parent) {}
QString FileReadTool::name() const {
return QStringLiteral("readfile");
@@ -34,8 +19,8 @@ QString FileReadTool::description() const {
"its content. At most a limited number of characters is returned "
"per call; use offset and limit to page through larger files. "
"Binary files are not supported. Full output of webfetch calls "
"is stored under /tmp/zshell-llm/webfetch/ and can be read this "
"way. This tool is read-only.");
"is saved in the sandbox directory and can be read this way. "
"This tool is read-only.");
}
QJsonObject FileReadTool::parameters() const {
@@ -72,19 +57,16 @@ QJsonObject FileReadTool::parameters() const {
}
void FileReadTool::execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
Q_UNUSED(toolCallId);
const ToolContext& context, const QJsonObject& args, ToolDone done) {
const QString path = args[QStringLiteral("path")].toString().trimmed();
if (path.isEmpty()) {
done(makeError(QStringLiteral("Missing required argument 'path'")));
done({false, QStringLiteral("Missing required argument 'path'")});
return;
}
const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0));
int budget = inlineBudgetChars();
if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars);
const int budget = context.outputBudgetChars > 0
? context.outputBudgetChars
: registry()->outputBudgetChars(1);
const int limit =
qBound(1, args[QStringLiteral("limit")].toInt(budget), budget);
@@ -92,33 +74,33 @@ void FileReadTool::execute(
if (!file.open(QIODevice::ReadOnly)) {
const QFileInfo info(path);
if (info.exists() && info.isDir()) {
done(makeError(QStringLiteral(
"Path is a directory, not a "
"file: %1")
.arg(path)));
done(
{false,
QStringLiteral("Path is a directory, not a file: %1")
.arg(path)});
} else {
done(makeError(QStringLiteral("Cannot open file: %1").arg(path)));
done({false, QStringLiteral("Cannot open file: %1").arg(path)});
}
return;
}
const qint64 fileSize = file.size();
if (fileSize == 0) {
file.close();
done(makeOutput(QString()));
done({true, QString()});
return;
}
if (offset >= fileSize) {
file.close();
done(makeError(QStringLiteral(
"Offset %1 is beyond the end of the "
"file (%2 bytes)")
.arg(offset)
.arg(fileSize)));
done(
{false,
QStringLiteral("Offset %1 is beyond the end of the file (%2 bytes)")
.arg(offset)
.arg(fileSize)});
return;
}
if (!file.seek(offset)) {
file.close();
done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path)));
done({false, QStringLiteral("Cannot seek in file: %1").arg(path)});
return;
}
const qint64 wanted = qint64(limit) * 4 + 16;
@@ -126,17 +108,18 @@ void FileReadTool::execute(
file.close();
if (chunk.contains('\0')) {
done(makeError(
QStringLiteral("Binary files are not supported: %1").arg(path)));
done(
{false,
QStringLiteral("Binary files are not supported: %1").arg(path)});
return;
}
const QString content = QString::fromUtf8(chunk);
if (content.size() <= limit) {
done(makeOutput(content));
done({true, content});
return;
}
const QString head = content.left(limit);
const int nextOffset = offset + head.toUtf8().size();
const int nextOffset = offset + static_cast<int>(head.toUtf8().size());
const QString output =
head +
QStringLiteral(
@@ -147,7 +130,7 @@ void FileReadTool::execute(
.arg(nextOffset)
.arg(fileSize)
.arg(nextOffset);
done(makeOutput(output));
done({true, output});
}
} // namespace ZShell::llm
+3 -4
View File
@@ -8,16 +8,15 @@ class FileReadTool : public LlmTool {
Q_OBJECT
public:
explicit FileReadTool(QObject* parent = nullptr);
explicit FileReadTool(ToolRegistry* registry, QObject* parent = nullptr);
QString name() const override;
QString description() const override;
QJsonObject parameters() const override;
void execute(
const QString& toolCallId,
const ToolContext& context,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) override;
ToolDone done) override;
};
} // namespace ZShell::llm
+112 -101
View File
@@ -1,37 +1,50 @@
#include "generation.hpp"
#include "llmclient.hpp"
#include "messagemodel.hpp"
#include "message.hpp"
#include "session.hpp"
#include <QDateTime>
namespace ZShell::llm {
ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent)
: QObject(parent), m_timestamp(timestamp) {
ChatGeneration::ChatGeneration(
ChatMessage* message, qint64 timestamp, QObject* parent)
: QObject(parent), m_message(message), 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;
bool runningText = false;
bool runningTool = false;
for (auto* segment : m_segments) {
if (!segment->running()) continue;
anyRunning = true;
segment->elapsedMsChanged();
Q_EMIT segment->elapsedMsChanged();
if (segment->type() == LlmSegment::Type::ToolCall)
runningTool = true;
else
runningText = true;
}
if (anyRunning) Q_EMIT elapsedMsChanged();
if (!anyRunning && !m_streaming) m_timer.stop();
if (runningText) {
Q_EMIT reasoningElapsedMsChanged();
Q_EMIT contentElapsedMsChanged();
}
if (runningTool) Q_EMIT toolsElapsedMsChanged();
if (!runningText && !runningTool && !m_streaming) m_timer.stop();
});
}
ChatMessage* ChatGeneration::message() const {
return m_message;
}
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());
if (segment->type() != LlmSegment::Type::Content) continue;
const auto* text = qobject_cast<const LlmTextSegment*>(segment);
if (!text || text->text().isEmpty()) continue;
parts.append(text->text());
}
return parts.join(QStringLiteral("\n\n"));
}
@@ -39,10 +52,10 @@ QString ChatGeneration::content() const {
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());
if (segment->type() != LlmSegment::Type::Reasoning) continue;
const auto* text = qobject_cast<const LlmTextSegment*>(segment);
if (!text || text->text().isEmpty()) continue;
parts.append(text->text());
}
return parts.join(QStringLiteral("\n\n"));
}
@@ -77,25 +90,27 @@ qint64 ChatGeneration::toolsElapsedMs() const {
return total;
}
QList<LlmToolCall*> ChatGeneration::toolCalls() const {
QList<LlmToolCall*> calls;
for (auto* segment : m_segments)
if (auto* call = qobject_cast<LlmToolCall*>(segment))
calls.append(call);
return calls;
}
int ChatGeneration::toolCallCount() const {
int count = 0;
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall) ++count;
return count;
return static_cast<int>(toolCalls().size());
}
bool ChatGeneration::hasRunningTool() const {
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall && segment->running())
return true;
for (const auto* call : toolCalls())
if (call->running()) return true;
return false;
}
bool ChatGeneration::hasPendingTool() const {
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
return true;
for (const auto* call : toolCalls())
if (call->status() == LlmToolCall::Status::Pending) return true;
return false;
}
@@ -103,30 +118,37 @@ void ChatGeneration::setApprovalPending(bool value) {
if (m_approvalPending == value) return;
m_approvalPending = value;
Q_EMIT toolApprovalPendingChanged();
Q_EMIT pendingToolCallsChanged();
}
QVariantList ChatGeneration::pendingToolCalls() const {
QVariantList out;
if (!m_approvalPending) return out;
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
out.append(QVariant::fromValue(segment));
for (const auto* call : toolCalls())
if (call->status() == LlmToolCall::Status::Pending)
out.append(QVariant::fromValue(call));
return out;
}
void ChatGeneration::approveTools() {
if (auto* llmClient = client()) llmClient->approveTools();
if (auto* llmClient = client()) llmClient->approveTools(this);
}
void ChatGeneration::denyTools() {
if (auto* llmClient = client()) llmClient->denyTools();
if (auto* llmClient = client()) llmClient->denyTools(this);
}
void ChatGeneration::dismissError() {
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
Q_EMIT errorMessageChanged();
}
LlmClient* ChatGeneration::client() const {
if (auto* message = qobject_cast<ChatMessage*>(parent()))
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent()))
if (auto* session = model->session()) return session->client();
if (m_message && m_message->session())
return m_message->session()->client();
return nullptr;
}
@@ -138,21 +160,19 @@ void ChatGeneration::updateReasoningActive() {
}
void ChatGeneration::setContent(const QString& value) {
LlmSegment* first = nullptr;
LlmTextSegment* first = nullptr;
for (auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content) continue;
auto* text = qobject_cast<LlmTextSegment*>(segment);
if (!text) continue;
if (!first)
first = segment;
first = text;
else
segment->setText(QString());
text->setText(QString());
}
if (!first) {
if (value.isEmpty()) return;
first = new LlmSegment(
LlmSegment::Type::Content,
QDateTime::currentMSecsSinceEpoch(),
this);
addSegment(first);
first = openTextSegment(LlmSegment::Type::Content);
}
first->setText(value);
}
@@ -164,7 +184,7 @@ void ChatGeneration::appendContent(const QString& piece) {
segment->running())
segment->close();
}
openContentSegment()->appendText(piece);
openTextSegment(LlmSegment::Type::Content)->appendText(piece);
}
void ChatGeneration::appendReasoning(const QString& piece) {
@@ -173,7 +193,7 @@ void ChatGeneration::appendReasoning(const QString& piece) {
if (segment->type() == LlmSegment::Type::Content && segment->running())
segment->close();
}
openReasoningSegment()->appendText(piece);
openTextSegment(LlmSegment::Type::Reasoning)->appendText(piece);
}
void ChatGeneration::setError(const QString& value) {
@@ -182,13 +202,7 @@ void ChatGeneration::setError(const QString& value) {
m_hasError = hasError;
m_errorMessage = value;
Q_EMIT errorChanged();
}
void ChatGeneration::dismissError() {
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
Q_EMIT errorMessageChanged();
}
void ChatGeneration::setStreaming(bool value) {
@@ -201,70 +215,67 @@ void ChatGeneration::setStreaming(bool value) {
closeOpenSegments();
m_timer.stop();
}
Q_EMIT elapsedMsChanged();
Q_EMIT reasoningElapsedMsChanged();
Q_EMIT contentElapsedMsChanged();
Q_EMIT toolsElapsedMsChanged();
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;
LlmTextSegment* ChatGeneration::openTextSegment(LlmSegment::Type type) {
for (auto* segment : m_segments) {
if (segment->type() != type) continue;
auto* text = qobject_cast<LlmTextSegment*>(segment);
if (text && text->running()) return text;
}
auto* text =
new LlmTextSegment(type, QDateTime::currentMSecsSinceEpoch(), this);
text->begin();
addSegment(text);
return text;
}
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(
LlmToolCall* 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::Pending);
segment->begin();
addSegment(segment);
Q_EMIT toolStateChanged();
return segment;
auto* call = new LlmToolCall(QDateTime::currentMSecsSinceEpoch(), this);
call->setName(name);
call->setToolCallId(toolCallId);
call->setStatus(LlmToolCall::Status::Pending);
call->begin();
addSegment(call);
Q_EMIT toolCallCountChanged();
return call;
}
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();
connect(segment, &LlmSegment::runningChanged, this, [this, segment]() {
Q_EMIT toolCallCountChanged();
if (segment->type() == LlmSegment::Type::ToolCall)
Q_EMIT hasRunningToolChanged();
updateReasoningActive();
});
if (auto* text = qobject_cast<LlmTextSegment*>(segment))
connect(text, &LlmTextSegment::textChanged, this, [this, text]() {
if (text->type() == LlmSegment::Type::Reasoning)
Q_EMIT reasoningChanged();
else
Q_EMIT contentChanged();
updateReasoningActive();
});
if (auto* call = qobject_cast<LlmToolCall*>(segment)) {
connect(call, &LlmToolCall::statusChanged, this, [this, call]() {
Q_EMIT hasRunningToolChanged();
Q_EMIT pendingToolCallsChanged();
if (call->status() == LlmToolCall::Status::Pending)
Q_EMIT toolApprovalPendingChanged();
updateReasoningActive();
});
}
m_segments.append(segment);
Q_EMIT segmentsChanged();
Q_EMIT toolCallCountChanged();
}
void ChatGeneration::closeOpenSegments() {
+29 -18
View File
@@ -5,12 +5,13 @@
#include <QList>
#include <QObject>
#include <QString>
#include <QTimer>
#include <QVariantList>
#include <QtQml>
namespace ZShell::llm {
class ChatMessage;
class ChatSession;
class LlmClient;
class ChatGeneration : public QObject {
@@ -18,35 +19,39 @@ class ChatGeneration : public QObject {
QML_ELEMENT
QML_UNCREATABLE("Chat generations are managed by ChatMessage")
Q_PROPERTY(
QString content READ content WRITE setContent NOTIFY contentChanged)
Q_PROPERTY(QString content READ content NOTIFY contentChanged)
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged)
Q_PROPERTY(bool error READ error NOTIFY errorChanged)
Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged)
Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorMessageChanged)
Q_PROPERTY(
qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
elapsedMsChanged)
reasoningElapsedMsChanged)
Q_PROPERTY(
qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged)
qint64 contentElapsedMs READ contentElapsedMs NOTIFY
contentElapsedMsChanged)
Q_PROPERTY(
qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY toolsElapsedMsChanged)
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(
bool hasRunningTool READ hasRunningTool NOTIFY hasRunningToolChanged)
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)
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY toolCallCountChanged)
Q_PROPERTY(
bool toolApprovalPending READ toolApprovalPending NOTIFY
toolApprovalPendingChanged)
Q_PROPERTY(
QVariantList pendingToolCalls READ pendingToolCalls NOTIFY
toolApprovalPendingChanged)
pendingToolCallsChanged)
public:
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
ChatGeneration(ChatMessage* message, qint64 timestamp, QObject* parent);
[[nodiscard]] ChatMessage* message() const;
[[nodiscard]] QString content() const;
[[nodiscard]] QString reasoning() const;
@@ -59,12 +64,12 @@ class ChatGeneration : public QObject {
[[nodiscard]] bool streaming() const { return m_streaming; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
[[nodiscard]] QList<LlmToolCall*> toolCalls() const;
[[nodiscard]] int toolCallCount() const;
[[nodiscard]] bool hasRunningTool() const;
[[nodiscard]] bool hasPendingTool() const;
[[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; }
void setApprovalPending(bool value);
[[nodiscard]] QVariantList pendingToolCalls() const;
Q_INVOKABLE void approveTools();
@@ -76,10 +81,10 @@ class ChatGeneration : public QObject {
void appendReasoning(const QString& piece);
void setStreaming(bool value);
void setError(const QString& value);
void setApprovalPending(bool value);
[[nodiscard]] LlmSegment* openContentSegment();
[[nodiscard]] LlmSegment* openReasoningSegment();
[[nodiscard]] LlmSegment* beginToolCall(
[[nodiscard]] LlmTextSegment* openTextSegment(LlmSegment::Type type);
[[nodiscard]] LlmToolCall* beginToolCall(
const QString& name, const QString& toolCallId);
void addSegment(LlmSegment* segment);
void closeOpenSegments();
@@ -88,17 +93,23 @@ class ChatGeneration : public QObject {
void contentChanged();
void reasoningChanged();
void errorChanged();
void errorMessageChanged();
void reasoningActiveChanged();
void elapsedMsChanged();
void reasoningElapsedMsChanged();
void contentElapsedMsChanged();
void toolsElapsedMsChanged();
void streamingChanged();
void toolStateChanged();
void hasRunningToolChanged();
void segmentsChanged();
void toolCallCountChanged();
void toolApprovalPendingChanged();
void pendingToolCallsChanged();
private:
void updateReasoningActive();
LlmClient* client() const;
[[nodiscard]] LlmClient* client() const;
ChatMessage* m_message;
QTimer m_timer;
QList<LlmSegment*> m_segments;
bool m_reasoningActive = false;
File diff suppressed because it is too large Load Diff
+52 -73
View File
@@ -1,18 +1,19 @@
#pragma once
#include "segment.hpp"
#include "chatstore.hpp"
#include "tool.hpp"
#include "turn.hpp"
#include <QByteArray>
#include <QJsonArray>
#include <QNetworkAccessManager>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
#include <QTimer>
#include <functional>
#include <memory>
class QJsonObject;
class QNetworkReply;
@@ -21,80 +22,74 @@ namespace ZShell::llm {
class ChatGeneration;
class ChatSession;
class LlmSegment;
class LlmTool;
class LlmClient : public QObject {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
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(
bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY
sandboxEnabledChanged)
Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged)
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
public:
explicit LlmClient(QObject* parent = nullptr);
~LlmClient() override;
[[nodiscard]] QString endpoint() const { return m_endpoint; }
[[nodiscard]] ChatStore* chats() const { return m_store; }
[[nodiscard]] bool busy() const { return m_busy; }
[[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; }
[[nodiscard]] QString serverError() const { return m_serverError; }
void setEndpoint(const QString& value);
void setModel(const QString& value);
void setTemperature(double value);
void setContextSize(int size);
void probeContextSize(std::function<void()> done = {});
void refreshAllStates();
[[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] int contextSize() const { return m_tools->contextSize(); }
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
void setToolsEnabled(bool value) { m_tools->setEnabled(value); }
[[nodiscard]] bool sandboxEnabled() const { return m_sandboxEnabled; }
[[nodiscard]] bool sandboxEnabled() const {
return m_tools->sandboxEnabled();
}
[[nodiscard]] QString serverError() const { return m_serverError; }
[[nodiscard]] ChatSession* streamingSession() const {
return m_turn ? m_turn->session() : nullptr;
}
void setToolsEnabled(bool value);
void setSandboxEnabled(bool value);
[[nodiscard]] QString activeWorkspace() const;
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
[[nodiscard]] ChatSession* streamingSession() const { return m_active; }
Q_INVOKABLE void selectModel(const QString& id);
Q_INVOKABLE void stop();
Q_INVOKABLE void refreshModels();
void startGeneration(ChatSession* session, ChatGeneration* target);
void stop();
void endStream();
void clearOnFinish(ChatSession* session);
void sessionRemoved(ChatSession* session);
void approveTools();
void denyTools();
void refreshModels();
void approveTools(ZShell::llm::ChatGeneration* generation);
void denyTools(ZShell::llm::ChatGeneration* generation);
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 sandboxEnabledChanged();
void serverErrorChanged();
void titleSuggested(ZShell::llm::ChatSession* session, const QString& title);
void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon);
void generationFailed(
ZShell::llm::ChatSession* session, const QString& message);
private:
struct ToolCallBuilder {
QString id;
QString name;
QString arguments;
QPointer<LlmSegment> segment;
bool seen = false;
};
void sendRound();
QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
void appendSandboxPreamble(QJsonArray& messages);
void applyToolCallDelta(const QJsonObject& call);
void roundFinished();
void executeAllCalls();
@@ -102,13 +97,17 @@ class LlmClient : public QObject {
void setApprovalPending(bool value);
void finishPendingCalls(const QString& resultText);
void finishTurn();
void endStream();
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 setServerError(const QString& value);
void setModel(const QString& value);
void setContextSize(int size);
void probeContextSize(std::function<void()> done = {});
void refreshAllStates();
void shortRequest(
const QString& tag,
const QString& systemPrompt,
@@ -119,42 +118,22 @@ class LlmClient : public QObject {
static QString jsonErrorMessage(const QByteArray& body);
static QString serverErrorMessage(
const QByteArray& body, const QString& fallback);
[[nodiscard]] ToolContext toolContext() const;
QNetworkAccessManager m_manager;
ChatStore* m_store = nullptr;
ToolRegistry* m_tools = nullptr;
std::unique_ptr<Turn> m_turn;
QNetworkReply* m_reply = nullptr;
QByteArray m_buffer;
QTimer m_refreshTimer;
QPointer<ChatSession> m_active;
QPointer<ChatGeneration> m_streaming;
QPointer<ChatSession> m_pendingClear;
bool m_busy = false;
QString m_streamingChatId;
QString m_endpoint;
QString m_model;
QStringList m_availableModels;
QString m_serverError;
double m_temperature = 0.7;
int m_contextSize = 0;
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_sandboxEnabled = true;
bool m_roundDone = false;
bool m_streamDone = false;
bool m_toolPhase = false;
bool m_approvalPending = false;
bool m_contextRetryUsed = false;
int m_pendingCalls = 0;
bool m_busy = false;
bool m_configEnabled = true;
static constexpr int kMaxToolRounds = 12;
};
+23
View File
@@ -0,0 +1,23 @@
#include "markdownblock.hpp"
namespace ZShell::llm {
MarkdownBlock::MarkdownBlock(
Type type,
const QString& id,
const QString& text,
const QString& latex,
const QString& code,
const QString& language,
int level,
QObject* parent)
: QObject(parent)
, m_type(type)
, m_id(id)
, m_text(text)
, m_latex(latex)
, m_code(code)
, m_language(language)
, m_level(level) {}
} // namespace ZShell::llm
+37 -3
View File
@@ -1,20 +1,54 @@
#pragma once
#include <QObject>
#include <QString>
#include <QtQml>
namespace ZShell::llm {
class LlmMarkdown : public QObject {
class MarkdownBlock : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Blocks are produced by LlmSegment")
QML_UNCREATABLE("Blocks are produced by LlmTextSegment")
Q_PROPERTY(Type type READ type CONSTANT)
Q_PROPERTY(QString id READ id CONSTANT)
Q_PROPERTY(QString text READ text CONSTANT)
Q_PROPERTY(QString latex READ latex CONSTANT)
Q_PROPERTY(QString code READ code CONSTANT)
Q_PROPERTY(QString language READ language CONSTANT)
Q_PROPERTY(int level READ level CONSTANT)
public:
enum class Type : int { Text = 0, Heading, Code, Math };
Q_ENUM(Type)
explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {}
MarkdownBlock(
Type type,
const QString& id,
const QString& text,
const QString& latex,
const QString& code,
const QString& language,
int level,
QObject* parent = nullptr);
[[nodiscard]] Type type() const { return m_type; }
[[nodiscard]] QString id() const { return m_id; }
[[nodiscard]] QString text() const { return m_text; }
[[nodiscard]] QString latex() const { return m_latex; }
[[nodiscard]] QString code() const { return m_code; }
[[nodiscard]] QString language() const { return m_language; }
[[nodiscard]] int level() const { return m_level; }
private:
Type m_type;
QString m_id;
QString m_text;
QString m_latex;
QString m_code;
QString m_language;
int m_level = 0;
};
} // namespace ZShell::llm
+16 -25
View File
@@ -1,18 +1,15 @@
#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;
QVector<MarkdownBlockData> MarkdownParser::parse(const QString& source) {
QVector<MarkdownBlockData> blocks;
if (source.trimmed().isEmpty()) return blocks;
const QStringList lines = source.split('\n');
@@ -35,42 +32,36 @@ QVariantList MarkdownParser::parse(const QString& source) {
QStringLiteral("\\$\\$(.+?)\\$\\$"),
QRegularExpression::DotMatchesEverythingOption);
auto makeBlock = [&](LlmMarkdown::Type type) {
QVariantMap block;
block.insert("type", static_cast<int>(type));
block.insert(
"id",
QString::number(static_cast<int>(blocks.size())) +
QLatin1Char(':') + QString::number(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);
MarkdownBlockData block;
block.type = MarkdownBlock::Type::Text;
block.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);
MarkdownBlockData block;
block.type = MarkdownBlock::Type::Math;
block.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);
MarkdownBlockData block;
block.type = MarkdownBlock::Type::Code;
block.language = language;
block.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);
MarkdownBlockData block;
block.type = MarkdownBlock::Type::Heading;
block.level = level;
block.text = text;
blocks.append(block);
};
+13 -2
View File
@@ -1,13 +1,24 @@
#pragma once
#include "markdownblock.hpp"
#include <QString>
#include <QVariantList>
#include <QVector>
namespace ZShell::llm {
struct MarkdownBlockData {
MarkdownBlock::Type type = MarkdownBlock::Type::Text;
QString text;
QString latex;
QString code;
QString language;
int level = 0;
};
class MarkdownParser {
public:
[[nodiscard]] static QVariantList parse(const QString& source);
[[nodiscard]] static QVector<MarkdownBlockData> parse(const QString& source);
};
} // namespace ZShell::llm
+40 -17
View File
@@ -1,25 +1,36 @@
#include "message.hpp"
#include "messagemodel.hpp"
#include "session.hpp"
namespace ZShell::llm {
namespace {
ChatMessage::ChatMessage(
ChatSession* session, Role role, qint64 timestamp, QObject* parent)
: QObject(parent)
, m_session(session)
, m_role(role)
, m_timestamp(timestamp) {}
ChatSession* sessionOf(const ChatMessage* message) {
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent()))
return model->session();
return nullptr;
QString ChatMessage::content() const {
if (m_role == Role::User) return m_content;
const ChatGeneration* generation = activeGeneration();
return generation ? generation->content() : QString();
}
} // namespace
ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent)
: QObject(parent), m_role(role), m_timestamp(timestamp) {}
void ChatMessage::setContent(const QString& value) {
if (m_role != Role::User || m_content == value) return;
m_content = value;
Q_EMIT contentChanged();
}
ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) {
auto* generation = new ChatGeneration(timestamp, this);
if (m_role != Role::Assistant) return nullptr;
auto* generation = new ChatGeneration(this, timestamp, this);
connect(
generation, &ChatGeneration::contentChanged, this, [this, generation]() {
if (generation == activeGeneration()) Q_EMIT contentChanged();
});
m_generations.append(generation);
if (m_active < 0) m_active = static_cast<int>(m_generations.size() - 1);
Q_EMIT generationsChanged();
@@ -27,7 +38,7 @@ ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) {
}
ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) {
auto* generation = addGeneration(timestamp);
ChatGeneration* generation = addGeneration(timestamp);
setActiveInternal(static_cast<int>(m_generations.size() - 1));
return generation;
}
@@ -44,13 +55,17 @@ void ChatMessage::removeGeneration(ChatGeneration* generation) {
m_active = static_cast<int>(m_generations.size() - 1);
}
Q_EMIT generationsChanged();
if (wasActive) Q_EMIT activeGenerationChanged();
if (wasActive) {
Q_EMIT activeGenerationChanged();
Q_EMIT contentChanged();
}
}
void ChatMessage::setActiveInternal(int index) {
if (index < 0 || index >= m_generations.size() || index == m_active) return;
m_active = index;
Q_EMIT activeGenerationChanged();
Q_EMIT contentChanged();
}
void ChatMessage::setActiveGeneration(int index) {
@@ -72,17 +87,25 @@ void ChatMessage::setAttachments(QList<Attachment*> attachments) {
}
void ChatMessage::edit(const QString& newContent) {
if (auto* generation = activeGeneration())
if (m_role == Role::User) {
setContent(newContent);
} else if (ChatGeneration* generation = activeGeneration()) {
generation->setContent(newContent);
if (auto* session = sessionOf(this)) session->persist();
} else {
return;
}
if (m_session) m_session->persist();
}
void ChatMessage::retry() {
if (auto* session = sessionOf(this)) session->retry(this);
if (m_role != Role::Assistant || !m_session) return;
m_session->retry(this);
}
void ChatMessage::generate() {
if (auto* session = sessionOf(this)) session->continueFrom(this);
if (m_role != Role::Assistant || !m_session) return;
m_session->continueFrom(this);
}
} // namespace ZShell::llm
+16 -2
View File
@@ -10,13 +10,16 @@
namespace ZShell::llm {
class ChatSession;
class ChatMessage : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Chat messages are created by the Chat singleton")
QML_UNCREATABLE("Chat messages are created by ChatMessageModel")
Q_PROPERTY(Role role READ role CONSTANT)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(QString content READ content NOTIFY contentChanged)
Q_PROPERTY(
int generationCount READ generationCount NOTIFY generationsChanged)
Q_PROPERTY(
@@ -36,10 +39,16 @@ class ChatMessage : public QObject {
enum class Role : int { User = 0, Assistant };
Q_ENUM(Role)
explicit ChatMessage(Role role, qint64 timestamp, QObject* parent = nullptr);
ChatMessage(
ChatSession* session,
Role role,
qint64 timestamp,
QObject* parent = nullptr);
[[nodiscard]] ChatSession* session() const { return m_session; }
[[nodiscard]] Role role() const { return m_role; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] QString content() const;
[[nodiscard]] int generationCount() const {
return static_cast<int>(m_generations.size());
}
@@ -68,7 +77,10 @@ class ChatMessage : public QObject {
ChatGeneration* appendGeneration(qint64 timestamp);
void removeGeneration(ChatGeneration* generation);
void setContent(const QString& value);
Q_SIGNALS:
void contentChanged();
void generationsChanged();
void activeGenerationChanged();
void attachmentsChanged();
@@ -76,8 +88,10 @@ class ChatMessage : public QObject {
private:
void setActiveInternal(int index);
ChatSession* m_session;
Role m_role;
qint64 m_timestamp;
QString m_content;
QList<ChatGeneration*> m_generations;
QList<Attachment*> m_attachments;
int m_active = -1;
+8 -4
View File
@@ -40,14 +40,18 @@ int ChatMessageModel::rowOf(const ChatMessage* message) const {
ChatMessage* ChatMessageModel::createMessage(
ChatMessage::Role role, qint64 timestamp) {
return new ChatMessage(role, timestamp, this);
return new ChatMessage(m_session, 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);
auto* message = createMessage(role, timestamp);
if (role == ChatMessage::Role::User) {
message->setContent(content);
} else {
auto* generation = message->addGeneration(timestamp);
generation->setContent(content);
}
beginInsertRows(QModelIndex(), 0, 0);
m_messages.prepend(message);
+68
View File
@@ -0,0 +1,68 @@
#include "prompts.hpp"
namespace ZShell::llm::prompts {
QString sandboxPreamble(const QString& workspace, const QStringList& files) {
return QStringLiteral(
"The user attached the following files to this "
"conversation. They are available in your sandbox "
"directory at %1:\n%2\nruncommand executes shell "
"commands inside a bubblewrap sandbox: the rest of the "
"filesystem is read-only, there is no network access, "
"and only the sandbox directory is writable. Use it to "
"inspect, transform, or extract attached files (for "
"example to extract archives or search across many "
"files). Files you create stay inside the sandbox "
"directory. Use the webfetch tool for anything that "
"needs the network.")
.arg(workspace, files.join(QStringLiteral("\n")));
}
QString titleSystemPrompt() {
return 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.");
}
QString iconSystemPrompt() {
return 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(iconNames().join(QStringLiteral(", ")));
}
QStringList iconNames() {
return {
"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",
};
}
QString cleanTitle(const QString& raw) {
static 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');
};
QString title = raw;
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() > MaxTitleLength)
title = title.left(MaxTitleLength - 1) + QStringLiteral("…");
return title;
}
} // namespace ZShell::llm::prompts
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <QString>
#include <QStringList>
namespace ZShell::llm::prompts {
[[nodiscard]] QString sandboxPreamble(
const QString& workspace, const QStringList& files);
[[nodiscard]] QString titleSystemPrompt();
[[nodiscard]] QString iconSystemPrompt();
[[nodiscard]] QStringList iconNames();
constexpr int MaxTitleLength = 48;
[[nodiscard]] QString cleanTitle(const QString& raw);
} // namespace ZShell::llm::prompts
+104 -74
View File
@@ -11,16 +11,12 @@ namespace ZShell::llm {
namespace {
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;
if (parseMarkdown()) m_markdownDirty = false;
});
}
: QObject(parent), m_type(type), m_timestamp(timestamp) {}
LlmSegment::~LlmSegment() = default;
qint64 LlmSegment::elapsedMs() const {
if (m_startedAt <= 0) return 0;
@@ -45,60 +41,7 @@ void LlmSegment::close() {
Q_EMIT runningChanged();
Q_EMIT elapsedMsChanged();
}
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();
closed();
}
void LlmSegment::restore(qint64 elapsedMs) {
@@ -106,32 +49,119 @@ void LlmSegment::restore(qint64 elapsedMs) {
m_endedAt = m_timestamp + qMax<qint64>(0, elapsedMs);
}
void LlmSegment::scheduleMarkdown() {
if (m_type != Type::Content) return;
LlmTextSegment::LlmTextSegment(Type type, qint64 timestamp, QObject* parent)
: LlmSegment(type, timestamp, parent) {
m_markdownTimer.setInterval(kMarkdownRefreshMs);
connect(&m_markdownTimer, &QTimer::timeout, this, [this]() {
if (!m_markdownDirty) return;
if (parseMarkdown()) m_markdownDirty = false;
});
}
void LlmTextSegment::setText(const QString& value) {
if (m_text == value) return;
m_text = value;
Q_EMIT textChanged();
scheduleMarkdown();
}
void LlmTextSegment::appendText(const QString& piece) {
if (piece.isEmpty()) return;
m_text += piece;
Q_EMIT textChanged();
scheduleMarkdown();
}
void LlmTextSegment::closed() {
if (type() != Type::Content) return;
if (m_markdownDirty) {
m_markdownTimer.stop();
if (parseMarkdown()) m_markdownDirty = false;
}
}
void LlmTextSegment::scheduleMarkdown() {
if (type() != Type::Content) return;
m_markdownDirty = true;
if (!m_markdownTimer.isActive()) m_markdownTimer.start();
}
bool LlmSegment::parseMarkdown() {
bool LlmTextSegment::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);
QPointer<LlmSegment> guard(this);
const QVector<MarkdownBlockData> parsed = MarkdownParser::parse(text);
QPointer<LlmTextSegment> 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();
if (seg->m_markdownDirty) seg->parseMarkdown();
[guard, parsed]() {
LlmTextSegment* segment = guard;
if (!segment) return;
segment->m_parseInFlight = false;
QList<MarkdownBlock*> blocks;
blocks.reserve(static_cast<int>(parsed.size()));
for (int i = 0; i < static_cast<int>(parsed.size()); ++i) {
const MarkdownBlockData& data = parsed.at(i);
const QString id = QStringLiteral("%1:%2").arg(i).arg(
static_cast<int>(data.type));
blocks.append(new MarkdownBlock(
data.type,
id,
data.text,
data.latex,
data.code,
data.language,
data.level,
segment));
}
segment->m_blocks = blocks;
Q_EMIT segment->markdownChanged();
if (segment->m_markdownDirty) segment->parseMarkdown();
},
Qt::QueuedConnection);
});
return true;
}
LlmToolCall::LlmToolCall(qint64 timestamp, QObject* parent)
: LlmSegment(Type::ToolCall, timestamp, parent) {}
void LlmToolCall::setName(const QString& value) {
if (m_name == value) return;
m_name = value;
Q_EMIT nameChanged();
}
void LlmToolCall::setToolCallId(const QString& value) {
if (m_toolCallId == value) return;
m_toolCallId = value;
Q_EMIT toolCallIdChanged();
}
void LlmToolCall::appendArguments(const QString& piece) {
if (piece.isEmpty()) return;
m_arguments += piece;
Q_EMIT argumentsChanged();
}
void LlmToolCall::setResult(const QString& value) {
if (m_result == value) return;
m_result = value;
Q_EMIT resultChanged();
}
void LlmToolCall::setStatus(Status value) {
if (m_status == value) return;
m_status = value;
Q_EMIT statusChanged();
}
void LlmToolCall::finish(const QString& resultText, bool success) {
setResult(resultText);
setStatus(success ? Status::Success : Status::Error);
close();
}
} // namespace ZShell::llm
+90 -46
View File
@@ -1,9 +1,11 @@
#pragma once
#include "markdownblock.hpp"
#include <QList>
#include <QObject>
#include <QTimer>
#include <QString>
#include <QVariantList>
#include <QtQml>
namespace ZShell::llm {
@@ -15,79 +17,121 @@ class LlmSegment : public QObject {
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)
Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged)
public:
enum class Type : int { Reasoning = 0, ToolCall, Content };
enum class Type : int { Reasoning = 0, Content, ToolCall };
Q_ENUM(Type)
enum class Status : int { None = 0, Running, Success, Error, Pending };
Q_ENUM(Status)
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
~LlmSegment() override;
[[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; }
void begin();
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);
void finishTool(const QString& resultText, bool success);
void restore(qint64 elapsedMs);
Q_SIGNALS:
void runningChanged();
void elapsedMsChanged();
protected:
virtual void closed() {}
qint64 m_startedAt = 0;
qint64 m_endedAt = 0;
private:
Type m_type;
qint64 m_timestamp;
bool m_running = false;
};
class LlmTextSegment : public LlmSegment {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Text segments are managed by ChatGeneration")
Q_PROPERTY(QString text READ text NOTIFY textChanged)
Q_PROPERTY(
QList<ZShell::llm::MarkdownBlock*> markdown READ markdown NOTIFY
markdownChanged)
public:
LlmTextSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
[[nodiscard]] QString text() const { return m_text; }
void setText(const QString& value);
void appendText(const QString& piece);
[[nodiscard]] QList<MarkdownBlock*> markdown() const { return m_blocks; }
Q_SIGNALS:
void textChanged();
void markdownChanged();
void nameChanged();
void toolCallIdChanged();
void argumentsChanged();
void resultChanged();
void statusChanged();
void runningChanged();
void elapsedMsChanged();
private:
void scheduleMarkdown();
bool parseMarkdown();
void closed() override;
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;
QList<MarkdownBlock*> m_blocks;
QTimer m_markdownTimer;
bool m_markdownDirty = false;
bool m_parseInFlight = false;
};
class LlmToolCall : public LlmSegment {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Tool calls are managed by ChatGeneration")
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)
public:
enum class Status : int { None = 0, Running, Success, Error, Pending };
Q_ENUM(Status)
LlmToolCall(qint64 timestamp, QObject* parent = nullptr);
[[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; }
void setName(const QString& value);
void setToolCallId(const QString& value);
void appendArguments(const QString& piece);
void setResult(const QString& value);
void setStatus(Status value);
void finish(const QString& resultText, bool success);
Q_SIGNALS:
void nameChanged();
void toolCallIdChanged();
void argumentsChanged();
void resultChanged();
void statusChanged();
private:
QString m_name;
QString m_toolCallId;
QString m_arguments;
QString m_result;
Status m_status = Status::None;
};
} // namespace ZShell::llm
+78 -202
View File
@@ -2,42 +2,29 @@
#include "attachment.hpp"
#include "chatstore.hpp"
#include "clipboard.hpp"
#include "llmclient.hpp"
#include "prompts.hpp"
#include "workspace.hpp"
#include <QClipboard>
#include <QDebug>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImage>
#include <QMimeDatabase>
#include <QMimeData>
#include <QMimeType>
#include <QStandardPaths>
#include <QUrl>
#include <QtGlobal>
namespace ZShell::llm {
namespace {
constexpr qint64 kMaxImageBytes = 20 * 1024 * 1024;
constexpr qint64 kMaxFileBytes = 100 * 1024 * 1024;
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("…");
}
constexpr int kMaxStoredMessages = 200;
} // namespace
ChatSession::ChatSession(const QString& id, QObject* parent)
: QObject(parent), m_id(id), m_workspace(sandboxDirFor(id)) {
ChatSession::ChatSession(ChatStore* store, const QString& id, QObject* parent)
: QObject(parent), m_store(store), m_id(id) {
m_model = new ChatMessageModel(this, this);
m_workspace = new Workspace(this, this);
connect(
m_model,
&QAbstractListModel::rowsInserted,
@@ -70,18 +57,27 @@ void ChatSession::setIcon(const QString& value) {
if (m_icon == value) return;
m_icon = value;
Q_EMIT iconChanged();
if (m_store) m_store->saveFlags(this);
}
void ChatSession::setUpdatedAt(qint64 value) {
if (m_updatedAt == value) return;
m_updatedAt = value;
Q_EMIT updatedAtChanged();
if (m_store) m_store->reposition(this);
}
void ChatSession::setPinned(bool value) {
if (m_pinned == value) return;
m_pinned = value;
Q_EMIT pinnedChanged();
if (m_store) m_store->saveFlags(this);
}
void ChatSession::setUsageTokens(int value) {
if (m_usageTokens == value) return;
m_usageTokens = value;
Q_EMIT usageTokensChanged();
}
void ChatSession::setCount(int value) {
@@ -90,29 +86,25 @@ void ChatSession::setCount(int value) {
Q_EMIT messageCountChanged();
}
void ChatSession::setMeta(
const QString& title, qint64 createdAt, qint64 updatedAt, int messageCount) {
void ChatSession::hydrate(
const QString& title,
const QString& icon,
qint64 createdAt,
qint64 updatedAt,
int messageCount,
bool pinned) {
m_title = title;
m_icon = icon;
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;
m_pinned = pinned;
}
void ChatSession::ensureLoaded() {
if (m_loadRequested) return;
m_loadRequested = true;
if (auto* store = qobject_cast<ChatStore*>(parent()))
store->loadMessagesInto(this);
if (m_store) m_store->loadMessagesInto(this);
}
ChatMessageModel* ChatSession::messagesModel() {
@@ -126,18 +118,12 @@ void ChatSession::markLoaded() {
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);
if (m_store) m_store->persist(this);
}
ChatMessage* ChatSession::appendNewest(
@@ -154,32 +140,27 @@ void ChatSession::clearMessages() {
}
void ChatSession::startGeneration(ChatMessage* target) {
auto* generation = target->activeGeneration();
auto* clientObject = client();
if (generation && clientObject) {
if (isLoaded()) {
clientObject->startGeneration(this, generation);
} else {
connect(
this,
&ChatSession::loaded,
clientObject,
[this, generation, clientObject]() {
clientObject->startGeneration(this, generation);
});
}
ChatGeneration* generation = target->activeGeneration();
LlmClient* clientObject = client();
if (!generation || !clientObject) return;
if (isLoaded()) {
clientObject->startGeneration(this, generation);
return;
}
connect(
this,
&ChatSession::loaded,
clientObject,
[this, generation, clientObject]() {
clientObject->startGeneration(this, generation);
});
persist();
}
QString ChatSession::sandboxDirFor(const QString& sessionId) {
const QString state =
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation);
return state + QStringLiteral("/zshell/sandboxes/") + sessionId;
}
void ChatSession::ensureWorkspace() {
QDir().mkpath(m_workspace);
LlmClient* ChatSession::client() const {
return m_store ? m_store->client() : nullptr;
}
void ChatSession::setAttachmentError(const QString& value) {
@@ -189,125 +170,34 @@ void ChatSession::setAttachmentError(const QString& value) {
}
QVariantList ChatSession::addAttachments(const QStringList& paths) {
ensureWorkspace();
if (!m_workspace) return {};
setAttachmentError(QString());
QVariantList result;
for (const QString& rawPath : paths) {
const QFileInfo info(rawPath);
if (!info.exists() || !info.isFile()) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": file not found or not a file";
setAttachmentError(
QStringLiteral("File not found: %1").arg(info.fileName()));
continue;
}
const QString mimeType = QMimeDatabase().mimeTypeForFile(info).name();
const bool isImage =
mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive);
const qint64 cap = isImage ? kMaxImageBytes : kMaxFileBytes;
if (info.size() > cap) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": too large (" << info.size() << "bytes, cap" << cap
<< ")";
setAttachmentError(
QStringLiteral("File too large: %1").arg(info.fileName()));
continue;
}
QString destination = m_workspace + QLatin1Char('/') + info.fileName();
int suffix = 1;
while (QFile::exists(destination)) {
const QString extension = info.suffix().isEmpty()
? QString()
: QLatin1Char('.') + info.suffix();
destination = m_workspace + QLatin1Char('/') +
info.completeBaseName() +
QStringLiteral("_%1%2").arg(suffix).arg(extension);
++suffix;
}
if (!QFile::copy(info.absoluteFilePath(), destination)) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": copy to" << destination << "failed";
setAttachmentError(
QStringLiteral("Could not copy file: %1").arg(info.fileName()));
continue;
}
auto* attachment = new Attachment(
info.fileName(),
destination,
isImage ? Attachment::Kind::Image : Attachment::Kind::File,
mimeType,
info.size(),
this);
QString error;
for (Attachment* attachment : m_workspace->importFiles(paths, error))
result.append(QVariant::fromValue(attachment));
}
if (!error.isEmpty()) setAttachmentError(error);
return result;
}
bool ChatSession::clipboardHasAttachment() {
QClipboard* clipboard = QGuiApplication::clipboard();
if (!clipboard) return false;
const QMimeData* data = clipboard->mimeData();
if (!data) return false;
if (data->hasImage()) return true;
for (const QUrl& url : data->urls())
if (url.isLocalFile()) return true;
return false;
return clipboard::hasAttachment();
}
QVariantList ChatSession::pasteAttachments() {
QClipboard* clipboard = QGuiApplication::clipboard();
const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr;
if (!data) {
qWarning() << "ChatSession: pasteAttachments: no clipboard data";
return {};
}
qInfo() << "ChatSession: pasteAttachments: formats =" << data->formats()
<< "hasImage =" << data->hasImage()
<< "hasUrls =" << data->hasUrls() << "hasText =" << data->hasText();
QStringList paths;
QString tempImagePath;
if (data->hasImage()) {
const QImage image = clipboard->image();
qInfo() << "ChatSession: pasteAttachments: image"
<< (image.isNull() ? "null"
: QString::number(image.width()) + "x" +
QString::number(image.height()));
if (image.isNull()) {
setAttachmentError(
QStringLiteral("Clipboard image could not be decoded"));
return {};
}
tempImagePath =
QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png")
.arg(QDateTime::currentMSecsSinceEpoch());
if (!image.save(tempImagePath)) {
qWarning() << "ChatSession: pasteAttachments: saving image to"
<< tempImagePath << "failed";
setAttachmentError(
QStringLiteral("Could not save the pasted image"));
return {};
}
paths.append(tempImagePath);
} else if (data->hasUrls()) {
for (const QUrl& url : data->urls()) {
qInfo() << "ChatSession: pasteAttachments: url =" << url.toString()
<< "local =" << url.isLocalFile();
if (url.isLocalFile()) paths.append(url.toLocalFile());
}
}
QString error;
const QStringList paths = clipboard::attachmentPaths(error);
if (paths.isEmpty()) {
if (data->hasImage() || data->hasUrls())
setAttachmentError(QStringLiteral(
"Nothing usable on the clipboard (expected an image "
"or copied files)"));
if (!error.isEmpty()) setAttachmentError(error);
return {};
}
const QVariantList attachments = addAttachments(paths);
if (!tempImagePath.isEmpty()) QFile::remove(tempImagePath);
qInfo() << "ChatSession: pasteAttachments: result =" << attachments.size()
<< "attachment(s)";
for (const QString& path : paths)
if (path.startsWith(QDir::tempPath())) QFile::remove(path);
return attachments;
}
@@ -324,55 +214,52 @@ void ChatSession::sendMessage(
attachmentList.append(attachment);
const QString trimmed = text.trimmed();
if (trimmed.isEmpty() && attachmentList.isEmpty()) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
if (client() && client()->busy()) return;
if (!m_committed && m_store) m_store->commitDraft(this);
const qint64 now = QDateTime::currentMSecsSinceEpoch();
auto* userMessage = appendNewest(ChatMessage::Role::User, trimmed, now);
ChatMessage* userMessage =
appendNewest(ChatMessage::Role::User, trimmed, now);
if (!attachmentList.isEmpty()) userMessage->setAttachments(attachmentList);
while (m_model->rowCount() > 200)
while (m_model->rowCount() > kMaxStoredMessages)
m_model->removeMessage(m_model->at(m_model->rowCount() - 1));
if (m_title.isEmpty()) {
QString titleSource = trimmed;
if (titleSource.isEmpty() && !attachmentList.isEmpty())
titleSource = attachmentList.first()->name();
m_title = titleFrom(titleSource);
m_title = prompts::cleanTitle(titleSource);
Q_EMIT titleChanged();
qInfo() << "ChatSession:" << m_id << "new conversation,"
<< "fallback title" << m_title
<< "- requesting generated title and icon";
if (auto* clientObject = client()) {
persist();
if (LlmClient* clientObject = client()) {
clientObject->requestTitle(this, titleSource);
clientObject->requestIcon(this, titleSource);
}
}
auto* assistant =
ChatMessage* 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;
}
if (client() && client()->busy()) return;
const int row = m_model->rowOf(target);
if (row < 0) return;
m_model->removeRange(0, row - 1);
if (m_model->rowCount() < 2) return;
const qint64 now = QDateTime::currentMSecsSinceEpoch();
target->appendGeneration(now);
target->appendGeneration(QDateTime::currentMSecsSinceEpoch());
startGeneration(target);
}
void ChatSession::continueFrom(ChatMessage* message) {
if (!message) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
if (client() && client()->busy()) return;
const int row = m_model->rowOf(message);
if (row < 0) return;
@@ -384,26 +271,15 @@ void ChatSession::continueFrom(ChatMessage* message) {
message->appendGeneration(now);
startGeneration(message);
} else {
auto* assistant =
ChatMessage* 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()) {
m_clearPending = true;
return;
}
m_model->clear();
persist();
if (!m_store) return;
m_store->requestClear(this);
}
} // namespace ZShell::llm
+26 -22
View File
@@ -1,7 +1,7 @@
#pragma once
#include "messagemodel.hpp"
#include "message.hpp"
#include "messagemodel.hpp"
#include <QDateTime>
#include <QObject>
@@ -12,30 +12,32 @@
namespace ZShell::llm {
class ChatStore;
class LlmClient;
class Workspace;
class ChatSession : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Chat sessions are managed by Chat.chats")
QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton")
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(QString icon READ icon WRITE setIcon 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(bool pinned READ pinned WRITE setPinned NOTIFY pinnedChanged)
Q_PROPERTY(bool loaded READ isLoaded NOTIFY loaded)
Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged)
Q_PROPERTY(int usageTokens READ usageTokens NOTIFY usageTokensChanged)
Q_PROPERTY(
ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT)
Q_PROPERTY(QString workspace READ workspacePath CONSTANT)
Q_PROPERTY(
QString attachmentError READ attachmentError NOTIFY
attachmentErrorChanged)
public:
explicit ChatSession(const QString& id, QObject* parent = nullptr);
ChatSession(ChatStore* store, const QString& id, QObject* parent = nullptr);
[[nodiscard]] QString id() const { return m_id; }
[[nodiscard]] QString title() const { return m_title; }
@@ -50,34 +52,33 @@ class ChatSession : public QObject {
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
[[nodiscard]] bool pinned() const { return m_pinned; }
[[nodiscard]] int messageCount() const { return m_messageCount; }
[[nodiscard]] int usageTokens() const { return m_usageTokens; }
[[nodiscard]] ChatMessageModel* messagesModel();
void ensureLoaded();
[[nodiscard]] bool isLoaded() const { return m_loaded; }
[[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);
[[nodiscard]] QString workspacePath() const { return m_workspace; }
[[nodiscard]] QString attachmentError() const { return m_attachmentError; }
void setAttachmentError(const QString& value);
static QString sandboxDirFor(const QString& sessionId);
void ensureWorkspace();
[[nodiscard]] ChatStore* store() const { return m_store; }
[[nodiscard]] LlmClient* client() const;
[[nodiscard]] Workspace* workspace() const { return m_workspace; }
void setTitle(const QString& value);
void setIcon(const QString& value);
void setUpdatedAt(qint64 value);
void setPinned(bool value);
void setUsageTokens(int value);
void setMeta(
void hydrate(
const QString& title,
const QString& icon,
qint64 createdAt,
qint64 updatedAt,
int messageCount);
int messageCount,
bool pinned);
[[nodiscard]] bool isCommitted() const { return m_committed; }
void setCommitted(bool value) { m_committed = value; }
void adoptMessages(QList<ChatMessage*> messages);
void persist();
@@ -101,6 +102,7 @@ class ChatSession : public QObject {
void pinnedChanged();
void messageCountChanged();
void loaded();
void usageTokensChanged();
void attachmentErrorChanged();
private:
@@ -109,20 +111,22 @@ class ChatSession : public QObject {
ChatMessage::Role role, const QString& content, qint64 timestamp);
void startGeneration(ChatMessage* target);
void setCount(int value);
void setAttachmentError(const QString& value);
ChatStore* m_store;
QString m_id;
bool m_committed = false;
QString m_title;
QString m_icon;
qint64 m_createdAt = 0;
qint64 m_updatedAt = 0;
bool m_pinned = false;
int m_messageCount = 0;
int m_usageTokens = 0;
ChatMessageModel* m_model = nullptr;
Workspace* m_workspace = nullptr;
bool m_loadRequested = false;
bool m_loaded = false;
bool m_clearPending = false;
int m_lastTokenCount = 0;
QString m_workspace;
QString m_attachmentError;
};
+23 -46
View File
@@ -1,8 +1,5 @@
#include "shelltool.hpp"
#include "llmclient.hpp"
#include "session.hpp"
#include <QDir>
#include <QJsonArray>
#include <QJsonObject>
@@ -13,24 +10,8 @@
namespace ZShell::llm {
namespace {
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
ShellTool::ShellTool(LlmClient* client, QObject* parent)
: LlmTool(parent), m_client(client) {}
ShellTool::ShellTool(ToolRegistry* registry, QObject* parent)
: LlmTool(registry, parent) {}
ShellTool::~ShellTool() {
for (auto* job : m_jobs) {
@@ -125,27 +106,29 @@ QStringList ShellTool::sandboxArguments(const QString& workspace) {
return args;
}
bool ShellTool::available(const ToolContext& context) const {
return registry()->sandboxEnabled() && !context.workspace.isEmpty();
}
void ShellTool::execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
Q_UNUSED(toolCallId);
const ToolContext& context, const QJsonObject& args, ToolDone done) {
const QString command = args[QStringLiteral("command")].toString();
if (command.trimmed().isEmpty()) {
done(makeError(QStringLiteral("Missing required argument 'command'")));
done({false, QStringLiteral("Missing required argument 'command'")});
return;
}
LlmClient* client = m_client;
if (!client || !client->sandboxEnabled()) {
done(makeError(QStringLiteral(
"The file sandbox is disabled, so no commands can run")));
if (!registry()->sandboxEnabled()) {
done(
{false,
QStringLiteral(
"The file sandbox is disabled, so no commands can run")});
return;
}
const QString workspace = client->activeWorkspace();
const QString workspace = context.workspace;
if (workspace.isEmpty()) {
done(makeError(
QStringLiteral("No sandbox available: no chat session is active")));
done(
{false,
QStringLiteral("No sandbox available: no chat session is active")});
return;
}
QDir().mkpath(workspace);
@@ -156,7 +139,7 @@ void ShellTool::execute(
MaxTimeoutSeconds);
auto* job = new Job;
job->timeoutSeconds = timeoutSeconds;
job->outputBudgetChars = outputBudgetChars;
job->outputBudgetChars = context.outputBudgetChars;
job->done = std::move(done);
m_jobs.append(job);
@@ -230,9 +213,8 @@ void ShellTool::finishJob(Job* job) {
"Command timed out after %1 seconds and was killed.\n")
.arg(job->timeoutSeconds);
int budget = inlineBudgetChars();
if (job->outputBudgetChars > 0)
budget = qMin(budget, job->outputBudgetChars);
int budget = job->outputBudgetChars > 0 ? job->outputBudgetChars
: registry()->outputBudgetChars(1);
if (output.size() > budget)
output = output.left(budget) + QStringLiteral(
"\n[output truncated: %1 characters "
@@ -242,17 +224,12 @@ void ShellTool::finishJob(Job* job) {
const QString result =
note + QStringLiteral("exit code: %1\n").arg(exitCode) +
(output.isEmpty() ? QStringLiteral("(no output)") : output);
const bool ok = !job->cancelled && !job->timedOut && exitCode == 0;
QJsonObject resultObject;
if (!job->cancelled && !job->timedOut && exitCode == 0)
resultObject = makeOutput(result);
else
resultObject = makeError(result);
std::function<void(const QJsonObject&)> done = std::move(job->done);
ToolDone done = std::move(job->done);
if (process) process->deleteLater();
delete job;
if (done) done(resultObject);
if (done) done({ok, result});
}
} // namespace ZShell::llm
+5 -8
View File
@@ -11,23 +11,21 @@ class QTimer;
namespace ZShell::llm {
class LlmClient;
class ShellTool : public LlmTool {
Q_OBJECT
public:
explicit ShellTool(LlmClient* client, QObject* parent = nullptr);
explicit ShellTool(ToolRegistry* registry, QObject* parent = nullptr);
~ShellTool() override;
QString name() const override;
QString description() const override;
QJsonObject parameters() const override;
bool available(const ToolContext& context) const override;
void execute(
const QString& toolCallId,
const ToolContext& context,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) override;
ToolDone done) override;
void cancel() override;
static constexpr int DefaultTimeoutSeconds = 60;
@@ -39,7 +37,7 @@ class ShellTool : public LlmTool {
QPointer<QTimer> timer;
int timeoutSeconds = DefaultTimeoutSeconds;
int outputBudgetChars = 0;
std::function<void(const QJsonObject&)> done;
ToolDone done;
QString startError;
bool timedOut = false;
bool cancelled = false;
@@ -50,7 +48,6 @@ class ShellTool : public LlmTool {
void finishJob(Job* job);
QPointer<LlmClient> m_client;
QList<Job*> m_jobs;
};
+29 -26
View File
@@ -2,20 +2,14 @@
namespace ZShell::llm {
LlmTool::LlmTool(QObject* parent) : QObject(parent) {}
LlmTool::LlmTool(ToolRegistry* registry, QObject* parent)
: QObject(parent), m_registry(registry) {}
LlmTool::~LlmTool() = default;
void LlmTool::setContextSize(int value) {
if (m_contextSize == value) return;
m_contextSize = value;
}
int LlmTool::inlineBudgetChars() const {
if (m_contextSize <= 0) return DefaultInlineChars;
const int tokens =
qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken);
return tokens * CharsPerToken;
bool LlmTool::available(const ToolContext& context) const {
Q_UNUSED(context);
return true;
}
void LlmTool::cancel() {}
@@ -33,30 +27,22 @@ QJsonObject LlmTool::specification() const {
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);
tool->setContextSize(m_contextSize);
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);
for (auto* tool : m_tools)
if (tool->name() == name) return tool;
return nullptr;
}
QJsonArray ToolRegistry::specifications() const {
if (!m_enabled) return {};
QJsonArray ToolRegistry::specifications(const ToolContext& context) const {
QJsonArray specs;
for (const auto* tool : m_tools)
specs.append(tool->specification());
for (auto* tool : m_tools)
if (tool->available(context)) specs.append(tool->specification());
return specs;
}
@@ -68,8 +54,25 @@ void ToolRegistry::cancelAll() {
void ToolRegistry::setContextSize(int value) {
if (m_contextSize == value) return;
m_contextSize = value;
for (auto* tool : m_tools)
tool->setContextSize(value);
}
void ToolRegistry::setEnabled(bool value) {
if (m_enabled == value) return;
m_enabled = value;
}
void ToolRegistry::setSandboxEnabled(bool value) {
if (m_sandbox == value) return;
m_sandbox = value;
}
int ToolRegistry::outputBudgetChars(int callCount) const {
if (m_contextSize <= 0) return DefaultInlineChars;
const int tokens = qBound(
MinBudgetTokens,
m_contextSize / qMax(1, callCount),
DefaultInlineChars / CharsPerToken);
return tokens * CharsPerToken;
}
} // namespace ZShell::llm
+37 -22
View File
@@ -10,61 +10,76 @@
namespace ZShell::llm {
struct ToolResult {
bool ok = false;
QString text;
};
struct ToolContext {
QString toolCallId;
QString workspace;
int outputBudgetChars = 0;
};
using ToolDone = std::function<void(const ToolResult&)>;
class ToolRegistry;
class LlmTool : public QObject {
Q_OBJECT
public:
explicit LlmTool(QObject* parent = nullptr);
explicit LlmTool(ToolRegistry* registry, QObject* parent = nullptr);
~LlmTool() override;
[[nodiscard]] virtual QString name() const = 0;
[[nodiscard]] virtual QString description() const = 0;
[[nodiscard]] virtual QJsonObject parameters() const = 0;
[[nodiscard]] virtual bool available(const ToolContext& context) const;
virtual void execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) = 0;
const ToolContext& context, const QJsonObject& args, ToolDone done) = 0;
virtual void cancel();
[[nodiscard]] QJsonObject specification() const;
[[nodiscard]] int contextSize() const { return m_contextSize; }
void setContextSize(int value);
[[nodiscard]] ToolRegistry* registry() const { return m_registry; }
[[nodiscard]] int inlineBudgetChars() const;
static constexpr int CharsPerToken = 4;
static constexpr int DefaultInlineChars = 64 * 1024;
protected:
int m_contextSize = 0;
private:
ToolRegistry* m_registry;
};
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);
void registerTool(LlmTool* tool);
[[nodiscard]] LlmTool* tool(const QString& name) const;
[[nodiscard]] QJsonArray specifications() const;
[[nodiscard]] QList<LlmTool*> tools() const { return m_tools; }
[[nodiscard]] QJsonArray specifications(const ToolContext& context) const;
void cancelAll();
[[nodiscard]] bool enabled() const { return m_enabled; }
void setEnabled(bool value);
[[nodiscard]] bool sandboxEnabled() const { return m_sandbox; }
void setSandboxEnabled(bool value);
[[nodiscard]] int contextSize() const { return m_contextSize; }
void setContextSize(int value);
Q_SIGNALS:
void enabledChanged();
[[nodiscard]] int outputBudgetChars(int callCount) const;
static constexpr int CharsPerToken = 4;
static constexpr int DefaultInlineChars = 64 * 1024;
static constexpr int MinBudgetTokens = 512;
private:
bool m_enabled = true;
bool m_sandbox = true;
int m_contextSize = 0;
QList<LlmTool*> m_tools;
};

Some files were not shown because too many files have changed in this diff Show More