Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3178ad9cd | ||
|
|
82ca5b5335 | ||
|
|
02254a10d8 | ||
|
|
54d4676ddf | ||
|
|
bd699e72a7 | ||
|
|
cd3da1941b | ||
|
|
34dde4bbfb | ||
|
|
fcb848b6d2 | ||
|
|
ed7ec326b1 | ||
|
|
ae5c1fa148 | ||
|
|
b254000737 | ||
|
|
04fe8b1e43 | ||
|
|
dfa6b010e6 | ||
|
|
8a4e9bac31 | ||
|
|
f20fa45b29 | ||
|
|
53a3c76dc0 | ||
|
|
617e87d64f | ||
|
|
ef2fb95fab | ||
|
|
2150859847 | ||
|
|
04869656aa | ||
|
|
2f6847ce0e | ||
|
|
e5e52802de | ||
|
|
42f02f4dd2 | ||
|
|
9df3a64815 | ||
|
|
9370284c6b | ||
|
|
5598f4f435 | ||
|
|
7e02c87b91 |
@@ -18,3 +18,5 @@ dist/
|
|||||||
network-dev/
|
network-dev/
|
||||||
**/zshell.build/
|
**/zshell.build/
|
||||||
**/zshell.dist/
|
**/zshell.dist/
|
||||||
|
.opencode/
|
||||||
|
run-agent.sh
|
||||||
|
|||||||
+20
-20
@@ -26,22 +26,36 @@ if(NOT DEFINED VERSION)
|
|||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
if(NOT DEFINED GIT_REVISION)
|
||||||
|
execute_process(COMMAND git rev-parse HEAD
|
||||||
|
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
|
||||||
|
OUTPUT_VARIABLE GIT_REVISION
|
||||||
|
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||||
|
)
|
||||||
|
|
||||||
|
if("${GIT_REVISION}" STREQUAL "")
|
||||||
|
message(FATAL_ERROR "GIT_REVISION is not set and failed to get from git")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
set(VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
|
set(VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
|
||||||
|
|
||||||
project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH})
|
project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH})
|
||||||
message(STATUS "ZShell version: ${VERSION}")
|
message(STATUS "ZShell version: ${VERSION}")
|
||||||
|
|
||||||
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
|
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
|
||||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
set(ENABLE_MODULES "plugin;shell;m3shapes" CACHE STRING "Modules to build/install")
|
set(ENABLE_MODULES "plugin;shell;cli;m3shapes" CACHE STRING "Modules to build/install")
|
||||||
|
|
||||||
set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir")
|
set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/ZShell" CACHE STRING "Library install dir")
|
||||||
set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir")
|
set(INSTALL_QMLDIR "${CMAKE_INSTALL_LIBDIR}/qt6/qml" CACHE STRING "QML install dir")
|
||||||
set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir")
|
set(INSTALL_QSCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir")
|
||||||
set(INSTALL_GREETERCONFDIR "etc/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir")
|
set(INSTALL_GREETERCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir")
|
||||||
|
|
||||||
set(CMAKE_INSTALL_MESSAGE NEVER)
|
set(CMAKE_INSTALL_MESSAGE NEVER)
|
||||||
|
|
||||||
@@ -54,21 +68,7 @@ add_compile_options(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if("shell" IN_LIST ENABLE_MODULES)
|
if("cli" IN_LIST ENABLE_MODULES)
|
||||||
# Build settings index
|
|
||||||
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
|
||||||
set(SETTINGS_INDEX_JSON "${CMAKE_BINARY_DIR}/settings-index.json")
|
|
||||||
execute_process(
|
|
||||||
COMMAND ${Python3_EXECUTABLE}
|
|
||||||
"${CMAKE_SOURCE_DIR}/scripts/build-settings-index.py"
|
|
||||||
"${CMAKE_SOURCE_DIR}/Modules/Settings"
|
|
||||||
"${SETTINGS_INDEX_JSON}"
|
|
||||||
RESULT_VARIABLE SETTINGS_INDEX_RESULT
|
|
||||||
)
|
|
||||||
if(NOT SETTINGS_INDEX_RESULT EQUAL 0)
|
|
||||||
message(FATAL_ERROR "Failed to build settings search index")
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# Nuitka compilation
|
# Nuitka compilation
|
||||||
set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli")
|
set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli")
|
||||||
set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist")
|
set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist")
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Rectangle {
|
|||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ ScrollBar {
|
|||||||
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
|
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
|
||||||
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
|
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
|
||||||
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
|
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
|
||||||
readonly property real axisContentPos: isHorizontal ? (flickable.contentX - flickable.originX) : (flickable.contentY - flickable.originY)
|
readonly property real axisContentPos: isHorizontal ? (reversed ? flickable.contentX : (flickable.contentX - flickable.originX)) : (reversed ? flickable.contentY : (flickable.contentY - flickable.originY))
|
||||||
readonly property real axisLength: isHorizontal ? root.width : root.height
|
readonly property real axisLength: isHorizontal ? root.width : root.height
|
||||||
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
|
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
|
||||||
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
|
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
|
||||||
@@ -102,9 +102,9 @@ ScrollBar {
|
|||||||
|
|
||||||
var newPos = contentPosFromThumbStart(thumbStart);
|
var newPos = contentPosFromThumbStart(thumbStart);
|
||||||
if (root.isHorizontal)
|
if (root.isHorizontal)
|
||||||
root.flickable.contentX = newPos + root.flickable.originX;
|
root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0));
|
||||||
else
|
else
|
||||||
root.flickable.contentY = newPos + root.flickable.originY;
|
root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function visualThumbStart() {
|
function visualThumbStart() {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ TextEdit {
|
|||||||
color: Colors.palette.m3onSurface
|
color: Colors.palette.m3onSurface
|
||||||
cursorVisible: !readOnly
|
cursorVisible: !readOnly
|
||||||
font.pointSize: Tokens.font.size.small
|
font.pointSize: Tokens.font.size.small
|
||||||
|
font.family: Config.appearance.font.family.sans
|
||||||
renderType: TextField.NativeRendering
|
renderType: TextField.NativeRendering
|
||||||
selectedTextColor: color
|
selectedTextColor: color
|
||||||
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
|
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Item {
|
|||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
|
|
||||||
sourceComponent: BatteryIcon {
|
sourceComponent: BatteryIcon {
|
||||||
devState: Battery.deviceStateString
|
devState: Battery.deviceStateString.toLowerCase()
|
||||||
percentage: Battery.currentPerc
|
percentage: Battery.currentPerc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ CustomListView {
|
|||||||
return [0];
|
return [0];
|
||||||
case "variant":
|
case "variant":
|
||||||
return SchemeVariants.query(text);
|
return SchemeVariants.query(text);
|
||||||
|
case "files":
|
||||||
|
return Files.query(text);
|
||||||
default:
|
default:
|
||||||
return Apps.search(text);
|
return Apps.search(text);
|
||||||
}
|
}
|
||||||
@@ -33,7 +35,7 @@ CustomListView {
|
|||||||
function stateForText(text: string): string {
|
function stateForText(text: string): string {
|
||||||
const prefix = Config.launcher.actionPrefix;
|
const prefix = Config.launcher.actionPrefix;
|
||||||
if (text.startsWith(prefix)) {
|
if (text.startsWith(prefix)) {
|
||||||
for (const action of ["calc", "scheme", "variant"])
|
for (const action of ["calc", "scheme", "variant", "files"])
|
||||||
if (text.startsWith(`${prefix}${action} `))
|
if (text.startsWith(`${prefix}${action} `))
|
||||||
return action;
|
return action;
|
||||||
|
|
||||||
@@ -163,6 +165,13 @@ CustomListView {
|
|||||||
PropertyChanges {
|
PropertyChanges {
|
||||||
root.delegate: variantItem
|
root.delegate: variantItem
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
State {
|
||||||
|
name: "files"
|
||||||
|
|
||||||
|
PropertyChanges {
|
||||||
|
root.delegate: filesItem
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
transitions: Transition {
|
transitions: Transition {
|
||||||
@@ -264,6 +273,14 @@ CustomListView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: filesItem
|
||||||
|
|
||||||
|
FilesItem {
|
||||||
|
list: root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
function onTextChanged() {
|
function onTextChanged() {
|
||||||
root.syncDisplayText();
|
root.syncDisplayText();
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ Item {
|
|||||||
} else if (text.startsWith(Config.launcher.actionPrefix)) {
|
} else if (text.startsWith(Config.launcher.actionPrefix)) {
|
||||||
if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
|
if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
|
||||||
currentItem.onClicked();
|
currentItem.onClicked();
|
||||||
|
else if (text.startsWith(`${Config.launcher.actionPrefix}files `))
|
||||||
|
currentItem.onClicked();
|
||||||
else
|
else
|
||||||
currentItem.modelData.onClicked(list.currentList);
|
currentItem.modelData.onClicked(list.currentList);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.modelData?.onClicked(root.list);
|
root.modelData?.onClicked(root.list);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
Apps.launch(root.modelData);
|
Apps.launch(root.modelData);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.onClicked();
|
root.onClicked();
|
||||||
@@ -97,8 +97,7 @@ Item {
|
|||||||
text: qsTr("Open in calculator")
|
text: qsTr("Open in calculator")
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Widgets
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Components
|
||||||
|
import qs.Helpers
|
||||||
|
import qs.Modules.Launcher.Services
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var list
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
anchors.left: parent?.left
|
||||||
|
anchors.right: parent?.right
|
||||||
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
|
function onClicked(): void {
|
||||||
|
root.list.visibilities.launcher = false;
|
||||||
|
if (root.modelData?.path)
|
||||||
|
Files.launch(["xdg-open", root.modelData.path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateLayer {
|
||||||
|
id: sLayer
|
||||||
|
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
|
manualHoverOverride: openLocation.hovered
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
root.onClicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: openLocation
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Tokens.padding.medium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
icon: "open_in_new"
|
||||||
|
isRound: true
|
||||||
|
|
||||||
|
scale: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0.4
|
||||||
|
opacity: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on scale {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
root.list.visibilities.launcher = false;
|
||||||
|
if (root.modelData?.path)
|
||||||
|
Files.showInFolder(root.modelData.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: Tokens.padding.small
|
||||||
|
anchors.margins: Tokens.padding.small
|
||||||
|
anchors.rightMargin: Tokens.padding.small
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
id: icon
|
||||||
|
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitSize: Tokens.font.size.extraLarge
|
||||||
|
source: Quickshell.iconPath(root.modelData?.icon) ?? ""
|
||||||
|
visible: !root.modelData?.isImage
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
active: opacity > 0
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
asynchronous: true
|
||||||
|
opacity: root.modelData?.isImage && image.status === Image.Loading ? 1 : 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceComponent: LoadingIndicator {
|
||||||
|
color: Colors.palette.m3primaryContainer
|
||||||
|
implicitSize: Tokens.font.size.extraLarge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FadeImage {
|
||||||
|
id: image
|
||||||
|
|
||||||
|
property int implicitSize: Tokens.font.size.extraLarge
|
||||||
|
|
||||||
|
width: implicitSize
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
source: root.modelData?.isImage ? root.modelData?.path ?? "" : ""
|
||||||
|
visible: root.modelData?.isImage
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.left: icon.right
|
||||||
|
anchors.leftMargin: Tokens.spacing.medium
|
||||||
|
anchors.verticalCenter: icon.verticalCenter
|
||||||
|
implicitHeight: name.implicitHeight + desc.implicitHeight
|
||||||
|
implicitWidth: parent.width - icon.width
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: name
|
||||||
|
|
||||||
|
font.pointSize: Tokens.font.size.normal
|
||||||
|
text: root.modelData?.name ?? ""
|
||||||
|
elide: Text.ElideRight
|
||||||
|
width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: desc
|
||||||
|
|
||||||
|
anchors.top: name.bottom
|
||||||
|
color: Colors.palette.m3outline
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.pointSize: Tokens.font.size.small
|
||||||
|
text: root.modelData?.path ?? ""
|
||||||
|
width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.modelData?.onClicked(root.list);
|
root.modelData?.onClicked(root.list);
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// qs/Services/Files.qml
|
||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import ZShell.Config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property list<var> results: []
|
||||||
|
|
||||||
|
property string _pendingQuery: ""
|
||||||
|
property int _generation: 0
|
||||||
|
|
||||||
|
readonly property var _imageExts: ({
|
||||||
|
"png": true,
|
||||||
|
"jpg": true,
|
||||||
|
"jpeg": true,
|
||||||
|
"gif": true,
|
||||||
|
"bmp": true,
|
||||||
|
"webp": true,
|
||||||
|
"svg": true,
|
||||||
|
"tiff": true,
|
||||||
|
"tif": true,
|
||||||
|
"ico": true,
|
||||||
|
"heic": true,
|
||||||
|
"avif": true
|
||||||
|
})
|
||||||
|
|
||||||
|
// freedesktop mimetype-icon names; extend as needed
|
||||||
|
readonly property var _extIcons: ({
|
||||||
|
// images
|
||||||
|
"png": "image-x-generic",
|
||||||
|
"jpg": "image-x-generic",
|
||||||
|
"jpeg": "image-x-generic",
|
||||||
|
"gif": "image-x-generic",
|
||||||
|
"bmp": "image-x-generic",
|
||||||
|
"webp": "image-x-generic",
|
||||||
|
"svg": "image-x-generic",
|
||||||
|
"tiff": "image-x-generic",
|
||||||
|
"tif": "image-x-generic",
|
||||||
|
"ico": "image-x-generic",
|
||||||
|
"heic": "image-x-generic",
|
||||||
|
"avif": "image-x-generic",
|
||||||
|
// documents
|
||||||
|
"pdf": "application-pdf",
|
||||||
|
"doc": "x-office-document",
|
||||||
|
"docx": "x-office-document",
|
||||||
|
"odt": "x-office-document",
|
||||||
|
"txt": "text-x-generic",
|
||||||
|
"md": "text-x-generic",
|
||||||
|
"xls": "x-office-spreadsheet",
|
||||||
|
"xlsx": "x-office-spreadsheet",
|
||||||
|
"ods": "x-office-spreadsheet",
|
||||||
|
"ppt": "x-office-presentation",
|
||||||
|
"pptx": "x-office-presentation",
|
||||||
|
"odp": "x-office-presentation",
|
||||||
|
// archives
|
||||||
|
"zip": "package-x-generic",
|
||||||
|
"tar": "package-x-generic",
|
||||||
|
"gz": "package-x-generic",
|
||||||
|
"xz": "package-x-generic",
|
||||||
|
"7z": "package-x-generic",
|
||||||
|
"rar": "package-x-generic",
|
||||||
|
// audio/video
|
||||||
|
"mp3": "audio-x-generic",
|
||||||
|
"flac": "audio-x-generic",
|
||||||
|
"wav": "audio-x-generic",
|
||||||
|
"ogg": "audio-x-generic",
|
||||||
|
"mp4": "video-x-generic",
|
||||||
|
"mkv": "video-x-generic",
|
||||||
|
"webm": "video-x-generic",
|
||||||
|
"avi": "video-x-generic",
|
||||||
|
"mov": "video-x-generic",
|
||||||
|
// code
|
||||||
|
"js": "text-x-script",
|
||||||
|
"ts": "text-x-script",
|
||||||
|
"py": "text-x-script",
|
||||||
|
"sh": "text-x-script",
|
||||||
|
"qml": "text-x-script",
|
||||||
|
"cpp": "text-x-c++src",
|
||||||
|
"c": "text-x-csrc",
|
||||||
|
"h": "text-x-chdr",
|
||||||
|
"json": "application-json",
|
||||||
|
"html": "text-html",
|
||||||
|
"css": "text-css"
|
||||||
|
})
|
||||||
|
|
||||||
|
function showInFolder(path: string): void {
|
||||||
|
const explorer = Config.general.apps.explorer;
|
||||||
|
let args = [];
|
||||||
|
|
||||||
|
if (explorer.includes("dolphin"))
|
||||||
|
args = [...explorer, "--select", path];
|
||||||
|
else
|
||||||
|
args = [...explorer, path];
|
||||||
|
|
||||||
|
if (Config.launcher.uwsm)
|
||||||
|
Quickshell.execDetached(["app2unit", "--", ...args]);
|
||||||
|
else
|
||||||
|
Quickshell.execDetached([...args]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(command: list<string>): void {
|
||||||
|
if (Config.launcher.uwsm)
|
||||||
|
Quickshell.execDetached(["app2unit", "--"].concat(command));
|
||||||
|
else
|
||||||
|
Quickshell.execDetached(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformSearch(search: string): string {
|
||||||
|
return search.slice(Config.launcher.actionPrefix.length + "files ".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(search: string): list<var> {
|
||||||
|
search = transformSearch(search);
|
||||||
|
if (search !== _pendingQuery) {
|
||||||
|
_pendingQuery = search;
|
||||||
|
debounceTimer.restart();
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extOf(name: string): string {
|
||||||
|
const i = name.lastIndexOf(".");
|
||||||
|
// no dot, or dotfile with no real extension (".bashrc" -> treat as no ext)
|
||||||
|
if (i <= 0)
|
||||||
|
return "";
|
||||||
|
return name.slice(i + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _iconFor(name: string, isDir: bool): string {
|
||||||
|
if (isDir)
|
||||||
|
return "folder";
|
||||||
|
const ext = _extOf(name);
|
||||||
|
return _extIcons[ext] ?? "text-x-generic";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildEntry(path: string): var {
|
||||||
|
const name = path.split("/").pop();
|
||||||
|
const ext = _extOf(name);
|
||||||
|
// trailing slash from plocate's directory output (if -d/dir results ever appear)
|
||||||
|
const isDir = path.endsWith("/");
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: path,
|
||||||
|
name: name,
|
||||||
|
icon: _iconFor(name, isDir),
|
||||||
|
isImage: !isDir && (_imageExts[ext] ?? false)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: debounceTimer
|
||||||
|
interval: 120
|
||||||
|
onTriggered: root._runQuery(root._pendingQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
function _runQuery(search: string): void {
|
||||||
|
_generation++;
|
||||||
|
|
||||||
|
if (!search) {
|
||||||
|
proc.running = false;
|
||||||
|
results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proc.running)
|
||||||
|
proc.running = false;
|
||||||
|
|
||||||
|
proc.buffer = [];
|
||||||
|
proc.myGeneration = _generation;
|
||||||
|
proc.command = ["plocate", "-i", "-b", "--limit", "50", search];
|
||||||
|
proc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: proc
|
||||||
|
|
||||||
|
property var buffer: []
|
||||||
|
property int myGeneration: 0
|
||||||
|
|
||||||
|
stdout: SplitParser {
|
||||||
|
onRead: line => proc.buffer.push(root._buildEntry(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
onRunningChanged: {
|
||||||
|
if (!running && myGeneration === root._generation)
|
||||||
|
root.results = buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ ColumnLayout {
|
|||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
color: Colors.palette.m3secondary
|
color: Colors.palette.m3secondary
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.family: Appearance.font.family.clock
|
font.family: Config.appearance.font.family.clock
|
||||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
||||||
text: Time.hourStr
|
text: Time.hourStr
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ ColumnLayout {
|
|||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
color: Colors.palette.m3primary
|
color: Colors.palette.m3primary
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.family: Appearance.font.family.clock
|
font.family: Config.appearance.font.family.clock
|
||||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
||||||
text: ":"
|
text: ":"
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ ColumnLayout {
|
|||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
color: Colors.palette.m3secondary
|
color: Colors.palette.m3secondary
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.family: Appearance.font.family.clock
|
font.family: Config.appearance.font.family.clock
|
||||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
||||||
text: Time.minuteStr
|
text: Time.minuteStr
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ ColumnLayout {
|
|||||||
Layout.topMargin: -Tokens.padding.large * 2
|
Layout.topMargin: -Tokens.padding.large * 2
|
||||||
color: Colors.palette.m3tertiary
|
color: Colors.palette.m3tertiary
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.family: Appearance.font.family.mono
|
font.family: Config.appearance.font.family.mono
|
||||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale)
|
font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale)
|
||||||
text: Time.format("dddd, d MMMM yyyy")
|
text: Time.format("dddd, d MMMM yyyy")
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ ColumnLayout {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
animateProp: "opacity"
|
animateProp: "opacity"
|
||||||
color: Colors.palette.m3onSurfaceVariant
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
font.family: Appearance.font.family.mono
|
font.family: Config.appearance.font.family.mono
|
||||||
horizontalAlignment: Qt.AlignHCenter
|
horizontalAlignment: Qt.AlignHCenter
|
||||||
lineHeight: 1.2
|
lineHeight: 1.2
|
||||||
opacity: shouldBeVisible && !message.msg ? 1 : 0
|
opacity: shouldBeVisible && !message.msg ? 1 : 0
|
||||||
@@ -295,7 +295,7 @@ ColumnLayout {
|
|||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
color: Colors.palette.m3error
|
color: Colors.palette.m3error
|
||||||
font.family: Appearance.font.family.mono
|
font.family: Config.appearance.font.family.mono
|
||||||
font.pointSize: Tokens.font.size.small
|
font.pointSize: Tokens.font.size.small
|
||||||
horizontalAlignment: Qt.AlignHCenter
|
horizontalAlignment: Qt.AlignHCenter
|
||||||
opacity: 0
|
opacity: 0
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound
|
|||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
|
import QtQuick
|
||||||
import ZShell.Internal
|
import ZShell.Internal
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
import qs.Helpers
|
import qs.Helpers
|
||||||
@@ -30,12 +31,12 @@ Scope {
|
|||||||
Quickshell.execDetached(action);
|
Quickshell.execDetached(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
LidWatcher {
|
LidWatcher {
|
||||||
onAboutToSleep: root.lock.lock.locked = true
|
onAboutToSleep: root.lock.lock.locked = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: Config.general.idle.timeouts
|
model: Config.general.idle.timeouts.values
|
||||||
|
|
||||||
IdleMonitor {
|
IdleMonitor {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Item {
|
|||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
animate: true
|
animate: true
|
||||||
color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline
|
color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline
|
||||||
font.family: Appearance.font.family.mono
|
font.family: Config.appearance.font.family.mono
|
||||||
font.pointSize: Tokens.font.size.normal
|
font.pointSize: Tokens.font.size.normal
|
||||||
opacity: root.buffer ? 0 : 1
|
opacity: root.buffer ? 0 : 1
|
||||||
text: {
|
text: {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ ColumnLayout {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
color: Colors.palette.m3outline
|
color: Colors.palette.m3outline
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.family: Appearance.font.family.mono
|
font.family: Config.appearance.font.family.mono
|
||||||
font.weight: 500
|
font.weight: 500
|
||||||
text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications")
|
text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications")
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ ColumnLayout {
|
|||||||
CustomText {
|
CustomText {
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
color: Colors.palette.m3outlineVariant
|
color: Colors.palette.m3outlineVariant
|
||||||
font.family: Appearance.font.family.mono
|
font.family: Config.appearance.font.family.mono
|
||||||
font.pointSize: Tokens.font.size.large
|
font.pointSize: Tokens.font.size.large
|
||||||
font.weight: 500
|
font.weight: 500
|
||||||
text: qsTr("No Notifications")
|
text: qsTr("No Notifications")
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ Item {
|
|||||||
}
|
}
|
||||||
delegate: MessageDelegate {
|
delegate: MessageDelegate {
|
||||||
rotation: 180
|
rotation: 180
|
||||||
|
rootParent: list
|
||||||
}
|
}
|
||||||
|
|
||||||
displaced: Transition {
|
displaced: Transition {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ CustomClippingRect {
|
|||||||
|
|
||||||
property bool expanded: false
|
property bool expanded: false
|
||||||
property bool highlighted: false
|
property bool highlighted: false
|
||||||
|
property bool slim: false
|
||||||
required property ChatSession modelData
|
required property ChatSession modelData
|
||||||
|
|
||||||
signal clicked(content: ChatSession)
|
signal clicked(content: ChatSession)
|
||||||
@@ -20,6 +21,9 @@ CustomClippingRect {
|
|||||||
implicitHeight: {
|
implicitHeight: {
|
||||||
let h = 0;
|
let h = 0;
|
||||||
|
|
||||||
|
if (slim)
|
||||||
|
return chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
|
||||||
|
|
||||||
h += infoContainer.implicitHeight;
|
h += infoContainer.implicitHeight;
|
||||||
|
|
||||||
if (expanded)
|
if (expanded)
|
||||||
@@ -85,6 +89,14 @@ CustomClippingRect {
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
opacity: root.slim ? 0 : 1
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CustomRect {
|
CustomRect {
|
||||||
id: title
|
id: title
|
||||||
|
|
||||||
@@ -246,7 +258,7 @@ CustomClippingRect {
|
|||||||
color: Colors.palette.m3onSurfaceVariant
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
font.pointSize: Tokens.font.size.small
|
font.pointSize: Tokens.font.size.small
|
||||||
maximumLineCount: 1
|
maximumLineCount: 1
|
||||||
opacity: !root.expanded ? 1 : 0
|
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?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
|
||||||
|
|
||||||
Behavior on y {
|
Behavior on y {
|
||||||
@@ -347,6 +359,16 @@ CustomClippingRect {
|
|||||||
radius: Tokens.rounding.full
|
radius: Tokens.rounding.full
|
||||||
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
|
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
|
||||||
|
|
||||||
|
opacity: root.slim ? 0 : 1
|
||||||
|
visible: opacity > 0
|
||||||
|
enabled: visible
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
onClicked: root.expanded = !root.expanded
|
onClicked: root.expanded = !root.expanded
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ Item {
|
|||||||
property alias list: list
|
property alias list: list
|
||||||
property alias model: list.model
|
property alias model: list.model
|
||||||
property bool highlight: false
|
property bool highlight: false
|
||||||
|
property bool slim: false
|
||||||
|
|
||||||
|
signal requestExpand
|
||||||
signal deleteChatRequest(content: ChatSession)
|
signal deleteChatRequest(content: ChatSession)
|
||||||
signal loadChatRequest(content: ChatSession, index: int)
|
signal loadChatRequest(content: ChatSession, index: int)
|
||||||
signal newChatRequest
|
signal newChatRequest
|
||||||
@@ -79,6 +81,7 @@ Item {
|
|||||||
|
|
||||||
implicitWidth: ListView.view.width
|
implicitWidth: ListView.view.width
|
||||||
highlighted: root.highlight && ChatState.chatSession === modelData
|
highlighted: root.highlight && ChatState.chatSession === modelData
|
||||||
|
slim: root.slim
|
||||||
|
|
||||||
onHighlightedChanged: if (highlighted)
|
onHighlightedChanged: if (highlighted)
|
||||||
list.currentIndex = index
|
list.currentIndex = index
|
||||||
@@ -110,8 +113,15 @@ Item {
|
|||||||
radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2
|
radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2
|
||||||
|
|
||||||
function prettyModelName(): string {
|
function prettyModelName(): string {
|
||||||
|
if (modelsList.count < 1)
|
||||||
|
return qsTr("No models found");
|
||||||
|
|
||||||
const pathList = Chat.model.split("\/");
|
const pathList = Chat.model.split("\/");
|
||||||
const name = pathList[pathList.length - 1];
|
const name = pathList[pathList.length - 1];
|
||||||
|
|
||||||
|
if (!name)
|
||||||
|
return qsTr("No model selected");
|
||||||
|
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +141,7 @@ Item {
|
|||||||
implicitHeight: fabRoot.implicitHeight
|
implicitHeight: fabRoot.implicitHeight
|
||||||
|
|
||||||
CustomText {
|
CustomText {
|
||||||
|
id: label
|
||||||
text: modelsContainer.prettyModelName()
|
text: modelsContainer.prettyModelName()
|
||||||
color: Colors.palette.m3onSurfaceVariant
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
@@ -146,10 +157,13 @@ Item {
|
|||||||
text: modelsContainer.expanded ? "unfold_less" : "unfold_more"
|
text: modelsContainer.expanded ? "unfold_less" : "unfold_more"
|
||||||
animate: true
|
animate: true
|
||||||
font.pointSize: Tokens.font.size.large
|
font.pointSize: Tokens.font.size.large
|
||||||
|
visible: modelsList.count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
id: modelLayer
|
id: modelLayer
|
||||||
|
enabled: modelsList.count > 0
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
ChatState.fabExpanded = false;
|
ChatState.fabExpanded = false;
|
||||||
modelsContainer.expanded = !modelsContainer.expanded;
|
modelsContainer.expanded = !modelsContainer.expanded;
|
||||||
@@ -246,8 +260,16 @@ Item {
|
|||||||
anchors.margins: Tokens.padding.small
|
anchors.margins: Tokens.padding.small
|
||||||
padding: 8
|
padding: 8
|
||||||
font.pointSize: Math.round(18 * 1.2)
|
font.pointSize: Math.round(18 * 1.2)
|
||||||
icon: "add"
|
icon: root.slim ? "left_panel_open" : "add"
|
||||||
isRound: ChatState.fabExpanded
|
isRound: ChatState.fabExpanded
|
||||||
|
// opacity: root.slim ? 0 : 1
|
||||||
|
// visible: opacity > 0
|
||||||
|
//
|
||||||
|
// Behavior on opacity {
|
||||||
|
// Anim {
|
||||||
|
// type: Anim.DefaultEffects
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
label.transform: Rotation {
|
label.transform: Rotation {
|
||||||
origin.y: fabRoot.label.height / 2
|
origin.y: fabRoot.label.height / 2
|
||||||
@@ -260,6 +282,11 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
|
if (root.slim && !ChatState.narrowSidebarExpanded) {
|
||||||
|
root.requestExpand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
modelsContainer.expanded = false;
|
modelsContainer.expanded = false;
|
||||||
ChatState.fabExpanded = !ChatState.fabExpanded;
|
ChatState.fabExpanded = !ChatState.fabExpanded;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ Singleton {
|
|||||||
property bool inChat: false
|
property bool inChat: false
|
||||||
property bool fabExpanded: false
|
property bool fabExpanded: false
|
||||||
property bool isWindow: false
|
property bool isWindow: false
|
||||||
|
property bool isWide: false
|
||||||
|
property bool settledSlim: false
|
||||||
|
property bool settledWide: false
|
||||||
|
property bool narrowSidebarExpanded: false
|
||||||
|
property bool windowIsWide: false
|
||||||
property ShellScreen screen
|
property ShellScreen screen
|
||||||
property bool sidebarVisible: Visibilities.getForActive().sidebar
|
property bool sidebarVisible: Visibilities.getForActive().sidebar
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ TextEditBase {
|
|||||||
|
|
||||||
color: Colors.palette.m3onSurface
|
color: Colors.palette.m3onSurface
|
||||||
readOnly: true
|
readOnly: true
|
||||||
anchors.margins: Tokens.padding.medium
|
|
||||||
textFormat: Text.MarkdownText
|
textFormat: Text.MarkdownText
|
||||||
font.pointSize: Tokens.font.size.smaller
|
font.pointSize: Tokens.font.size.smaller
|
||||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||||
|
|||||||
@@ -6,127 +6,138 @@ import qs.Services
|
|||||||
import qs.Modules.Notifications.Sidebar.Chat
|
import qs.Modules.Notifications.Sidebar.Chat
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property int animOff
|
property int animOff
|
||||||
property Item currentItem
|
property Item currentItem
|
||||||
property bool loading: false
|
property bool noAnim: false
|
||||||
property int lastIdx: -1
|
property bool loading: false
|
||||||
|
property int lastIdx: -1
|
||||||
|
|
||||||
readonly property Component conversationComp: ChatContent {}
|
readonly property Component conversationComp: ChatContent {}
|
||||||
|
|
||||||
function loadConversation(chat): void {
|
signal requestClose
|
||||||
if (currentItem) {
|
|
||||||
currentItem.destroy();
|
|
||||||
currentItem = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chat)
|
function loadConversation(chat): void {
|
||||||
return;
|
if (currentItem) {
|
||||||
|
currentItem.destroy();
|
||||||
|
currentItem = null;
|
||||||
|
}
|
||||||
|
|
||||||
root.loading = true;
|
if (!chat)
|
||||||
|
return;
|
||||||
|
|
||||||
const incubator = root.conversationComp.incubateObject(container, {
|
root.loading = true;
|
||||||
chatData: chat
|
|
||||||
});
|
|
||||||
|
|
||||||
const attach = () => {
|
const incubator = root.conversationComp.incubateObject(container, {
|
||||||
incubator.object.anchors.fill = container;
|
chatData: chat
|
||||||
currentItem = incubator.object;
|
});
|
||||||
root.loading = false;
|
|
||||||
enterAnim.start();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (incubator.status === Component.Ready)
|
const attach = () => {
|
||||||
attach();
|
incubator.object.anchors.fill = container;
|
||||||
else
|
incubator.object.requestClose.connect(root.requestClose);
|
||||||
incubator.onStatusChanged = status => {
|
currentItem = incubator.object;
|
||||||
if (status === Component.Ready)
|
root.loading = false;
|
||||||
attach();
|
if (!noAnim)
|
||||||
};
|
enterAnim.start();
|
||||||
}
|
};
|
||||||
|
|
||||||
Item {
|
if (incubator.status === Component.Ready)
|
||||||
id: container
|
attach();
|
||||||
|
else
|
||||||
|
incubator.onStatusChanged = status => {
|
||||||
|
if (status === Component.Ready)
|
||||||
|
attach();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
anchors.fill: parent
|
Item {
|
||||||
layer.enabled: opacity < 1
|
id: container
|
||||||
objectName: "ConversationContainer"
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
anchors.fill: parent
|
||||||
if (ChatState.chatSession)
|
layer.enabled: opacity < 1
|
||||||
root.loadConversation(ChatState.chatSession);
|
objectName: "ConversationContainer"
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LoadingIndicator {
|
Component.onCompleted: {
|
||||||
anchors.centerIn: parent
|
if (ChatState.chatSession)
|
||||||
implicitSize: Tokens.font.size.extraLarge * 4
|
root.loadConversation(ChatState.chatSession);
|
||||||
opacity: root.loading ? 1 : 0
|
}
|
||||||
visible: opacity > 0
|
}
|
||||||
|
|
||||||
Behavior on opacity {
|
LoadingIndicator {
|
||||||
Anim {}
|
anchors.centerIn: parent
|
||||||
}
|
implicitSize: Tokens.font.size.extraLarge * 4
|
||||||
}
|
opacity: root.loading ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
Connections {
|
Behavior on opacity {
|
||||||
function onChatSessionChanged(): void {
|
Anim {}
|
||||||
exitAnim.complete();
|
}
|
||||||
enterAnim.complete();
|
}
|
||||||
root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1);
|
|
||||||
root.lastIdx = ChatState.currentIdx;
|
|
||||||
exitAnim.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
target: ChatState
|
Connections {
|
||||||
}
|
function onChatSessionChanged(): void {
|
||||||
|
exitAnim.complete();
|
||||||
|
enterAnim.complete();
|
||||||
|
|
||||||
SequentialAnimation {
|
if (root.noAnim) {
|
||||||
id: exitAnim
|
root.loadConversation(ChatState.chatSession);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Anim {
|
root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1);
|
||||||
property: "opacity"
|
root.lastIdx = ChatState.currentIdx;
|
||||||
target: container
|
exitAnim.start();
|
||||||
to: 0
|
}
|
||||||
type: Anim.DefaultEffects
|
|
||||||
}
|
|
||||||
|
|
||||||
ScriptAction {
|
target: ChatState
|
||||||
script: root.loadConversation(ChatState.chatSession)
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SequentialAnimation {
|
SequentialAnimation {
|
||||||
id: enterAnim
|
id: exitAnim
|
||||||
|
|
||||||
PropertyAction {
|
Anim {
|
||||||
property: "topMargin"
|
property: "opacity"
|
||||||
target: container.anchors
|
target: container
|
||||||
value: root.animOff
|
to: 0
|
||||||
}
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
|
||||||
PropertyAction {
|
ScriptAction {
|
||||||
property: "bottomMargin"
|
script: root.loadConversation(ChatState.chatSession)
|
||||||
target: container.anchors
|
}
|
||||||
value: -root.animOff
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ParallelAnimation {
|
SequentialAnimation {
|
||||||
Anim {
|
id: enterAnim
|
||||||
from: 0
|
|
||||||
property: "opacity"
|
|
||||||
target: container
|
|
||||||
to: 1
|
|
||||||
type: Anim.SlowEffects
|
|
||||||
}
|
|
||||||
|
|
||||||
Anim {
|
PropertyAction {
|
||||||
properties: "topMargin,bottomMargin"
|
property: "topMargin"
|
||||||
target: container.anchors
|
target: container.anchors
|
||||||
to: 0
|
value: root.animOff
|
||||||
type: Anim.SlowEffects
|
}
|
||||||
}
|
|
||||||
}
|
PropertyAction {
|
||||||
}
|
property: "bottomMargin"
|
||||||
|
target: container.anchors
|
||||||
|
value: -root.animOff
|
||||||
|
}
|
||||||
|
|
||||||
|
ParallelAnimation {
|
||||||
|
Anim {
|
||||||
|
from: 0
|
||||||
|
property: "opacity"
|
||||||
|
target: container
|
||||||
|
to: 1
|
||||||
|
type: Anim.SlowEffects
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
properties: "topMargin,bottomMargin"
|
||||||
|
target: container.anchors
|
||||||
|
to: 0
|
||||||
|
type: Anim.SlowEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import QtQuick
|
|||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ZShell.Llm
|
import ZShell.Llm
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
|
import qs.Modules.Notifications.Sidebar.Chat
|
||||||
import qs.Components
|
import qs.Components
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
|
||||||
@@ -16,32 +17,60 @@ Item {
|
|||||||
required property ChatMessage message
|
required property ChatMessage message
|
||||||
required property ChatGeneration current
|
required property ChatGeneration current
|
||||||
|
|
||||||
// Widest the bubble may grow to; assistant bubbles always use it so
|
|
||||||
// the markdown blocks have a deterministic width to wrap against.
|
|
||||||
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
|
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
|
||||||
|
|
||||||
signal edit(text: string)
|
signal edit(text: string)
|
||||||
|
|
||||||
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
|
implicitHeight: bubble.implicitHeight + (actionsRow.shouldBeActive ? actionsRow.implicitHeight + actionsRow.anchors.topMargin : 0)
|
||||||
|
|
||||||
CustomClippingRect {
|
CustomClippingRect {
|
||||||
id: bubble
|
id: bubble
|
||||||
|
|
||||||
|
property real liveTargetWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
||||||
|
property real frozenWidth: 0.0
|
||||||
|
property bool layoutFrozen: false
|
||||||
|
property real layoutWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
||||||
|
|
||||||
radius: Tokens.rounding.medium
|
radius: Tokens.rounding.medium
|
||||||
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
||||||
implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
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 : blocks.implicitHeight + blocks.anchors.topMargin * 2
|
||||||
anchors.right: root.isUser ? parent.right : undefined
|
anchors.right: root.isUser ? parent.right : undefined
|
||||||
|
layer.enabled: false
|
||||||
|
layer.smooth: true
|
||||||
|
|
||||||
// Behavior on implicitHeight {
|
transform: Scale {
|
||||||
// enabled: !root.segment.running
|
id: stretchScale
|
||||||
//
|
origin.x: root.isUser ? bubble.width : 0
|
||||||
// Anim {
|
xScale: bubble.layoutFrozen && bubble.frozenWidth > 0 ? bubble.liveTargetWidth / bubble.frozenWidth : 1.0
|
||||||
// type: Anim.DefaultEffects
|
}
|
||||||
// }
|
|
||||||
// }
|
states: [
|
||||||
|
State {
|
||||||
|
name: "toSlim"
|
||||||
|
when: ChatState.isWindow && !ChatState.isWide && !ChatState.settledSlim
|
||||||
|
|
||||||
|
PropertyChanges {
|
||||||
|
bubble.layer.enabled: true
|
||||||
|
bubble.layoutFrozen: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
State {
|
||||||
|
name: "toWide"
|
||||||
|
when: ChatState.isWindow && ChatState.isWide && !ChatState.settledWide
|
||||||
|
|
||||||
|
PropertyChanges {
|
||||||
|
bubble.layer.enabled: true
|
||||||
|
bubble.layoutFrozen: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
onLayoutFrozenChanged: {
|
||||||
|
if (layoutFrozen)
|
||||||
|
frozenWidth = width;
|
||||||
|
}
|
||||||
|
|
||||||
// User messages stay a plain editable text field.
|
|
||||||
TextEditBase {
|
TextEditBase {
|
||||||
id: msgText
|
id: msgText
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ MouseArea {
|
|||||||
property ChatGeneration current: modelData.activeGeneration
|
property ChatGeneration current: modelData.activeGeneration
|
||||||
required property int index
|
required property int index
|
||||||
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
||||||
|
required property Item rootParent
|
||||||
required property ChatMessage modelData
|
required property ChatMessage modelData
|
||||||
property bool reasoningExpanded: false
|
property bool reasoningExpanded: false
|
||||||
readonly property var blocks: blockify(current.segments)
|
readonly property var blocks: blockify(current.segments)
|
||||||
@@ -160,6 +161,7 @@ MouseArea {
|
|||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
|
spacing: Tokens.spacing.medium
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
id: segmentRep
|
id: segmentRep
|
||||||
@@ -177,6 +179,8 @@ MouseArea {
|
|||||||
delegate: ProcessBlock {
|
delegate: ProcessBlock {
|
||||||
width: root.width
|
width: root.width
|
||||||
blocks: root.blocks
|
blocks: root.blocks
|
||||||
|
current: root.current
|
||||||
|
rootParent: root.rootParent
|
||||||
|
|
||||||
onExpandedChanged: root.handleReasoningToggle(expanded)
|
onExpandedChanged: root.handleReasoningToggle(expanded)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,40 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Blobs
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
|
import ZShell.Components
|
||||||
import ZShell.Llm
|
import ZShell.Llm
|
||||||
import qs.Components
|
import qs.Components
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
|
||||||
CustomClippingRect {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
required property int index
|
required property int index
|
||||||
required property var modelData
|
required property var modelData
|
||||||
required property var blocks
|
required property var blocks
|
||||||
readonly property var segments: modelData.segments
|
readonly property var segments: modelData.segments
|
||||||
|
required property Item rootParent
|
||||||
|
required property ChatGeneration current
|
||||||
readonly property bool isActive: index === blocks.length - 1
|
readonly property bool isActive: index === blocks.length - 1
|
||||||
property bool expanded: false
|
property bool expanded: false
|
||||||
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1]
|
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1]
|
||||||
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
|
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))
|
||||||
|
|
||||||
implicitHeight: expanded ? expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin * 2 : layout.implicitHeight + Tokens.spacing.small
|
clip: true
|
||||||
|
implicitHeight: {
|
||||||
|
const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin;
|
||||||
|
const collapsed = layout.implicitHeight;
|
||||||
|
const approval = layout.implicitHeight + approvalPrompt.implicitHeight + approvalPrompt.anchors.topMargin;
|
||||||
|
|
||||||
|
if (expanded)
|
||||||
|
return expand;
|
||||||
|
else if (pendingApproval)
|
||||||
|
return approval;
|
||||||
|
else
|
||||||
|
return collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
Behavior on implicitHeight {
|
Behavior on implicitHeight {
|
||||||
Anim {
|
Anim {
|
||||||
@@ -57,6 +74,7 @@ CustomClippingRect {
|
|||||||
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
opacity: root.isActive ? 0 : 1
|
opacity: root.isActive ? 0 : 1
|
||||||
|
enabled: opacity > 0
|
||||||
rotation: root.expanded ? 180 : 0
|
rotation: root.expanded ? 180 : 0
|
||||||
type: IconButton.Text
|
type: IconButton.Text
|
||||||
|
|
||||||
@@ -77,6 +95,9 @@ CustomClippingRect {
|
|||||||
Layout.leftMargin: Tokens.spacing.medium
|
Layout.leftMargin: Tokens.spacing.medium
|
||||||
text: {
|
text: {
|
||||||
if (root.isActive) {
|
if (root.isActive) {
|
||||||
|
if (root.pendingApproval)
|
||||||
|
return qsTr("Waiting for approval...");
|
||||||
|
|
||||||
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
|
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
|
||||||
return qsTr("Using %1...").arg(root.lastSegment.name);
|
return qsTr("Using %1...").arg(root.lastSegment.name);
|
||||||
return qsTr("Thinking...");
|
return qsTr("Thinking...");
|
||||||
@@ -90,6 +111,261 @@ CustomClippingRect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
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.pointSize: Tokens.font.size.large
|
||||||
|
color: Colors.palette.m3onSurface
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: toolsBg
|
||||||
|
|
||||||
|
property bool open: false
|
||||||
|
property Item rootParent: root.rootParent
|
||||||
|
property int openHeight: toolsBg.rootParent.height - Tokens.padding.medium * 2
|
||||||
|
property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2
|
||||||
|
|
||||||
|
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
function reparentWrapper(): void {
|
||||||
|
const newParent = open ? toolsBg.rootParent : toolsBg;
|
||||||
|
const pos = toolsWrapper.mapToItem(newParent, 0, 0);
|
||||||
|
toolsWrapper.parent = newParent;
|
||||||
|
toolsWrapper.x = pos.x;
|
||||||
|
toolsWrapper.y = pos.y;
|
||||||
|
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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: (toolsBg.rootParent.height - toolsBg.openHeight) / 2
|
||||||
|
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: {
|
||||||
|
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
|
||||||
|
toolsBg.open = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: toolList
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.current.pendingToolCalls ?? []
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
color: Colors.palette.m3onPrimaryContainer
|
||||||
|
text: String(toolText.index + 1)
|
||||||
|
font.pointSize: Tokens.font.size.large
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonRow {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CustomRect {
|
CustomRect {
|
||||||
id: expandedRect
|
id: expandedRect
|
||||||
|
|
||||||
@@ -98,7 +374,6 @@ CustomClippingRect {
|
|||||||
anchors.top: layout.bottom
|
anchors.top: layout.bottom
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
anchors.topMargin: Tokens.spacing.small
|
anchors.topMargin: Tokens.spacing.small
|
||||||
anchors.bottomMargin: Tokens.spacing.small
|
|
||||||
color: Colors.palette.m3surfaceContainerLow
|
color: Colors.palette.m3surfaceContainerLow
|
||||||
implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2
|
implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2
|
||||||
opacity: root.expanded ? 1 : 0
|
opacity: root.expanded ? 1 : 0
|
||||||
@@ -147,6 +422,49 @@ CustomClippingRect {
|
|||||||
CustomText {
|
CustomText {
|
||||||
id: content
|
id: content
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyTool(seg: LlmSegment): string {
|
||||||
|
if (seg.name === "webfetch") {
|
||||||
|
switch (seg.status) {
|
||||||
|
case LlmSegment.Status.Running:
|
||||||
|
return qsTr("Fetching");
|
||||||
|
case LlmSegment.Status.Pending:
|
||||||
|
return qsTr("Pending fetch");
|
||||||
|
case LlmSegment.Status.Success:
|
||||||
|
return qsTr("Fetched");
|
||||||
|
case LlmSegment.Status.Error:
|
||||||
|
return qsTr("Failed to fetch");
|
||||||
|
default:
|
||||||
|
return qsTr("Fetched website");
|
||||||
|
}
|
||||||
|
} else if (seg.name === "readfile") {
|
||||||
|
switch (seg.status) {
|
||||||
|
case LlmSegment.Status.Running:
|
||||||
|
return qsTr("Reading");
|
||||||
|
case LlmSegment.Status.Pending:
|
||||||
|
return qsTr("Pending read");
|
||||||
|
case LlmSegment.Status.Success:
|
||||||
|
return qsTr("Read");
|
||||||
|
case LlmSegment.Status.Error:
|
||||||
|
return qsTr("Failed to read");
|
||||||
|
default:
|
||||||
|
return qsTr("Read file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: Tokens.padding.small
|
anchors.leftMargin: Tokens.padding.small
|
||||||
anchors.top: header.bottom
|
anchors.top: header.bottom
|
||||||
@@ -155,7 +473,7 @@ CustomClippingRect {
|
|||||||
color: Colors.palette.m3outline
|
color: Colors.palette.m3outline
|
||||||
font.pointSize: Tokens.font.size.small
|
font.pointSize: Tokens.font.size.small
|
||||||
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
|
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
|
||||||
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website")
|
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,38 +4,67 @@ import QtQuick
|
|||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import ZShell.Blobs
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
import ZShell.Llm
|
import ZShell.Llm
|
||||||
import qs.Components
|
import qs.Components
|
||||||
import qs.Modules.Notifications.Sidebar.Chat.Content
|
import qs.Modules.Notifications.Sidebar.Chat.Content
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
|
||||||
RowLayout {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property int breakpoint: 700
|
property int breakpoint: Math.round(Config.sidebar.sizes.width * 2) + Tokens.spacing.medium * 2 + Tokens.padding.medium * 3
|
||||||
|
property color blobColor: Colors.tPalette.m3surfaceContainerLow
|
||||||
|
readonly property int iconSize: Math.round(Tokens.font.size.extraLarge * (96 / 72)) + Tokens.padding.large * 2
|
||||||
property alias conversationModel: sidebar.model
|
property alias conversationModel: sidebar.model
|
||||||
|
property bool chatOpen: ChatState.chatSession !== null
|
||||||
|
|
||||||
readonly property bool isWide: root.width >= root.breakpoint
|
readonly property bool isWide: root.width >= root.breakpoint
|
||||||
property bool narrowShowsSidebar: true
|
|
||||||
|
readonly property bool showList: isWide || !chatOpen
|
||||||
|
readonly property bool showContent: !isWide && chatOpen
|
||||||
|
|
||||||
function openConversation(conv: ChatSession, index: int): void {
|
function openConversation(conv: ChatSession, index: int): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BlobGroup {
|
||||||
|
id: blobGroup
|
||||||
|
|
||||||
|
color: root.blobColor
|
||||||
|
smoothing: Tokens.rounding.medium
|
||||||
|
}
|
||||||
|
|
||||||
|
BlobInvertedRect {
|
||||||
|
id: invertedRect
|
||||||
|
anchors.fill: parent
|
||||||
|
borderBottom: Tokens.padding.small
|
||||||
|
borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium
|
||||||
|
borderRight: Tokens.padding.small
|
||||||
|
borderTop: Tokens.padding.small
|
||||||
|
group: blobGroup
|
||||||
|
opacity: root.blobColor.a
|
||||||
|
radius: Tokens.rounding.largeIncreased
|
||||||
|
}
|
||||||
|
|
||||||
ChatList {
|
ChatList {
|
||||||
id: sidebar
|
id: sidebar
|
||||||
|
|
||||||
Layout.fillHeight: true
|
anchors.top: parent.top
|
||||||
Layout.margins: Tokens.padding.medium
|
anchors.left: parent.left
|
||||||
Layout.rightMargin: Tokens.spacing.medium
|
anchors.bottom: parent.bottom
|
||||||
Layout.maximumWidth: Config.sidebar.sizes.width
|
anchors.margins: Tokens.padding.medium
|
||||||
Layout.preferredWidth: root.width / 4
|
implicitWidth: root.isWide || ChatState.narrowSidebarExpanded ? Config.sidebar.sizes.width : root.iconSize
|
||||||
Layout.minimumWidth: Config.sidebar.sizes.width / 2
|
|
||||||
highlight: true
|
highlight: true
|
||||||
|
slim: !root.isWide && !ChatState.narrowSidebarExpanded
|
||||||
|
z: 1
|
||||||
|
|
||||||
Behavior on implicitWidth {
|
Behavior on implicitWidth {
|
||||||
Anim {}
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
model: ScriptModel {
|
model: ScriptModel {
|
||||||
values: Chat.chats.values
|
values: Chat.chats.values
|
||||||
}
|
}
|
||||||
@@ -46,24 +75,78 @@ RowLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDeleteChatRequest: chat => {
|
onDeleteChatRequest: chat => {
|
||||||
|
if (chat === ChatState.chatSession) {
|
||||||
|
ChatState.chatSession = null;
|
||||||
|
ChatState.currentIdx = -1;
|
||||||
|
}
|
||||||
Chat.chats.remove(chat);
|
Chat.chats.remove(chat);
|
||||||
ChatState.currentIdx = -1;
|
|
||||||
ChatState.chatSession = null;
|
|
||||||
}
|
}
|
||||||
onNewChatRequest: {
|
onNewChatRequest: {
|
||||||
const data = Chat.chats.insert();
|
const data = Chat.chats.insert();
|
||||||
ChatState.currentIdx = 0;
|
ChatState.currentIdx = 0;
|
||||||
ChatState.chatSession = data;
|
ChatState.chatSession = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onRequestExpand: {
|
||||||
|
ChatState.narrowSidebarExpanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomClippingRect {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: invertedRect.borderLeft
|
||||||
|
anchors.topMargin: invertedRect.borderTop
|
||||||
|
anchors.bottomMargin: invertedRect.borderBottom
|
||||||
|
anchors.rightMargin: invertedRect.borderRight
|
||||||
|
radius: invertedRect.radius
|
||||||
|
|
||||||
|
children: [contentArea, dim]
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: dim
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
color: Colors.palette.m3shadow
|
||||||
|
opacity: ChatState.narrowSidebarExpanded && !root.isWide ? 0.3 : 0
|
||||||
|
z: 1
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.margins: Tokens.padding.extraLarge
|
id: contentArea
|
||||||
Layout.leftMargin: Tokens.spacing.extraLarge
|
|
||||||
Layout.topMargin: Tokens.padding.large
|
property int leftMargin: sidebar.implicitWidth
|
||||||
Layout.preferredWidth: Config.sidebar.sizes.width * 2
|
|
||||||
Layout.fillWidth: true
|
z: 0
|
||||||
Layout.fillHeight: true
|
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
|
||||||
|
|
||||||
|
states: State {
|
||||||
|
name: "wide"
|
||||||
|
when: !root.isWide
|
||||||
|
|
||||||
|
PropertyChanges {
|
||||||
|
contentArea.leftMargin: root.iconSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transitions: Transition {
|
||||||
|
to: "wide"
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
property: "leftMargin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ChatHost {
|
ChatHost {
|
||||||
id: convHost
|
id: convHost
|
||||||
@@ -71,10 +154,11 @@ RowLayout {
|
|||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
implicitWidth: Math.min(parent.width, 800)
|
implicitWidth: Math.max(Math.min(parent.width, 800), Config.sidebar.sizes.width)
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
onImplicitWidthChanged: console.log(implicitWidth)
|
onLoadingChanged: if (!loading)
|
||||||
|
root.chatOpen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
@@ -100,6 +184,51 @@ RowLayout {
|
|||||||
color: Colors.tPalette.m3onSurfaceVariant
|
color: Colors.tPalette.m3onSurfaceVariant
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: dimArea
|
||||||
|
anchors.fill: parent
|
||||||
|
preventStealing: true
|
||||||
|
enabled: !root.isWide && ChatState.narrowSidebarExpanded
|
||||||
|
cursorShape: undefined
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
const x = mouseX;
|
||||||
|
const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2;
|
||||||
|
|
||||||
|
if (x > invertedRect.borderLeft - margin)
|
||||||
|
ChatState.narrowSidebarExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
cursorShape: dimArea.enabled ? Qt.ArrowCursor : undefined
|
||||||
|
enabled: dimArea.enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Binding {
|
||||||
|
target: ChatState
|
||||||
|
property: "windowIsWide"
|
||||||
|
value: root.showList
|
||||||
|
}
|
||||||
|
|
||||||
|
Binding {
|
||||||
|
target: ChatState
|
||||||
|
property: "isWide"
|
||||||
|
value: root.isWide
|
||||||
|
}
|
||||||
|
|
||||||
|
Binding {
|
||||||
|
target: ChatState
|
||||||
|
value: contentArea.leftMargin === root.iconSize
|
||||||
|
property: "settledSlim"
|
||||||
|
}
|
||||||
|
|
||||||
|
Binding {
|
||||||
|
target: ChatState
|
||||||
|
value: contentArea.leftMargin === sidebar.implicitWidth
|
||||||
|
property: "settledWide"
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
pragma ComponentBehavior: Bound
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
import ZShell.Llm
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import qs.Components
|
import qs.Components
|
||||||
@@ -31,7 +30,7 @@ Item {
|
|||||||
|
|
||||||
Tabs {
|
Tabs {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: ChatState.isWindow ? 0 : implicitHeight
|
Layout.preferredHeight: ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight
|
||||||
dashState: root.props
|
dashState: root.props
|
||||||
nonAnimWidth: layout.width
|
nonAnimWidth: layout.width
|
||||||
visible: height > 0
|
visible: height > 0
|
||||||
@@ -53,12 +52,10 @@ Item {
|
|||||||
x: root.props.currentTab === 0 ? 0 : -root.width
|
x: root.props.currentTab === 0 ? 0 : -root.width
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on x {
|
Behavior on x {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomRect {
|
CustomRect {
|
||||||
@@ -73,33 +70,39 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Loader {
|
||||||
id: chatPage
|
id: chatLoader
|
||||||
|
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
implicitWidth: parent.width
|
active: Config.llm.enabled
|
||||||
opacity: root.props.currentTab === 1 ? 1 : 0
|
width: parent.width
|
||||||
visible: opacity > 0
|
property bool tre: true
|
||||||
x: root.props.currentTab === 0 ? root.width : 0
|
|
||||||
z: 1
|
|
||||||
|
|
||||||
Behavior on opacity {
|
sourceComponent: Item {
|
||||||
Anim {
|
id: chatPage
|
||||||
|
|
||||||
|
opacity: root.props.currentTab === 1 ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
x: root.props.currentTab === 0 ? root.width : 0
|
||||||
|
z: 1
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Behavior on x {
|
Behavior on x {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
CustomRect {
|
CustomRect {
|
||||||
anchors.fill: parent
|
|
||||||
color: Colors.tPalette.m3surfaceContainerLow
|
|
||||||
radius: Tokens.rounding.small
|
|
||||||
|
|
||||||
ChatPanel {
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
color: Colors.tPalette.m3surfaceContainerLow
|
||||||
|
radius: Tokens.rounding.small
|
||||||
|
|
||||||
|
ChatPanel {
|
||||||
|
anchors.fill: parent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,10 @@ ColumnLayout {
|
|||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.visibilities.sidebar = false;
|
root.visibilities.sidebar = false;
|
||||||
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]);
|
if (Config.launcher.uwsm)
|
||||||
|
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]);
|
||||||
|
else
|
||||||
|
Quickshell.execDetached([...Config.general.apps.playback, recording.modelData.path]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +123,10 @@ ColumnLayout {
|
|||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.visibilities.sidebar = false;
|
root.visibilities.sidebar = false;
|
||||||
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]);
|
if (Config.launcher.uwsm)
|
||||||
|
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]);
|
||||||
|
else
|
||||||
|
Quickshell.execDetached([...Config.general.apps.explorer, recording.modelData.path]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,8 +141,7 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Behavior on implicitHeight {
|
Behavior on implicitHeight {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
model: FileSystemModel {
|
model: FileSystemModel {
|
||||||
nameFilters: ["recording_*.mp4"]
|
nameFilters: ["recording_*.mp4"]
|
||||||
@@ -162,8 +167,7 @@ ColumnLayout {
|
|||||||
opacity: list.count === 0 ? 1 : 0
|
opacity: list.count === 0 ? 1 : 0
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sourceComponent: ColumnLayout {
|
sourceComponent: ColumnLayout {
|
||||||
spacing: Tokens.spacing.small
|
spacing: Tokens.spacing.small
|
||||||
@@ -178,16 +182,13 @@ ColumnLayout {
|
|||||||
text: "scan_delete"
|
text: "scan_delete"
|
||||||
|
|
||||||
Behavior on Layout.preferredHeight {
|
Behavior on Layout.preferredHeight {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on scale {
|
Behavior on scale {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,16 +204,13 @@ ColumnLayout {
|
|||||||
text: "scan_delete"
|
text: "scan_delete"
|
||||||
|
|
||||||
Behavior on Layout.preferredWidth {
|
Behavior on Layout.preferredWidth {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on scale {
|
Behavior on scale {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-20
@@ -73,20 +73,21 @@ Scope {
|
|||||||
|
|
||||||
// mask: Region { item: inputPanel }
|
// mask: Region { item: inputPanel }
|
||||||
|
|
||||||
Rectangle {
|
CustomRect {
|
||||||
id: inputPanel
|
id: inputPanel
|
||||||
|
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
color: Colors.tPalette.m3surface
|
color: Colors.tPalette.m3surface
|
||||||
implicitHeight: layout.childrenRect.height + 28
|
implicitHeight: layout.implicitHeight + layout.anchors.margins * 2
|
||||||
implicitWidth: layout.childrenRect.width + 32
|
implicitWidth: Math.max(layout.implicitWidth + layout.anchors.margins * 2, 450)
|
||||||
opacity: 0
|
opacity: 0
|
||||||
radius: Tokens.rounding.small * 2
|
radius: Tokens.rounding.small * 2
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: layout
|
id: layout
|
||||||
|
|
||||||
anchors.centerIn: parent
|
anchors.fill: parent
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
id: contentRow
|
id: contentRow
|
||||||
@@ -130,7 +131,7 @@ Scope {
|
|||||||
Layout.preferredWidth: Math.min(600, contentWidth)
|
Layout.preferredWidth: Math.min(600, contentWidth)
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pointSize: 16
|
font.pointSize: 16
|
||||||
text: polkitAgent.flow?.message
|
text: polkitAgent.flow?.message ?? ""
|
||||||
wrapMode: Text.WordWrap
|
wrapMode: Text.WordWrap
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +148,8 @@ Scope {
|
|||||||
TextField {
|
TextField {
|
||||||
id: passInput
|
id: passInput
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: 40
|
Layout.preferredHeight: 40
|
||||||
Layout.preferredWidth: contentColumn.implicitWidth
|
|
||||||
color: Colors.palette.m3onSurfaceVariant
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
echoMode: polkitAgent.flow?.responseVisible ? TextInput.Normal : TextInput.Password
|
echoMode: polkitAgent.flow?.responseVisible ? TextInput.Normal : TextInput.Password
|
||||||
placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password"
|
placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password"
|
||||||
@@ -168,7 +169,7 @@ Scope {
|
|||||||
id: showPassCheckbox
|
id: showPassCheckbox
|
||||||
|
|
||||||
Layout.alignment: Qt.AlignLeft
|
Layout.alignment: Qt.AlignLeft
|
||||||
checked: polkitAgent.flow?.responseVisible
|
checked: polkitAgent.flow?.responseVisible ?? false
|
||||||
text: "Show Password"
|
text: "Show Password"
|
||||||
|
|
||||||
onCheckedChanged: {
|
onCheckedChanged: {
|
||||||
@@ -189,7 +190,8 @@ Scope {
|
|||||||
clip: true
|
clip: true
|
||||||
color: Colors.tPalette.m3surfaceContainerLow
|
color: Colors.tPalette.m3surfaceContainerLow
|
||||||
implicitHeight: 0
|
implicitHeight: 0
|
||||||
radius: 16
|
implicitWidth: textDetailsColumn.implicitWidth + textDetailsColumn.anchors.margins * 2
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
visible: true
|
visible: true
|
||||||
|
|
||||||
Behavior on open {
|
Behavior on open {
|
||||||
@@ -197,7 +199,8 @@ Scope {
|
|||||||
Anim {
|
Anim {
|
||||||
property: "implicitHeight"
|
property: "implicitHeight"
|
||||||
target: detailsPanel
|
target: detailsPanel
|
||||||
to: !detailsPanel.open ? textDetailsColumn.childrenRect.height + 16 : 0
|
to: !detailsPanel.open ? textDetailsColumn.implicitHeight + Tokens.padding.small * 2 : 0
|
||||||
|
type: Anim.DefaultEffects
|
||||||
}
|
}
|
||||||
|
|
||||||
Anim {
|
Anim {
|
||||||
@@ -205,12 +208,6 @@ Scope {
|
|||||||
target: textDetailsColumn
|
target: textDetailsColumn
|
||||||
to: !detailsPanel.open ? 1 : 0
|
to: !detailsPanel.open ? 1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
Anim {
|
|
||||||
property: "scale"
|
|
||||||
target: textDetailsColumn
|
|
||||||
to: !detailsPanel.open ? 1 : 0.9
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,10 +215,9 @@ Scope {
|
|||||||
id: textDetailsColumn
|
id: textDetailsColumn
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 8
|
anchors.margins: Tokens.padding.small
|
||||||
opacity: 0
|
opacity: 0
|
||||||
scale: 0.9
|
spacing: Tokens.spacing.small
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
CustomText {
|
CustomText {
|
||||||
text: `actionId: ${polkitAgent.flow?.actionId}`
|
text: `actionId: ${polkitAgent.flow?.actionId}`
|
||||||
@@ -239,16 +235,17 @@ Scope {
|
|||||||
Layout.preferredWidth: contentRow.implicitWidth
|
Layout.preferredWidth: contentRow.implicitWidth
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
IconTextButton {
|
IconButton {
|
||||||
id: detailsButton
|
id: detailsButton
|
||||||
|
|
||||||
Layout.alignment: Qt.AlignLeft
|
Layout.alignment: Qt.AlignLeft
|
||||||
|
horizontalPadding: Tokens.padding.medium
|
||||||
icon: "info"
|
icon: "info"
|
||||||
inactiveColor: Colors.palette.m3surfaceContainer
|
inactiveColor: Colors.palette.m3surfaceContainer
|
||||||
inactiveOnColor: Colors.palette.m3onSurface
|
inactiveOnColor: Colors.palette.m3onSurface
|
||||||
isRound: true
|
isRound: true
|
||||||
shapeMorph: true
|
shapeMorph: true
|
||||||
text: "Details"
|
verticalPadding: Tokens.padding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
panelWindow.detailsOpen = !panelWindow.detailsOpen;
|
panelWindow.detailsOpen = !panelWindow.detailsOpen;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ ColumnLayout {
|
|||||||
function findAnchor(item: Item, anchor: string): Item {
|
function findAnchor(item: Item, anchor: string): Item {
|
||||||
if (!item)
|
if (!item)
|
||||||
return null;
|
return null;
|
||||||
if (item.settingAnchor !== undefined && item.settingAnchor === anchor)
|
if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property
|
||||||
return item;
|
return item;
|
||||||
const kids = item.children;
|
const kids = item.children;
|
||||||
for (let i = 0; i < kids.length; i++) {
|
for (let i = 0; i < kids.length; i++) {
|
||||||
@@ -43,8 +43,8 @@ ColumnLayout {
|
|||||||
|
|
||||||
function highlightAnchor(anchor: string): void {
|
function highlightAnchor(anchor: string): void {
|
||||||
const row = findAnchor(contentChild, anchor);
|
const row = findAnchor(contentChild, anchor);
|
||||||
if (row && row.flashHighlight !== undefined)
|
if (row && row.flashHighlight !== undefined) // qmllint disable missing-property
|
||||||
row.flashHighlight();
|
row.flashHighlight(); // qmllint disable missing-property
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToAnchor(anchor: string): bool {
|
function scrollToAnchor(anchor: string): bool {
|
||||||
@@ -61,8 +61,8 @@ ColumnLayout {
|
|||||||
root.animateScroll = true;
|
root.animateScroll = true;
|
||||||
flickable.contentY = target;
|
flickable.contentY = target;
|
||||||
Qt.callLater(() => root.animateScroll = false);
|
Qt.callLater(() => root.animateScroll = false);
|
||||||
if (row.flashHighlight !== undefined)
|
if (row.flashHighlight !== undefined) // qmllint disable missing-property
|
||||||
row.flashHighlight();
|
row.flashHighlight(); // qmllint disable missing-property
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +154,20 @@ ColumnLayout {
|
|||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.topMargin: -topMargin
|
Layout.topMargin: -topMargin
|
||||||
|
|
||||||
bottomMargin: Tokens.padding.extraLarge
|
bottomMargin: Tokens.padding.extraLarge
|
||||||
|
topMargin: Tokens.padding.large
|
||||||
|
fadeAmount: 0.1
|
||||||
|
|
||||||
contentHeight: root.contentChild?.implicitHeight ?? 0
|
contentHeight: root.contentChild?.implicitHeight ?? 0
|
||||||
contentItem.children: [root.contentChild]
|
contentItem.children: [root.contentChild]
|
||||||
fadeAmount: 0.1
|
|
||||||
topMargin: Tokens.padding.large
|
rebound: Transition {
|
||||||
|
Anim {
|
||||||
|
properties: "x,y"
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Behavior on contentY {
|
Behavior on contentY {
|
||||||
enabled: root.animateScroll
|
enabled: root.animateScroll
|
||||||
|
|||||||
@@ -81,14 +81,10 @@ VerticalFadeFlickable {
|
|||||||
topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||||
topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||||
|
|
||||||
RadiusBehavior on bottomLeftRadius {
|
RadiusBehavior on bottomLeftRadius {}
|
||||||
}
|
RadiusBehavior on bottomRightRadius {}
|
||||||
RadiusBehavior on bottomRightRadius {
|
RadiusBehavior on topLeftRadius {}
|
||||||
}
|
RadiusBehavior on topRightRadius {}
|
||||||
RadiusBehavior on topLeftRadius {
|
|
||||||
}
|
|
||||||
RadiusBehavior on topRightRadius {
|
|
||||||
}
|
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
id: stateLayer
|
id: stateLayer
|
||||||
@@ -262,14 +258,10 @@ VerticalFadeFlickable {
|
|||||||
topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||||
width: cardList.width
|
width: cardList.width
|
||||||
|
|
||||||
RadiusBehavior on bottomLeftRadius {
|
RadiusBehavior on bottomLeftRadius {}
|
||||||
}
|
RadiusBehavior on bottomRightRadius {}
|
||||||
RadiusBehavior on bottomRightRadius {
|
RadiusBehavior on topLeftRadius {}
|
||||||
}
|
RadiusBehavior on topRightRadius {}
|
||||||
RadiusBehavior on topLeftRadius {
|
|
||||||
}
|
|
||||||
RadiusBehavior on topRightRadius {
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: resultLayout
|
id: resultLayout
|
||||||
@@ -318,9 +310,10 @@ VerticalFadeFlickable {
|
|||||||
|
|
||||||
z: 1
|
z: 1
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor);
|
const target = result.modelData.targetSubPath;
|
||||||
}
|
root.sState.jumpToSetting(result.modelData.pageIdx, target.length > 0 ? target : result.modelData.subPath, target.length > 0 ? "" : result.modelData.anchor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomSwitch {
|
CustomSwitch {
|
||||||
|
|||||||
@@ -67,6 +67,20 @@ PageBase {
|
|||||||
|
|
||||||
SectionHeader {
|
SectionHeader {
|
||||||
first: true
|
first: true
|
||||||
|
text: qsTr("General")
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleRow {
|
||||||
|
settingAnchor: "panels-sidebar-llm-enabled"
|
||||||
|
checked: Config.llm.enabled
|
||||||
|
text: qsTr("Enabled")
|
||||||
|
first: true
|
||||||
|
last: true
|
||||||
|
|
||||||
|
onToggled: Config.llm.enabled = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionHeader {
|
||||||
text: qsTr("Appearance")
|
text: qsTr("Appearance")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ PageBase {
|
|||||||
|
|
||||||
NavRow {
|
NavRow {
|
||||||
text: qsTr("AI chat")
|
text: qsTr("AI chat")
|
||||||
|
settingAnchor: "panels-sidebar-llm"
|
||||||
icon: "robot_2"
|
icon: "robot_2"
|
||||||
first: true
|
first: true
|
||||||
last: true
|
last: true
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ PageBase {
|
|||||||
spacing: Tokens.spacing.small
|
spacing: Tokens.spacing.small
|
||||||
|
|
||||||
IconTextButton {
|
IconTextButton {
|
||||||
|
|
||||||
|
property string settingAnchor: "style-wallpapers"
|
||||||
|
|
||||||
enabled: Config.background.enabled
|
enabled: Config.background.enabled
|
||||||
horizontalPadding: Tokens.padding.extraLarge
|
horizontalPadding: Tokens.padding.extraLarge
|
||||||
icon: "wallpaper"
|
icon: "wallpaper"
|
||||||
@@ -51,6 +54,9 @@ PageBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
IconTextButton {
|
IconTextButton {
|
||||||
|
|
||||||
|
property string settingAnchor: "style-colors-fonts"
|
||||||
|
|
||||||
enabled: Config.background.enabled
|
enabled: Config.background.enabled
|
||||||
horizontalPadding: Tokens.padding.extraLarge
|
horizontalPadding: Tokens.padding.extraLarge
|
||||||
icon: "palette"
|
icon: "palette"
|
||||||
|
|||||||
@@ -1,66 +1,24 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
|
||||||
import "../../scripts/fzf.js" as Fzf
|
import "../../scripts/fzf.js" as Fzf
|
||||||
|
import "../../scripts/settings-indexer.js" as SettingsIndexer
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import ZShell
|
import ZShell
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
|
import qs.Paths
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var fzfFinder: null
|
property var inverted: ({})
|
||||||
|
property var ranking: ({})
|
||||||
readonly property var highlightCache: ({
|
readonly property var highlightCache: ({
|
||||||
"search": "",
|
"search": "",
|
||||||
"pattern": null
|
"pattern": null
|
||||||
})
|
})
|
||||||
property var inverted: ({})
|
property var fzfFinder: null
|
||||||
property var ranking: ({})
|
readonly property string cachePath: Paths.cache + "/settings-index.json"
|
||||||
|
|
||||||
function highlight(text: string, search: string, colour: color): string {
|
|
||||||
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
||||||
if (search.length === 0)
|
|
||||||
return escaped;
|
|
||||||
|
|
||||||
const cache = root.highlightCache;
|
|
||||||
if (search !== cache.search) {
|
|
||||||
const tokens = tokenize(search);
|
|
||||||
cache.search = search;
|
|
||||||
if (tokens.length === 0)
|
|
||||||
cache.pattern = null;
|
|
||||||
else {
|
|
||||||
const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
||||||
cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pattern = cache.pattern;
|
|
||||||
if (!pattern)
|
|
||||||
return escaped;
|
|
||||||
|
|
||||||
pattern.lastIndex = 0;
|
|
||||||
if (!pattern.test(escaped))
|
|
||||||
return escaped;
|
|
||||||
|
|
||||||
pattern.lastIndex = 0;
|
|
||||||
return escaped.replace(pattern, `<font color="${colour}">$1</font>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lookup(token: string): var {
|
|
||||||
const result = ({});
|
|
||||||
const exact = root.inverted[token] !== undefined;
|
|
||||||
const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
|
|
||||||
for (const key of keys) {
|
|
||||||
const rank = root.ranking[key] ?? ({});
|
|
||||||
for (const id of root.inverted[key]) {
|
|
||||||
const w = rank[id] ?? 0.1;
|
|
||||||
if (result[id] === undefined || w > result[id])
|
|
||||||
result[id] = w;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function query(search: string): list<QtObject> {
|
function query(search: string): list<QtObject> {
|
||||||
const tokens = root.tokenize(search);
|
const tokens = root.tokenize(search);
|
||||||
@@ -70,7 +28,7 @@ Singleton {
|
|||||||
const scores = ({});
|
const scores = ({});
|
||||||
const hitCounts = ({});
|
const hitCounts = ({});
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
const matches = root.lookup(token);
|
const matches = root.lookup(token); // { id: weight }
|
||||||
for (const id in matches) {
|
for (const id in matches) {
|
||||||
scores[id] = (scores[id] ?? 0) + matches[id];
|
scores[id] = (scores[id] ?? 0) + matches[id];
|
||||||
hitCounts[id] = (hitCounts[id] ?? 0) + 1;
|
hitCounts[id] = (hitCounts[id] ?? 0) + 1;
|
||||||
@@ -103,13 +61,76 @@ Singleton {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lookup(token: string): var {
|
||||||
|
const result = ({});
|
||||||
|
const exact = root.inverted[token] !== undefined;
|
||||||
|
const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
|
||||||
|
for (const key of keys) {
|
||||||
|
const rank = root.ranking[key] ?? ({});
|
||||||
|
for (const id of root.inverted[key]) {
|
||||||
|
const w = rank[id] ?? 0.1;
|
||||||
|
if (result[id] === undefined || w > result[id])
|
||||||
|
result[id] = w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function tokenize(text: string): var {
|
function tokenize(text: string): var {
|
||||||
return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0);
|
return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function highlight(text: string, search: string, colour: color): string {
|
||||||
|
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
if (search.length === 0)
|
||||||
|
return escaped;
|
||||||
|
|
||||||
|
const cache = root.highlightCache;
|
||||||
|
if (search !== cache.search) {
|
||||||
|
const tokens = root.tokenize(search);
|
||||||
|
cache.search = search;
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
cache.pattern = null;
|
||||||
|
} else {
|
||||||
|
const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||||
|
cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = cache.pattern;
|
||||||
|
if (!pattern)
|
||||||
|
return escaped;
|
||||||
|
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
if (!pattern.test(escaped))
|
||||||
|
return escaped;
|
||||||
|
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
return escaped.replace(pattern, `<font color="${colour}">$1</font>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadIndex(): var {
|
||||||
|
const revision = ZUtils.gitRevision();
|
||||||
|
const cached = ZUtils.readTextFile(cachePath);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cached);
|
||||||
|
if (parsed.version === 3 && revision && parsed.revision === revision && parsed.locale === Qt.locale().name)
|
||||||
|
return parsed;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
const data = SettingsIndexer.buildIndex(`${Quickshell.shellDir}/Modules/Settings`, p => ZUtils.readTextFile(p), (d, s) => ZUtils.listFiles(d, s), (ctx, text) => qsTranslate(ctx, text));
|
||||||
|
data.revision = revision;
|
||||||
|
data.locale = Qt.locale().name;
|
||||||
|
ZUtils.writeTextFile(cachePath, JSON.stringify(data));
|
||||||
|
console.log(`SettingsSearcher: indexed ${data.entries.length} settings (revision ${revision || "unknown"})`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(ZUtils.settingsIndex());
|
const data = root.loadIndex();
|
||||||
entries.model = data.entries;
|
entries.model = data.entries;
|
||||||
root.inverted = data.inverted ?? {};
|
root.inverted = data.inverted ?? {};
|
||||||
root.ranking = data.ranking ?? {};
|
root.ranking = data.ranking ?? {};
|
||||||
@@ -122,6 +143,7 @@ Singleton {
|
|||||||
limit: 25
|
limit: 25
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.warn("SettingsSearcher: failed to build settings index:", e);
|
||||||
entries.model = [];
|
entries.model = [];
|
||||||
root.inverted = {};
|
root.inverted = {};
|
||||||
root.ranking = {};
|
root.ranking = {};
|
||||||
@@ -132,22 +154,25 @@ Singleton {
|
|||||||
Variants {
|
Variants {
|
||||||
id: entries
|
id: entries
|
||||||
|
|
||||||
SettingEntry {
|
SettingEntry {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
component SettingEntry: QtObject {
|
component SettingEntry: QtObject {
|
||||||
readonly property string anchor: modelData.anchor ?? ""
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property int pageIdx: modelData.pageIdx
|
||||||
|
readonly property var subPath: modelData.subPath
|
||||||
|
readonly property var targetSubPath: modelData.targetSubPath ?? []
|
||||||
readonly property var crumbIcons: modelData.crumbIcons
|
readonly property var crumbIcons: modelData.crumbIcons
|
||||||
readonly property var crumbLabels: modelData.crumbLabels
|
readonly property var crumbLabels: modelData.crumbLabels
|
||||||
readonly property bool isToggle: togglePath.length > 0
|
|
||||||
required property var modelData
|
|
||||||
readonly property int pageIdx: modelData.pageIdx
|
|
||||||
readonly property string section: modelData.section ?? ""
|
|
||||||
readonly property var subPath: modelData.subPath
|
|
||||||
readonly property string subtext: modelData.subtext ?? ""
|
|
||||||
readonly property string title: modelData.title
|
readonly property string title: modelData.title
|
||||||
|
readonly property string section: modelData.section ?? ""
|
||||||
|
readonly property string subtext: modelData.subtext ?? ""
|
||||||
|
readonly property string anchor: modelData.anchor ?? ""
|
||||||
|
readonly property string icon: modelData.icon ?? ""
|
||||||
|
|
||||||
readonly property string togglePath: modelData.togglePath ?? ""
|
readonly property string togglePath: modelData.togglePath ?? ""
|
||||||
|
readonly property bool isToggle: togglePath.length > 0
|
||||||
readonly property bool toggleValue: {
|
readonly property bool toggleValue: {
|
||||||
if (!isToggle)
|
if (!isToggle)
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1 +1,24 @@
|
|||||||
|
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter)
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
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)
|
||||||
|
pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET)
|
||||||
|
pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0)
|
||||||
|
|
||||||
|
if(NOT Cava_FOUND)
|
||||||
|
pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include(cmake/sensorslib.cmake)
|
||||||
|
|
||||||
|
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
|
||||||
|
qt_standard_project_setup(REQUIRES 6.9)
|
||||||
|
|
||||||
|
include(cmake/pch.cmake)
|
||||||
|
include(cmake/qml-module.cmake)
|
||||||
|
|
||||||
|
add_library(zshell-util INTERFACE)
|
||||||
|
target_include_directories(zshell-util INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||||
|
|
||||||
add_subdirectory(ZShell)
|
add_subdirectory(ZShell)
|
||||||
|
|||||||
@@ -1,55 +1,3 @@
|
|||||||
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter)
|
|
||||||
find_package(PkgConfig REQUIRED)
|
|
||||||
find_library(SENSORS_LIBRARY NAMES sensors REQUIRED)
|
|
||||||
find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED)
|
|
||||||
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)
|
|
||||||
pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET)
|
|
||||||
pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0)
|
|
||||||
|
|
||||||
if(NOT Cava_FOUND)
|
|
||||||
pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
if(NOT TARGET Sensors::Sensors)
|
|
||||||
add_library(Sensors::Sensors UNKNOWN IMPORTED)
|
|
||||||
set_target_properties(Sensors::Sensors PROPERTIES
|
|
||||||
IMPORTED_LOCATION "${SENSORS_LIBRARY}"
|
|
||||||
INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}"
|
|
||||||
)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
|
|
||||||
qt_standard_project_setup(REQUIRES 6.9)
|
|
||||||
|
|
||||||
function(qml_module arg_TARGET)
|
|
||||||
cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;LIBRARIES;RESOURCES")
|
|
||||||
qt_add_qml_module(${arg_TARGET}
|
|
||||||
URI ${arg_URI}
|
|
||||||
VERSION 1.0
|
|
||||||
SOURCES ${arg_SOURCES}
|
|
||||||
RESOURCES ${arg_RESOURCES}
|
|
||||||
)
|
|
||||||
|
|
||||||
qt_query_qml_module(${arg_TARGET}
|
|
||||||
URI module_uri
|
|
||||||
VERSION module_version
|
|
||||||
PLUGIN_TARGET module_plugin_target
|
|
||||||
TARGET_PATH module_target_path
|
|
||||||
QMLDIR module_qmldir
|
|
||||||
TYPEINFO module_typeinfo
|
|
||||||
)
|
|
||||||
set(module_dir "${INSTALL_QMLDIR}/${module_target_path}")
|
|
||||||
install(TARGETS ${arg_TARGET} LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
|
|
||||||
install(TARGETS "${module_plugin_target}" LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
|
|
||||||
install(FILES "${module_qmldir}" DESTINATION "${module_dir}")
|
|
||||||
install(FILES "${module_typeinfo}" DESTINATION "${module_dir}")
|
|
||||||
target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES})
|
|
||||||
endfunction()
|
|
||||||
|
|
||||||
set_source_files_properties("${SETTINGS_INDEX_JSON}" PROPERTIES QT_RESOURCE_ALIAS "settings-index.json")
|
|
||||||
|
|
||||||
qml_module(ZShell
|
qml_module(ZShell
|
||||||
URI ZShell
|
URI ZShell
|
||||||
SOURCES
|
SOURCES
|
||||||
@@ -60,8 +8,6 @@ qml_module(ZShell
|
|||||||
toaster.hpp toaster.cpp
|
toaster.hpp toaster.cpp
|
||||||
qalculator.hpp qalculator.cpp
|
qalculator.hpp qalculator.cpp
|
||||||
zutils.hpp zutils.cpp
|
zutils.hpp zutils.cpp
|
||||||
RESOURCES
|
|
||||||
"${SETTINGS_INDEX_JSON}"
|
|
||||||
LIBRARIES
|
LIBRARIES
|
||||||
Qt::Gui
|
Qt::Gui
|
||||||
Qt::Quick
|
Qt::Quick
|
||||||
@@ -69,9 +15,13 @@ qml_module(ZShell
|
|||||||
Qt::Sql
|
Qt::Sql
|
||||||
Qt::DBus
|
Qt::DBus
|
||||||
PkgConfig::Qalculate
|
PkgConfig::Qalculate
|
||||||
|
zshell-util
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(ZShell PRIVATE ZSHELL_VERSION="${VERSION}")
|
target_compile_definitions(ZShell PRIVATE
|
||||||
|
ZSHELL_VERSION="${VERSION}"
|
||||||
|
GIT_REVISION="${GIT_REVISION}"
|
||||||
|
)
|
||||||
|
|
||||||
add_subdirectory(Models)
|
add_subdirectory(Models)
|
||||||
add_subdirectory(Internal)
|
add_subdirectory(Internal)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class Llm : public ConfigObject {
|
|||||||
CFG_PROPERTY(QString, model, "")
|
CFG_PROPERTY(QString, model, "")
|
||||||
CFG_PROPERTY(double, temperature, 0.7)
|
CFG_PROPERTY(double, temperature, 0.7)
|
||||||
CFG_PROPERTY(bool, tools, true)
|
CFG_PROPERTY(bool, tools, true)
|
||||||
|
CFG_PROPERTY(bool, enabled, true)
|
||||||
CONFIG_SUBOBJECT(LlmAppearance, appearance)
|
CONFIG_SUBOBJECT(LlmAppearance, appearance)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ qml_module(ZShell-llm
|
|||||||
chat.hpp chat.cpp
|
chat.hpp chat.cpp
|
||||||
chatstore.hpp chatstore.cpp
|
chatstore.hpp chatstore.cpp
|
||||||
codehighlighter.hpp codehighlighter.cpp
|
codehighlighter.hpp codehighlighter.cpp
|
||||||
|
filetool.hpp filetool.cpp
|
||||||
generation.hpp generation.cpp
|
generation.hpp generation.cpp
|
||||||
llmclient.hpp llmclient.cpp
|
llmclient.hpp llmclient.cpp
|
||||||
markdownblock.hpp
|
markdownblock.hpp
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "chat.hpp"
|
#include "chat.hpp"
|
||||||
|
|
||||||
#include "config.hpp"
|
#include "config.hpp"
|
||||||
|
#include "filetool.hpp"
|
||||||
#include "llm.hpp"
|
#include "llm.hpp"
|
||||||
#include "llmclient.hpp"
|
#include "llmclient.hpp"
|
||||||
#include "webfetchtool.hpp"
|
#include "webfetchtool.hpp"
|
||||||
@@ -17,6 +18,7 @@ Chat::Chat(QObject* parent)
|
|||||||
|
|
||||||
m_store->setLlmClient(m_client);
|
m_store->setLlmClient(m_client);
|
||||||
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
|
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
|
||||||
|
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
|
||||||
|
|
||||||
const auto* llm = config::Config::instance()->llm();
|
const auto* llm = config::Config::instance()->llm();
|
||||||
m_client->setEndpoint(llm->endpoint());
|
m_client->setEndpoint(llm->endpoint());
|
||||||
@@ -38,7 +40,6 @@ Chat::Chat(QObject* parent)
|
|||||||
});
|
});
|
||||||
|
|
||||||
connect(m_client, &LlmClient::busyChanged, this, [this]() {
|
connect(m_client, &LlmClient::busyChanged, this, [this]() {
|
||||||
// A fresh run supersedes the previous error.
|
|
||||||
if (m_client->busy() && !m_lastError.isEmpty()) {
|
if (m_client->busy() && !m_lastError.isEmpty()) {
|
||||||
m_lastError.clear();
|
m_lastError.clear();
|
||||||
Q_EMIT lastErrorChanged();
|
Q_EMIT lastErrorChanged();
|
||||||
@@ -137,6 +138,7 @@ QString Chat::streamingChatId() const {
|
|||||||
return m_client->streamingChatId();
|
return m_client->streamingChatId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Chat* Chat::s_instance = nullptr;
|
Chat* Chat::s_instance = nullptr;
|
||||||
|
|
||||||
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
|
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
|
||||||
@@ -158,6 +160,10 @@ void Chat::refreshModels() {
|
|||||||
m_client->refreshModels();
|
m_client->refreshModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Chat::refreshFromServer() {
|
||||||
|
m_client->refreshFromServer();
|
||||||
|
}
|
||||||
|
|
||||||
void Chat::selectModel(const QString& id) {
|
void Chat::selectModel(const QString& id) {
|
||||||
if (id.isEmpty()) return;
|
if (id.isEmpty()) return;
|
||||||
m_client->setModel(id);
|
m_client->setModel(id);
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ namespace ZShell::llm {
|
|||||||
|
|
||||||
class LlmClient;
|
class LlmClient;
|
||||||
|
|
||||||
// QML-facing facade. Persistence lives in ChatStore, network streaming in
|
|
||||||
// LlmClient; this class only wires them together and exposes the
|
|
||||||
// application-wide state.
|
|
||||||
class Chat : public QObject {
|
class Chat : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
QML_ELEMENT
|
QML_ELEMENT
|
||||||
@@ -55,6 +52,7 @@ class Chat : public QObject {
|
|||||||
Q_INVOKABLE void stop();
|
Q_INVOKABLE void stop();
|
||||||
Q_INVOKABLE void dismissError();
|
Q_INVOKABLE void dismissError();
|
||||||
Q_INVOKABLE void refreshModels();
|
Q_INVOKABLE void refreshModels();
|
||||||
|
Q_INVOKABLE void refreshFromServer();
|
||||||
Q_INVOKABLE void selectModel(const QString& id);
|
Q_INVOKABLE void selectModel(const QString& id);
|
||||||
|
|
||||||
static Chat* create(QQmlEngine*, QJSEngine*);
|
static Chat* create(QQmlEngine*, QJSEngine*);
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ class CodeHighlighter : public QObject {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
struct State {
|
struct State {
|
||||||
bool bad = false; // permanent failure, do not retry
|
bool bad = false;
|
||||||
void* lib = nullptr;
|
void* lib = nullptr;
|
||||||
const void* lang = nullptr; // const TSLanguage*
|
const void* lang = nullptr;
|
||||||
void* query = nullptr; // TSQuery*
|
void* query = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
[[nodiscard]] static const QHash<QString, QString>& aliases();
|
[[nodiscard]] static const QHash<QString, QString>& aliases();
|
||||||
@@ -59,15 +59,14 @@ class CodeHighlighter : public QObject {
|
|||||||
const QVariantList& spans) const;
|
const QVariantList& spans) const;
|
||||||
|
|
||||||
struct SpanCacheEntry {
|
struct SpanCacheEntry {
|
||||||
QString code; // re-compared on lookup; a hash collision can
|
QString code;
|
||||||
// never deliver the wrong spans
|
|
||||||
QVariantList spans;
|
QVariantList spans;
|
||||||
};
|
};
|
||||||
|
|
||||||
mutable QHash<QString, std::shared_ptr<const State>> m_states;
|
mutable QHash<QString, std::shared_ptr<const State>> m_states;
|
||||||
mutable QMutex m_stateMutex;
|
mutable QMutex m_stateMutex;
|
||||||
mutable QHash<QString, SpanCacheEntry> m_spanCache;
|
mutable QHash<QString, SpanCacheEntry> m_spanCache;
|
||||||
mutable QStringList m_spanCacheOrder; // LRU order, oldest first
|
mutable QStringList m_spanCacheOrder;
|
||||||
mutable int m_spanCacheBytes = 0;
|
mutable int m_spanCacheBytes = 0;
|
||||||
mutable QMutex m_cacheMutex;
|
mutable QMutex m_cacheMutex;
|
||||||
static CodeHighlighter* s_instance;
|
static CodeHighlighter* s_instance;
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
#include "filetool.hpp"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
|
||||||
|
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) {}
|
||||||
|
|
||||||
|
QString FileReadTool::name() const {
|
||||||
|
return QStringLiteral("readfile");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FileReadTool::description() const {
|
||||||
|
return QStringLiteral(
|
||||||
|
"Read a text file from the local filesystem and return part of "
|
||||||
|
"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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject FileReadTool::parameters() const {
|
||||||
|
QJsonObject path;
|
||||||
|
path[QStringLiteral("type")] = QStringLiteral("string");
|
||||||
|
path[QStringLiteral("description")] =
|
||||||
|
QStringLiteral("Path of the file to read");
|
||||||
|
|
||||||
|
QJsonObject offset;
|
||||||
|
offset[QStringLiteral("type")] = QStringLiteral("integer");
|
||||||
|
offset[QStringLiteral("minimum")] = 0;
|
||||||
|
offset[QStringLiteral("description")] =
|
||||||
|
QStringLiteral("Byte offset to start reading from. Defaults to 0.");
|
||||||
|
|
||||||
|
QJsonObject limit;
|
||||||
|
limit[QStringLiteral("type")] = QStringLiteral("integer");
|
||||||
|
limit[QStringLiteral("minimum")] = 1;
|
||||||
|
limit[QStringLiteral("description")] = QStringLiteral(
|
||||||
|
"Maximum number of characters to return. Defaults "
|
||||||
|
"to a value that fits the model's context window.");
|
||||||
|
|
||||||
|
QJsonObject properties;
|
||||||
|
properties[QStringLiteral("path")] = path;
|
||||||
|
properties[QStringLiteral("offset")] = offset;
|
||||||
|
properties[QStringLiteral("limit")] = limit;
|
||||||
|
|
||||||
|
QJsonObject schema;
|
||||||
|
schema[QStringLiteral("type")] = QStringLiteral("object");
|
||||||
|
schema[QStringLiteral("properties")] = properties;
|
||||||
|
QJsonArray required;
|
||||||
|
required.append(QStringLiteral("path"));
|
||||||
|
schema[QStringLiteral("required")] = required;
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileReadTool::execute(
|
||||||
|
const QString& toolCallId,
|
||||||
|
const QJsonObject& args,
|
||||||
|
int outputBudgetChars,
|
||||||
|
std::function<void(const QJsonObject&)> done) {
|
||||||
|
Q_UNUSED(toolCallId);
|
||||||
|
const QString path = args[QStringLiteral("path")].toString().trimmed();
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
done(makeError(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 limit =
|
||||||
|
qBound(1, args[QStringLiteral("limit")].toInt(budget), budget);
|
||||||
|
|
||||||
|
QFile file(path);
|
||||||
|
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)));
|
||||||
|
} else {
|
||||||
|
done(makeError(QStringLiteral("Cannot open file: %1").arg(path)));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const qint64 fileSize = file.size();
|
||||||
|
if (fileSize == 0) {
|
||||||
|
file.close();
|
||||||
|
done(makeOutput(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)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!file.seek(offset)) {
|
||||||
|
file.close();
|
||||||
|
done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const qint64 wanted = qint64(limit) * 4 + 16;
|
||||||
|
const QByteArray chunk = file.read(qMin(wanted, fileSize - offset));
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (chunk.contains('\0')) {
|
||||||
|
done(makeError(
|
||||||
|
QStringLiteral("Binary files are not supported: %1").arg(path)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString content = QString::fromUtf8(chunk);
|
||||||
|
if (content.size() <= limit) {
|
||||||
|
done(makeOutput(content));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString head = content.left(limit);
|
||||||
|
const int nextOffset = offset + head.toUtf8().size();
|
||||||
|
const QString output =
|
||||||
|
head +
|
||||||
|
QStringLiteral(
|
||||||
|
"\n\n[... truncated: showing %1 characters (bytes %2-%3 of a "
|
||||||
|
"%4 byte file). Continue with offset=%5.]")
|
||||||
|
.arg(limit)
|
||||||
|
.arg(offset)
|
||||||
|
.arg(nextOffset)
|
||||||
|
.arg(fileSize)
|
||||||
|
.arg(nextOffset);
|
||||||
|
done(makeOutput(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "tool.hpp"
|
||||||
|
|
||||||
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
class FileReadTool : public LlmTool {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit FileReadTool(QObject* parent = nullptr);
|
||||||
|
|
||||||
|
QString name() const override;
|
||||||
|
QString description() const override;
|
||||||
|
QJsonObject parameters() const override;
|
||||||
|
void execute(
|
||||||
|
const QString& toolCallId,
|
||||||
|
const QJsonObject& args,
|
||||||
|
int outputBudgetChars,
|
||||||
|
std::function<void(const QJsonObject& result)> done) override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
#include "generation.hpp"
|
#include "generation.hpp"
|
||||||
|
|
||||||
|
#include "llmclient.hpp"
|
||||||
|
#include "messagemodel.hpp"
|
||||||
|
#include "session.hpp"
|
||||||
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
|
||||||
namespace ZShell::llm {
|
namespace ZShell::llm {
|
||||||
@@ -46,7 +50,7 @@ QString ChatGeneration::reasoning() const {
|
|||||||
bool ChatGeneration::reasoningActive() const {
|
bool ChatGeneration::reasoningActive() const {
|
||||||
if (!m_streaming) return false;
|
if (!m_streaming) return false;
|
||||||
if (!content().isEmpty()) return false;
|
if (!content().isEmpty()) return false;
|
||||||
return !hasRunningTool();
|
return !hasRunningTool() && !hasPendingTool();
|
||||||
}
|
}
|
||||||
|
|
||||||
qint64 ChatGeneration::reasoningElapsedMs() const {
|
qint64 ChatGeneration::reasoningElapsedMs() const {
|
||||||
@@ -87,6 +91,45 @@ bool ChatGeneration::hasRunningTool() const {
|
|||||||
return false;
|
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;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatGeneration::setApprovalPending(bool value) {
|
||||||
|
if (m_approvalPending == value) return;
|
||||||
|
m_approvalPending = value;
|
||||||
|
Q_EMIT toolApprovalPendingChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatGeneration::approveTools() {
|
||||||
|
if (auto* llmClient = client()) llmClient->approveTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatGeneration::denyTools() {
|
||||||
|
if (auto* llmClient = client()) llmClient->denyTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
void ChatGeneration::updateReasoningActive() {
|
void ChatGeneration::updateReasoningActive() {
|
||||||
const bool active = reasoningActive();
|
const bool active = reasoningActive();
|
||||||
if (m_reasoningActive == active) return;
|
if (m_reasoningActive == active) return;
|
||||||
@@ -177,7 +220,7 @@ LlmSegment* ChatGeneration::beginToolCall(
|
|||||||
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
|
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
|
||||||
segment->setName(name);
|
segment->setName(name);
|
||||||
segment->setToolCallId(toolCallId);
|
segment->setToolCallId(toolCallId);
|
||||||
segment->setStatus(LlmSegment::Status::Running);
|
segment->setStatus(LlmSegment::Status::Pending);
|
||||||
segment->begin();
|
segment->begin();
|
||||||
addSegment(segment);
|
addSegment(segment);
|
||||||
Q_EMIT toolStateChanged();
|
Q_EMIT toolStateChanged();
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
#include <QVariantList>
|
||||||
#include <QtQml>
|
#include <QtQml>
|
||||||
|
|
||||||
namespace ZShell::llm {
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
class LlmClient;
|
||||||
|
|
||||||
class ChatGeneration : public QObject {
|
class ChatGeneration : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
QML_ELEMENT
|
QML_ELEMENT
|
||||||
@@ -33,6 +36,12 @@ class ChatGeneration : public QObject {
|
|||||||
QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
|
QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
|
||||||
segmentsChanged)
|
segmentsChanged)
|
||||||
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged)
|
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged)
|
||||||
|
Q_PROPERTY(
|
||||||
|
bool toolApprovalPending READ toolApprovalPending NOTIFY
|
||||||
|
toolApprovalPendingChanged)
|
||||||
|
Q_PROPERTY(
|
||||||
|
QVariantList pendingToolCalls READ pendingToolCalls NOTIFY
|
||||||
|
toolApprovalPendingChanged)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
|
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
|
||||||
@@ -48,6 +57,14 @@ class ChatGeneration : public QObject {
|
|||||||
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
|
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
|
||||||
[[nodiscard]] int toolCallCount() const;
|
[[nodiscard]] int toolCallCount() const;
|
||||||
[[nodiscard]] bool hasRunningTool() 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();
|
||||||
|
Q_INVOKABLE void denyTools();
|
||||||
|
|
||||||
void setContent(const QString& value);
|
void setContent(const QString& value);
|
||||||
void appendContent(const QString& piece);
|
void appendContent(const QString& piece);
|
||||||
@@ -69,14 +86,17 @@ class ChatGeneration : public QObject {
|
|||||||
void streamingChanged();
|
void streamingChanged();
|
||||||
void toolStateChanged();
|
void toolStateChanged();
|
||||||
void segmentsChanged();
|
void segmentsChanged();
|
||||||
|
void toolApprovalPendingChanged();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void updateReasoningActive();
|
void updateReasoningActive();
|
||||||
|
LlmClient* client() const;
|
||||||
|
|
||||||
QTimer m_timer;
|
QTimer m_timer;
|
||||||
QList<LlmSegment*> m_segments;
|
QList<LlmSegment*> m_segments;
|
||||||
bool m_reasoningActive = false;
|
bool m_reasoningActive = false;
|
||||||
bool m_streaming = false;
|
bool m_streaming = false;
|
||||||
|
bool m_approvalPending = false;
|
||||||
qint64 m_timestamp;
|
qint64 m_timestamp;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,117 @@
|
|||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
|
#include <QRegularExpression>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
namespace ZShell::llm {
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kMinContextTokens = 512;
|
||||||
|
|
||||||
|
qsizetype jsonValueChars(const QJsonValue& value) {
|
||||||
|
switch (value.type()) {
|
||||||
|
case QJsonValue::String:
|
||||||
|
return value.toString().size();
|
||||||
|
case QJsonValue::Array: {
|
||||||
|
qsizetype total = 0;
|
||||||
|
for (const QJsonValue& item : value.toArray())
|
||||||
|
total += jsonValueChars(item);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
case QJsonValue::Object: {
|
||||||
|
qsizetype total = 0;
|
||||||
|
const QJsonObject obj = value.toObject();
|
||||||
|
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it)
|
||||||
|
total += jsonValueChars(it.value());
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int estimateMessageTokens(const QJsonObject& message) {
|
||||||
|
return int(jsonValueChars(message) / LlmTool::CharsPerToken) + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContextUnit {
|
||||||
|
QList<QJsonObject> messages;
|
||||||
|
int tokens = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
void shrinkUnitToBudget(QList<QJsonObject>& unit, int budgetTokens) {
|
||||||
|
static const QStringList kTextKeys = {
|
||||||
|
QStringLiteral("content"), QStringLiteral("reasoning_content")};
|
||||||
|
for (int pass = 0; pass < 8; ++pass) {
|
||||||
|
int tokens = 0;
|
||||||
|
for (const QJsonObject& message : unit)
|
||||||
|
tokens += estimateMessageTokens(message);
|
||||||
|
if (tokens <= budgetTokens) return;
|
||||||
|
qsizetype bestMessage = -1;
|
||||||
|
QString bestKey;
|
||||||
|
qsizetype bestLength = 0;
|
||||||
|
for (qsizetype i = 0; i < unit.size(); ++i) {
|
||||||
|
const QJsonObject message = unit.at(i);
|
||||||
|
for (const QString& key : kTextKeys) {
|
||||||
|
const QJsonValue value = message.value(key);
|
||||||
|
if (value.isString() && value.toString().size() > bestLength) {
|
||||||
|
bestMessage = i;
|
||||||
|
bestKey = key;
|
||||||
|
bestLength = value.toString().size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestMessage < 0) return;
|
||||||
|
QString text = unit.at(bestMessage).value(bestKey).toString();
|
||||||
|
text.truncate(qMax<qsizetype>(32, text.size() * 3 / 4));
|
||||||
|
const QString marker = QStringLiteral("\n[…]");
|
||||||
|
if (!text.endsWith(marker)) text += marker;
|
||||||
|
unit[bestMessage][bestKey] = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int estimateArrayTokens(const QJsonArray& messages) {
|
||||||
|
int total = 0;
|
||||||
|
for (const QJsonValue& value : messages)
|
||||||
|
total += estimateMessageTokens(value.toObject());
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int kToolResultStubChars = 512;
|
||||||
|
|
||||||
|
constexpr double kPromptEstimateDiscount = 10.0 / 12.0;
|
||||||
|
|
||||||
|
constexpr int kMinToolResultTokens = 128;
|
||||||
|
|
||||||
|
int completionReserve(int contextSize) {
|
||||||
|
return qBound(2048, contextSize / 8, 8192);
|
||||||
|
}
|
||||||
|
|
||||||
|
int promptBudgetTokens(int contextSize) {
|
||||||
|
return int(
|
||||||
|
(contextSize - completionReserve(contextSize)) *
|
||||||
|
kPromptEstimateDiscount);
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int kRefreshIntervalMs = 60'000;
|
||||||
|
|
||||||
|
constexpr int kProbeTimeoutMs = 2000;
|
||||||
|
|
||||||
|
int contextSizeFromErrorMessage(const QString& message) {
|
||||||
|
static const QRegularExpression re(
|
||||||
|
QLatin1String("available context size \\((\\d+)"));
|
||||||
|
const auto match = re.match(message);
|
||||||
|
if (match.hasMatch()) return match.captured(1).toInt();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
QString LlmClient::completionsPath(
|
QString LlmClient::completionsPath(
|
||||||
const QString& endpoint, const QString& subpath) {
|
const QString& endpoint, const QString& subpath) {
|
||||||
QString base = endpoint.trimmed();
|
QString base = endpoint.trimmed();
|
||||||
@@ -65,7 +171,12 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) {
|
|||||||
&ToolRegistry::enabledChanged,
|
&ToolRegistry::enabledChanged,
|
||||||
this,
|
this,
|
||||||
&LlmClient::toolsEnabledChanged);
|
&LlmClient::toolsEnabledChanged);
|
||||||
probeContextSize();
|
|
||||||
|
m_refreshTimer.setParent(this);
|
||||||
|
m_refreshTimer.setInterval(kRefreshIntervalMs);
|
||||||
|
connect(
|
||||||
|
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer);
|
||||||
|
m_refreshTimer.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
LlmClient::~LlmClient() {
|
LlmClient::~LlmClient() {
|
||||||
@@ -78,8 +189,8 @@ void LlmClient::setEndpoint(const QString& value) {
|
|||||||
if (m_endpoint == value) return;
|
if (m_endpoint == value) return;
|
||||||
m_endpoint = value;
|
m_endpoint = value;
|
||||||
Q_EMIT endpointChanged();
|
Q_EMIT endpointChanged();
|
||||||
probeContextSize();
|
m_refreshTimer.start();
|
||||||
if (m_model.isEmpty()) refreshModels();
|
refreshFromServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::setModel(const QString& value) {
|
void LlmClient::setModel(const QString& value) {
|
||||||
@@ -95,9 +206,11 @@ void LlmClient::setTemperature(double value) {
|
|||||||
|
|
||||||
void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
|
void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
|
||||||
if (m_busy || !session || !target) return;
|
if (m_busy || !session || !target) return;
|
||||||
|
refreshModels();
|
||||||
|
m_contextRetryUsed = false;
|
||||||
m_active = session;
|
m_active = session;
|
||||||
m_streaming = target;
|
m_streaming = target;
|
||||||
m_streaming->setStreaming(true);
|
target->setStreaming(true);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStreamingChatId(session->id());
|
setStreamingChatId(session->id());
|
||||||
|
|
||||||
@@ -125,12 +238,17 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
|
|||||||
m_round = 0;
|
m_round = 0;
|
||||||
m_contentMark = 0;
|
m_contentMark = 0;
|
||||||
m_reasoningMark = 0;
|
m_reasoningMark = 0;
|
||||||
|
setApprovalPending(false);
|
||||||
|
|
||||||
sendRound();
|
probeContextSize([this, session, target]() {
|
||||||
|
if (m_active != session || m_streaming != target) return;
|
||||||
|
sendRound();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::sendRound() {
|
void LlmClient::sendRound() {
|
||||||
if (!m_active || !m_streaming) return;
|
if (!m_active || !m_streaming) return;
|
||||||
|
probeContextSize();
|
||||||
m_finishReason.clear();
|
m_finishReason.clear();
|
||||||
m_callBuilders.clear();
|
m_callBuilders.clear();
|
||||||
m_callResults.clear();
|
m_callResults.clear();
|
||||||
@@ -146,9 +264,11 @@ void LlmClient::sendRound() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto* model = m_active->messagesModel();
|
ChatSession* active = m_active;
|
||||||
|
ChatGeneration* streaming = m_streaming;
|
||||||
|
const auto* model = active->messagesModel();
|
||||||
const int targetRow =
|
const int targetRow =
|
||||||
model->rowOf(qobject_cast<ChatMessage*>(m_streaming->parent()));
|
model->rowOf(qobject_cast<ChatMessage*>(streaming->parent()));
|
||||||
if (targetRow < 0) {
|
if (targetRow < 0) {
|
||||||
fail(QStringLiteral(
|
fail(QStringLiteral(
|
||||||
"Internal error: generation target is not in the "
|
"Internal error: generation target is not in the "
|
||||||
@@ -156,10 +276,38 @@ void LlmClient::sendRound() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QJsonArray messages = buildContextMessages(m_active, targetRow);
|
int transcriptTokens = 0;
|
||||||
|
for (const QJsonValue& value : m_transcript)
|
||||||
|
transcriptTokens += estimateMessageTokens(value.toObject());
|
||||||
|
QJsonArray messages =
|
||||||
|
buildContextMessages(m_active, targetRow, transcriptTokens);
|
||||||
for (const QJsonValue& value : m_transcript)
|
for (const QJsonValue& value : m_transcript)
|
||||||
messages.append(value);
|
messages.append(value);
|
||||||
|
|
||||||
|
if (m_contextSize > 0) {
|
||||||
|
const int promptLimit = promptBudgetTokens(m_contextSize);
|
||||||
|
while (estimateArrayTokens(messages) > promptLimit) {
|
||||||
|
bool stubbed = false;
|
||||||
|
for (qsizetype i = 0; i < messages.size(); ++i) {
|
||||||
|
QJsonObject message = messages.at(i).toObject();
|
||||||
|
if (message.value("role").toString() != QLatin1String("tool"))
|
||||||
|
continue;
|
||||||
|
const QString content = message.value("content").toString();
|
||||||
|
if (content.size() <= kToolResultStubChars) continue;
|
||||||
|
QString stub = content.left(kToolResultStubChars);
|
||||||
|
stub += QStringLiteral(
|
||||||
|
"\n[truncated to fit the context window; was "
|
||||||
|
"%1 characters]")
|
||||||
|
.arg(content.size());
|
||||||
|
message["content"] = stub;
|
||||||
|
messages[i] = message;
|
||||||
|
stubbed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!stubbed) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
QJsonObject body;
|
QJsonObject body;
|
||||||
QJsonObject streamOptions;
|
QJsonObject streamOptions;
|
||||||
streamOptions[QStringLiteral("include_usage")] = true;
|
streamOptions[QStringLiteral("include_usage")] = true;
|
||||||
@@ -199,73 +347,117 @@ void LlmClient::sendRound() {
|
|||||||
roundFinished();
|
roundFinished();
|
||||||
else if (error == QNetworkReply::OperationCanceledError)
|
else if (error == QNetworkReply::OperationCanceledError)
|
||||||
finishTurn();
|
finishTurn();
|
||||||
else
|
else {
|
||||||
fail(serverErrorMessage(responseBody, errorString));
|
const QString message =
|
||||||
|
serverErrorMessage(responseBody, errorString);
|
||||||
|
const int reported = contextSizeFromErrorMessage(message);
|
||||||
|
if (reported > 0 && reported != m_contextSize &&
|
||||||
|
!m_contextRetryUsed) {
|
||||||
|
qWarning() << "LlmClient: prompt overflow, server reports"
|
||||||
|
<< reported << "tokens; retrying round";
|
||||||
|
setContextSize(reported);
|
||||||
|
m_contextRetryUsed = true;
|
||||||
|
sendRound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail(message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
QJsonArray LlmClient::buildContextMessages(
|
QJsonArray LlmClient::buildContextMessages(
|
||||||
ChatSession* session, int stopBeforeRow) const {
|
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const {
|
||||||
const auto* model = session->messagesModel();
|
const auto* model = session->messagesModel();
|
||||||
QJsonArray messages;
|
QList<ContextUnit> units;
|
||||||
for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) {
|
for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) {
|
||||||
const auto* message = model->at(row);
|
const auto* message = model->at(row);
|
||||||
const auto* generation = message->activeGeneration();
|
const auto* generation = message->activeGeneration();
|
||||||
if (!generation) continue;
|
if (!generation) continue;
|
||||||
|
|
||||||
|
ContextUnit unit;
|
||||||
if (message->role() == ChatMessage::Role::User) {
|
if (message->role() == ChatMessage::Role::User) {
|
||||||
QJsonObject user;
|
QJsonObject user;
|
||||||
user[QStringLiteral("role")] = QStringLiteral("user");
|
user[QStringLiteral("role")] = QStringLiteral("user");
|
||||||
user[QStringLiteral("content")] = generation->content();
|
user[QStringLiteral("content")] = generation->content();
|
||||||
messages.append(user);
|
unit.messages.append(user);
|
||||||
continue;
|
} else {
|
||||||
}
|
QList<const LlmSegment*> toolSegments;
|
||||||
|
for (const auto* segment : generation->segments())
|
||||||
|
if (segment->type() == LlmSegment::Type::ToolCall)
|
||||||
|
toolSegments.append(segment);
|
||||||
|
|
||||||
QList<const LlmSegment*> toolSegments;
|
QJsonObject assistant;
|
||||||
for (const auto* segment : generation->segments())
|
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
|
||||||
if (segment->type() == LlmSegment::Type::ToolCall)
|
if (toolSegments.isEmpty())
|
||||||
toolSegments.append(segment);
|
assistant[QStringLiteral("content")] = generation->content();
|
||||||
|
else if (!generation->content().isEmpty())
|
||||||
QJsonObject assistant;
|
assistant[QStringLiteral("content")] = generation->content();
|
||||||
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
|
if (!generation->reasoning().isEmpty())
|
||||||
if (toolSegments.isEmpty())
|
assistant[QStringLiteral("reasoning_content")] =
|
||||||
assistant[QStringLiteral("content")] = generation->content();
|
generation->reasoning();
|
||||||
else if (!generation->content().isEmpty())
|
if (!toolSegments.isEmpty()) {
|
||||||
assistant[QStringLiteral("content")] = generation->content();
|
QJsonArray calls;
|
||||||
if (!generation->reasoning().isEmpty())
|
for (const auto* segment : toolSegments) {
|
||||||
assistant[QStringLiteral("reasoning_content")] =
|
QJsonObject function;
|
||||||
generation->reasoning();
|
function[QStringLiteral("name")] = segment->name();
|
||||||
|
function[QStringLiteral("arguments")] =
|
||||||
if (toolSegments.isEmpty()) {
|
segment->arguments();
|
||||||
messages.append(assistant);
|
QJsonObject call;
|
||||||
continue;
|
call[QStringLiteral("id")] = segment->toolCallId();
|
||||||
}
|
call[QStringLiteral("type")] = QStringLiteral("function");
|
||||||
QJsonArray calls;
|
call[QStringLiteral("function")] = function;
|
||||||
for (const auto* segment : toolSegments) {
|
calls.append(call);
|
||||||
QJsonObject function;
|
}
|
||||||
function[QStringLiteral("name")] = segment->name();
|
assistant[QStringLiteral("tool_calls")] = calls;
|
||||||
function[QStringLiteral("arguments")] = segment->arguments();
|
}
|
||||||
QJsonObject call;
|
unit.messages.append(assistant);
|
||||||
call[QStringLiteral("id")] = segment->toolCallId();
|
for (const auto* segment : toolSegments) {
|
||||||
call[QStringLiteral("type")] = QStringLiteral("function");
|
QJsonObject toolMessage;
|
||||||
call[QStringLiteral("function")] = function;
|
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
|
||||||
calls.append(call);
|
toolMessage[QStringLiteral("tool_call_id")] =
|
||||||
}
|
segment->toolCallId();
|
||||||
assistant[QStringLiteral("tool_calls")] = calls;
|
toolMessage[QStringLiteral("content")] = segment->result();
|
||||||
messages.append(assistant);
|
unit.messages.append(toolMessage);
|
||||||
for (const auto* segment : toolSegments) {
|
}
|
||||||
QJsonObject toolMessage;
|
|
||||||
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
|
|
||||||
toolMessage[QStringLiteral("tool_call_id")] = segment->toolCallId();
|
|
||||||
toolMessage[QStringLiteral("content")] = segment->result();
|
|
||||||
messages.append(toolMessage);
|
|
||||||
}
|
}
|
||||||
|
for (const QJsonObject& messageJson : unit.messages)
|
||||||
|
unit.tokens += estimateMessageTokens(messageJson);
|
||||||
|
units.append(unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (m_contextSize > 0) {
|
||||||
|
int budget = m_contextSize - completionReserve(m_contextSize) -
|
||||||
|
extraReserveTokens;
|
||||||
|
budget = qMax(budget, kMinContextTokens);
|
||||||
|
|
||||||
|
int total = 0;
|
||||||
|
for (const ContextUnit& unit : units)
|
||||||
|
total += unit.tokens;
|
||||||
|
while (units.size() > 1 && total > budget) {
|
||||||
|
total -= units.first().tokens;
|
||||||
|
units.removeFirst();
|
||||||
|
}
|
||||||
|
while (
|
||||||
|
units.size() > 1 &&
|
||||||
|
units.first().messages.first()[QStringLiteral("role")].toString() ==
|
||||||
|
QLatin1String("assistant")) {
|
||||||
|
total -= units.first().tokens;
|
||||||
|
units.removeFirst();
|
||||||
|
}
|
||||||
|
if (units.size() == 1 && units.first().tokens > budget)
|
||||||
|
shrinkUnitToBudget(units.first().messages, budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray messages;
|
||||||
|
for (const ContextUnit& unit : units)
|
||||||
|
for (const QJsonObject& messageJson : unit.messages)
|
||||||
|
messages.append(messageJson);
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::applyToolCallDelta(const QJsonObject& call) {
|
void LlmClient::applyToolCallDelta(const QJsonObject& call) {
|
||||||
if (!m_streaming) return;
|
ChatGeneration* streaming = m_streaming;
|
||||||
|
if (!streaming) return;
|
||||||
const int index = call[QStringLiteral("index")].toInt(-1);
|
const int index = call[QStringLiteral("index")].toInt(-1);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
while (m_callBuilders.size() <= index)
|
while (m_callBuilders.size() <= index)
|
||||||
@@ -281,8 +473,8 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) {
|
|||||||
if (!arguments.isEmpty()) builder.arguments += arguments;
|
if (!arguments.isEmpty()) builder.arguments += arguments;
|
||||||
|
|
||||||
if (!builder.segment) {
|
if (!builder.segment) {
|
||||||
m_streaming->closeOpenSegments();
|
streaming->closeOpenSegments();
|
||||||
builder.segment = m_streaming->beginToolCall(builder.name, builder.id);
|
builder.segment = streaming->beginToolCall(builder.name, builder.id);
|
||||||
}
|
}
|
||||||
builder.segment->setName(builder.name);
|
builder.segment->setName(builder.name);
|
||||||
builder.segment->setToolCallId(builder.id);
|
builder.segment->setToolCallId(builder.id);
|
||||||
@@ -291,12 +483,27 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) {
|
|||||||
|
|
||||||
void LlmClient::roundFinished() {
|
void LlmClient::roundFinished() {
|
||||||
if (m_roundDone || !m_streaming) return;
|
if (m_roundDone || !m_streaming) return;
|
||||||
|
ChatGeneration* streaming = m_streaming;
|
||||||
m_roundDone = true;
|
m_roundDone = true;
|
||||||
|
|
||||||
bool hasCalls = false;
|
bool hasCalls = false;
|
||||||
for (const auto& builder : m_callBuilders)
|
for (const auto& builder : m_callBuilders)
|
||||||
if (builder.seen) hasCalls = true;
|
if (builder.seen) hasCalls = true;
|
||||||
if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) {
|
if (!hasCalls) {
|
||||||
|
finishTurn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m_finishReason != QLatin1String("tool_calls")) {
|
||||||
|
qWarning() << "LlmClient: stream ended" << m_finishReason
|
||||||
|
<< "with incomplete tool calls, ending turn";
|
||||||
|
for (const auto& builder : m_callBuilders) {
|
||||||
|
if (!builder.seen || !builder.segment) continue;
|
||||||
|
builder.segment->finishTool(
|
||||||
|
QStringLiteral(
|
||||||
|
"Stream truncated before the tool call "
|
||||||
|
"completed"),
|
||||||
|
false);
|
||||||
|
}
|
||||||
finishTurn();
|
finishTurn();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -306,10 +513,10 @@ void LlmClient::roundFinished() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
m_streaming->closeOpenSegments();
|
streaming->closeOpenSegments();
|
||||||
|
|
||||||
const QString content = m_streaming->content();
|
const QString content = streaming->content();
|
||||||
const QString reasoning = m_streaming->reasoning();
|
const QString reasoning = streaming->reasoning();
|
||||||
QJsonObject assistant;
|
QJsonObject assistant;
|
||||||
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
|
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
|
||||||
if (content.size() > m_contentMark) {
|
if (content.size() > m_contentMark) {
|
||||||
@@ -337,7 +544,7 @@ void LlmClient::roundFinished() {
|
|||||||
m_transcript.append(assistant);
|
m_transcript.append(assistant);
|
||||||
m_round++;
|
m_round++;
|
||||||
|
|
||||||
executeAllCalls();
|
setApprovalPending(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::executeAllCalls() {
|
void LlmClient::executeAllCalls() {
|
||||||
@@ -345,6 +552,18 @@ void LlmClient::executeAllCalls() {
|
|||||||
m_pendingCalls = 0;
|
m_pendingCalls = 0;
|
||||||
m_callResults = QList<ToolCallResult>(m_callBuilders.size());
|
m_callResults = QList<ToolCallResult>(m_callBuilders.size());
|
||||||
|
|
||||||
|
int batchSize = 0;
|
||||||
|
for (const auto& call : m_callBuilders)
|
||||||
|
if (call.seen) ++batchSize;
|
||||||
|
int perCallChars = 0;
|
||||||
|
if (batchSize > 0 && m_contextSize > 0) {
|
||||||
|
const int available = promptBudgetTokens(m_contextSize) -
|
||||||
|
estimateArrayTokens(m_transcript);
|
||||||
|
const int perCallTokens =
|
||||||
|
qMax(available / batchSize, kMinToolResultTokens);
|
||||||
|
perCallChars = perCallTokens * LlmTool::CharsPerToken;
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < m_callBuilders.size(); ++i) {
|
for (int i = 0; i < m_callBuilders.size(); ++i) {
|
||||||
const auto& call = m_callBuilders.at(i);
|
const auto& call = m_callBuilders.at(i);
|
||||||
if (!call.seen) continue;
|
if (!call.seen) continue;
|
||||||
@@ -375,18 +594,22 @@ void LlmClient::executeAllCalls() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
++m_pendingCalls;
|
++m_pendingCalls;
|
||||||
tool->execute(args, [this, i, call](const QJsonObject& result) {
|
tool->execute(
|
||||||
if (!m_streaming) return;
|
call.id,
|
||||||
const bool success = result.contains(QStringLiteral("output"));
|
args,
|
||||||
const QString content =
|
perCallChars,
|
||||||
success ? result[QStringLiteral("output")].toString()
|
[this, i, call](const QJsonObject& result) {
|
||||||
: QStringLiteral("Error: ") +
|
if (!m_streaming) return;
|
||||||
result[QStringLiteral("error")].toString();
|
const bool success = result.contains(QStringLiteral("output"));
|
||||||
m_callResults[i] = {content, success};
|
const QString content =
|
||||||
if (LlmSegment* segment = call.segment)
|
success ? result[QStringLiteral("output")].toString()
|
||||||
segment->finishTool(content, success);
|
: QStringLiteral("Error: ") +
|
||||||
if (--m_pendingCalls == 0) flushCallResults();
|
result[QStringLiteral("error")].toString();
|
||||||
});
|
m_callResults[i] = {content, success};
|
||||||
|
if (LlmSegment* segment = call.segment)
|
||||||
|
segment->finishTool(content, success);
|
||||||
|
if (--m_pendingCalls == 0) flushCallResults();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (m_pendingCalls == 0) flushCallResults();
|
if (m_pendingCalls == 0) flushCallResults();
|
||||||
}
|
}
|
||||||
@@ -407,12 +630,56 @@ void LlmClient::flushCallResults() {
|
|||||||
sendRound();
|
sendRound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void LlmClient::setApprovalPending(bool value) {
|
||||||
|
if (m_approvalPending == value) return;
|
||||||
|
m_approvalPending = value;
|
||||||
|
if (ChatGeneration* streaming = m_streaming)
|
||||||
|
streaming->setApprovalPending(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LlmClient::approveTools() {
|
||||||
|
if (!m_approvalPending) return;
|
||||||
|
ChatGeneration* streaming = m_streaming;
|
||||||
|
if (!streaming) return;
|
||||||
|
setApprovalPending(false);
|
||||||
|
for (auto* segment : streaming->segments()) {
|
||||||
|
if (segment->type() != LlmSegment::Type::ToolCall ||
|
||||||
|
segment->status() != LlmSegment::Status::Pending)
|
||||||
|
continue;
|
||||||
|
segment->setStatus(LlmSegment::Status::Running);
|
||||||
|
segment->begin();
|
||||||
|
}
|
||||||
|
executeAllCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
void LlmClient::denyTools() {
|
||||||
|
if (!m_approvalPending || !m_streaming) return;
|
||||||
|
setApprovalPending(false);
|
||||||
|
finishPendingCalls(QStringLiteral("Denied by user"));
|
||||||
|
finishTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
void LlmClient::finishPendingCalls(const QString& resultText) {
|
||||||
|
ChatGeneration* streaming = m_streaming;
|
||||||
|
if (!streaming) return;
|
||||||
|
for (auto* segment : streaming->segments())
|
||||||
|
if (segment->type() == LlmSegment::Type::ToolCall &&
|
||||||
|
segment->status() == LlmSegment::Status::Pending)
|
||||||
|
segment->finishTool(resultText, false);
|
||||||
|
}
|
||||||
|
|
||||||
void LlmClient::stop() {
|
void LlmClient::stop() {
|
||||||
if (!m_busy) return;
|
if (!m_busy) return;
|
||||||
|
if (m_approvalPending) {
|
||||||
|
setApprovalPending(false);
|
||||||
|
finishPendingCalls(QStringLiteral("Cancelled"));
|
||||||
|
finishTurn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (m_toolPhase) {
|
if (m_toolPhase) {
|
||||||
m_tools->cancelAll();
|
m_tools->cancelAll();
|
||||||
if (m_streaming) {
|
if (ChatGeneration* streaming = m_streaming) {
|
||||||
for (auto* segment : m_streaming->segments()) {
|
for (auto* segment : streaming->segments()) {
|
||||||
if (segment->type() == LlmSegment::Type::ToolCall &&
|
if (segment->type() == LlmSegment::Type::ToolCall &&
|
||||||
segment->running())
|
segment->running())
|
||||||
segment->finishTool(QStringLiteral("Cancelled"), false);
|
segment->finishTool(QStringLiteral("Cancelled"), false);
|
||||||
@@ -421,16 +688,25 @@ void LlmClient::stop() {
|
|||||||
finishTurn();
|
finishTurn();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (m_reply) m_reply->abort();
|
if (m_reply)
|
||||||
|
m_reply->abort();
|
||||||
|
else
|
||||||
|
endStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::endStream() {
|
void LlmClient::endStream() {
|
||||||
if (!m_streaming) return;
|
if (!m_streaming) return;
|
||||||
auto* generation = m_streaming;
|
ChatGeneration* generation = m_streaming;
|
||||||
auto* session = m_active;
|
ChatSession* session = m_active;
|
||||||
m_streaming = nullptr;
|
m_streaming = nullptr;
|
||||||
m_active = nullptr;
|
m_active = nullptr;
|
||||||
generation->setStreaming(false);
|
generation->setStreaming(false);
|
||||||
|
m_approvalPending = false;
|
||||||
|
generation->setApprovalPending(false);
|
||||||
|
for (auto* segment : generation->segments())
|
||||||
|
if (segment->type() == LlmSegment::Type::ToolCall &&
|
||||||
|
segment->status() == LlmSegment::Status::Pending)
|
||||||
|
segment->finishTool(QStringLiteral("Cancelled"), false);
|
||||||
if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
|
if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
|
||||||
generation->toolCallCount() == 0) {
|
generation->toolCallCount() == 0) {
|
||||||
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
|
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
|
||||||
@@ -454,6 +730,7 @@ void LlmClient::finishTurn() {
|
|||||||
m_pendingClear.clear();
|
m_pendingClear.clear();
|
||||||
}
|
}
|
||||||
if (session) session->persist();
|
if (session) session->persist();
|
||||||
|
refreshFromServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::clearOnFinish(ChatSession* session) {
|
void LlmClient::clearOnFinish(ChatSession* session) {
|
||||||
@@ -528,6 +805,12 @@ void LlmClient::handleLine(const QByteArray& line) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void LlmClient::refreshFromServer() {
|
||||||
|
if (m_endpoint.trimmed().isEmpty()) return;
|
||||||
|
probeContextSize();
|
||||||
|
refreshModels();
|
||||||
|
}
|
||||||
|
|
||||||
void LlmClient::refreshModels() {
|
void LlmClient::refreshModels() {
|
||||||
const QUrl url =
|
const QUrl url =
|
||||||
QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
|
QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
|
||||||
@@ -563,6 +846,9 @@ void LlmClient::refreshModels() {
|
|||||||
if (m_model.isEmpty()) {
|
if (m_model.isEmpty()) {
|
||||||
m_model = models.first();
|
m_model = models.first();
|
||||||
Q_EMIT modelChanged();
|
Q_EMIT modelChanged();
|
||||||
|
} else if (!models.contains(m_model)) {
|
||||||
|
m_model = models.first();
|
||||||
|
Q_EMIT modelChanged();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -570,18 +856,26 @@ void LlmClient::refreshModels() {
|
|||||||
void LlmClient::setContextSize(int size) {
|
void LlmClient::setContextSize(int size) {
|
||||||
if (size <= 0 || m_contextSize == size) return;
|
if (size <= 0 || m_contextSize == size) return;
|
||||||
m_contextSize = size;
|
m_contextSize = size;
|
||||||
|
m_tools->setContextSize(size);
|
||||||
Q_EMIT contextSizeChanged();
|
Q_EMIT contextSizeChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::probeContextSize() {
|
void LlmClient::probeContextSize(std::function<void()> done) {
|
||||||
QString base = m_endpoint.trimmed();
|
QString base = m_endpoint.trimmed();
|
||||||
while (base.endsWith('/'))
|
while (base.endsWith('/'))
|
||||||
base.chop(1);
|
base.chop(1);
|
||||||
const QUrl url = QUrl::fromUserInput(base + "/props");
|
const QUrl url = QUrl::fromUserInput(base + "/props");
|
||||||
if (!url.isValid() || url.host().isEmpty()) return;
|
if (!url.isValid() || url.host().isEmpty()) {
|
||||||
|
if (done) done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
auto* reply = m_manager.get(QNetworkRequest(url));
|
auto* reply = m_manager.get(QNetworkRequest(url));
|
||||||
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
auto settled = std::make_shared<bool>(false);
|
||||||
|
const auto finish = [this, reply, settled, done]() {
|
||||||
|
if (*settled) return;
|
||||||
|
*settled = true;
|
||||||
|
|
||||||
const QNetworkReply::NetworkError error = reply->error();
|
const QNetworkReply::NetworkError error = reply->error();
|
||||||
const QByteArray data = reply->readAll();
|
const QByteArray data = reply->readAll();
|
||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
@@ -606,17 +900,26 @@ void LlmClient::probeContextSize() {
|
|||||||
.toInt(0);
|
.toInt(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setContextSize(size > 0 ? size : 4096);
|
if (size > 0)
|
||||||
|
setContextSize(size);
|
||||||
|
else if (m_contextSize <= 0)
|
||||||
|
setContextSize(4096);
|
||||||
|
if (done) done();
|
||||||
|
};
|
||||||
|
connect(reply, &QNetworkReply::finished, this, finish);
|
||||||
|
QTimer::singleShot(kProbeTimeoutMs, this, [reply, settled]() {
|
||||||
|
if (!*settled) reply->abort();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::updateTokenUsage(const QJsonObject& data) {
|
void LlmClient::updateTokenUsage(const QJsonObject& data) {
|
||||||
if (!m_active || m_contextSize <= 0) return;
|
ChatSession* active = m_active;
|
||||||
|
if (!active || m_contextSize <= 0) return;
|
||||||
const QJsonObject usage = data["usage"].toObject();
|
const QJsonObject usage = data["usage"].toObject();
|
||||||
if (usage.isEmpty()) return;
|
if (usage.isEmpty()) return;
|
||||||
const double used = usage.value("prompt_tokens").toDouble() +
|
const double used = usage.value("prompt_tokens").toDouble() +
|
||||||
usage.value("completion_tokens").toDouble();
|
usage.value("completion_tokens").toDouble();
|
||||||
if (used > 0) m_active->setLastTokenCount(static_cast<int>(used));
|
if (used > 0) active->setLastTokenCount(static_cast<int>(used));
|
||||||
}
|
}
|
||||||
|
|
||||||
void LlmClient::shortRequest(
|
void LlmClient::shortRequest(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
|
||||||
@@ -42,7 +43,8 @@ class LlmClient : public QObject {
|
|||||||
void setModel(const QString& value);
|
void setModel(const QString& value);
|
||||||
void setTemperature(double value);
|
void setTemperature(double value);
|
||||||
void setContextSize(int size);
|
void setContextSize(int size);
|
||||||
void probeContextSize();
|
void probeContextSize(std::function<void()> done = {});
|
||||||
|
Q_INVOKABLE void refreshFromServer();
|
||||||
|
|
||||||
[[nodiscard]] bool busy() const { return m_busy; }
|
[[nodiscard]] bool busy() const { return m_busy; }
|
||||||
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
|
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
|
||||||
@@ -56,6 +58,9 @@ class LlmClient : public QObject {
|
|||||||
void clearOnFinish(ChatSession* session);
|
void clearOnFinish(ChatSession* session);
|
||||||
void sessionRemoved(ChatSession* session);
|
void sessionRemoved(ChatSession* session);
|
||||||
|
|
||||||
|
void approveTools();
|
||||||
|
void denyTools();
|
||||||
|
|
||||||
void refreshModels();
|
void refreshModels();
|
||||||
void requestTitle(ChatSession* session, const QString& userText);
|
void requestTitle(ChatSession* session, const QString& userText);
|
||||||
void requestIcon(ChatSession* session, const QString& userText);
|
void requestIcon(ChatSession* session, const QString& userText);
|
||||||
@@ -83,11 +88,13 @@ class LlmClient : public QObject {
|
|||||||
|
|
||||||
void sendRound();
|
void sendRound();
|
||||||
QJsonArray buildContextMessages(
|
QJsonArray buildContextMessages(
|
||||||
ChatSession* session, int stopBeforeRow) const;
|
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
|
||||||
void applyToolCallDelta(const QJsonObject& call);
|
void applyToolCallDelta(const QJsonObject& call);
|
||||||
void roundFinished();
|
void roundFinished();
|
||||||
void executeAllCalls();
|
void executeAllCalls();
|
||||||
void flushCallResults();
|
void flushCallResults();
|
||||||
|
void setApprovalPending(bool value);
|
||||||
|
void finishPendingCalls(const QString& resultText);
|
||||||
void finishTurn();
|
void finishTurn();
|
||||||
void fail(const QString& message);
|
void fail(const QString& message);
|
||||||
void handleLine(const QByteArray& line);
|
void handleLine(const QByteArray& line);
|
||||||
@@ -109,8 +116,9 @@ class LlmClient : public QObject {
|
|||||||
ToolRegistry* m_tools = nullptr;
|
ToolRegistry* m_tools = nullptr;
|
||||||
QNetworkReply* m_reply = nullptr;
|
QNetworkReply* m_reply = nullptr;
|
||||||
QByteArray m_buffer;
|
QByteArray m_buffer;
|
||||||
ChatSession* m_active = nullptr;
|
QTimer m_refreshTimer;
|
||||||
ChatGeneration* m_streaming = nullptr;
|
QPointer<ChatSession> m_active;
|
||||||
|
QPointer<ChatGeneration> m_streaming;
|
||||||
QPointer<ChatSession> m_pendingClear;
|
QPointer<ChatSession> m_pendingClear;
|
||||||
bool m_busy = false;
|
bool m_busy = false;
|
||||||
QString m_streamingChatId;
|
QString m_streamingChatId;
|
||||||
@@ -133,6 +141,8 @@ class LlmClient : public QObject {
|
|||||||
qsizetype m_reasoningMark = 0;
|
qsizetype m_reasoningMark = 0;
|
||||||
bool m_roundDone = false;
|
bool m_roundDone = false;
|
||||||
bool m_toolPhase = false;
|
bool m_toolPhase = false;
|
||||||
|
bool m_approvalPending = false;
|
||||||
|
bool m_contextRetryUsed = false;
|
||||||
int m_pendingCalls = 0;
|
int m_pendingCalls = 0;
|
||||||
static constexpr int kMaxToolRounds = 12;
|
static constexpr int kMaxToolRounds = 12;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class LlmSegment : public QObject {
|
|||||||
enum class Type : int { Reasoning = 0, ToolCall, Content };
|
enum class Type : int { Reasoning = 0, ToolCall, Content };
|
||||||
Q_ENUM(Type)
|
Q_ENUM(Type)
|
||||||
|
|
||||||
enum class Status : int { None = 0, Running, Success, Error };
|
enum class Status : int { None = 0, Running, Success, Error, Pending };
|
||||||
Q_ENUM(Status)
|
Q_ENUM(Status)
|
||||||
|
|
||||||
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
|
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ LlmTool::LlmTool(QObject* parent) : QObject(parent) {}
|
|||||||
|
|
||||||
LlmTool::~LlmTool() = default;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
void LlmTool::cancel() {}
|
void LlmTool::cancel() {}
|
||||||
|
|
||||||
QJsonObject LlmTool::specification() const {
|
QJsonObject LlmTool::specification() const {
|
||||||
@@ -30,6 +42,7 @@ void ToolRegistry::setEnabled(bool value) {
|
|||||||
void ToolRegistry::registerTool(LlmTool* tool) {
|
void ToolRegistry::registerTool(LlmTool* tool) {
|
||||||
if (!tool || m_tools.contains(tool)) return;
|
if (!tool || m_tools.contains(tool)) return;
|
||||||
tool->setParent(this);
|
tool->setParent(this);
|
||||||
|
tool->setContextSize(m_contextSize);
|
||||||
m_tools.append(tool);
|
m_tools.append(tool);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,4 +65,11 @@ void ToolRegistry::cancelAll() {
|
|||||||
tool->cancel();
|
tool->cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ToolRegistry::setContextSize(int value) {
|
||||||
|
if (m_contextSize == value) return;
|
||||||
|
m_contextSize = value;
|
||||||
|
for (auto* tool : m_tools)
|
||||||
|
tool->setContextSize(value);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace ZShell::llm
|
} // namespace ZShell::llm
|
||||||
|
|||||||
@@ -22,11 +22,24 @@ class LlmTool : public QObject {
|
|||||||
[[nodiscard]] virtual QJsonObject parameters() const = 0;
|
[[nodiscard]] virtual QJsonObject parameters() const = 0;
|
||||||
|
|
||||||
virtual void execute(
|
virtual void execute(
|
||||||
|
const QString& toolCallId,
|
||||||
const QJsonObject& args,
|
const QJsonObject& args,
|
||||||
|
int outputBudgetChars,
|
||||||
std::function<void(const QJsonObject& result)> done) = 0;
|
std::function<void(const QJsonObject& result)> done) = 0;
|
||||||
virtual void cancel();
|
virtual void cancel();
|
||||||
|
|
||||||
[[nodiscard]] QJsonObject specification() const;
|
[[nodiscard]] QJsonObject specification() const;
|
||||||
|
|
||||||
|
[[nodiscard]] int contextSize() const { return m_contextSize; }
|
||||||
|
void setContextSize(int value);
|
||||||
|
|
||||||
|
[[nodiscard]] int inlineBudgetChars() const;
|
||||||
|
|
||||||
|
static constexpr int CharsPerToken = 4;
|
||||||
|
static constexpr int DefaultInlineChars = 64 * 1024;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
int m_contextSize = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
class ToolRegistry : public QObject {
|
class ToolRegistry : public QObject {
|
||||||
@@ -45,11 +58,14 @@ class ToolRegistry : public QObject {
|
|||||||
[[nodiscard]] QJsonArray specifications() const;
|
[[nodiscard]] QJsonArray specifications() const;
|
||||||
void cancelAll();
|
void cancelAll();
|
||||||
|
|
||||||
|
void setContextSize(int value);
|
||||||
|
|
||||||
Q_SIGNALS:
|
Q_SIGNALS:
|
||||||
void enabledChanged();
|
void enabledChanged();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool m_enabled = true;
|
bool m_enabled = true;
|
||||||
|
int m_contextSize = 0;
|
||||||
QList<LlmTool*> m_tools;
|
QList<LlmTool*> m_tools;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "webfetchtool.hpp"
|
#include "webfetchtool.hpp"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
@@ -8,6 +10,9 @@
|
|||||||
|
|
||||||
namespace ZShell::llm {
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
const QString WebFetchTool::StoragePath =
|
||||||
|
QStringLiteral("/tmp/zshell-llm/webfetch");
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
const char* kUserAgent =
|
const char* kUserAgent =
|
||||||
@@ -46,7 +51,10 @@ QString WebFetchTool::description() const {
|
|||||||
return QStringLiteral(
|
return QStringLiteral(
|
||||||
"Fetch content from an HTTP or HTTPS URL and return it as plain "
|
"Fetch content from an HTTP or HTTPS URL and return it as plain "
|
||||||
"text or raw HTML. HTML pages are reduced to their visible text "
|
"text or raw HTML. HTML pages are reduced to their visible text "
|
||||||
"by default. This tool is read-only.");
|
"by default. Only a limited amount of content is returned inline; "
|
||||||
|
"the full output of every call is saved under "
|
||||||
|
"/tmp/zshell-llm/webfetch/, where it can be paged through with "
|
||||||
|
"the readfile tool. This tool is read-only.");
|
||||||
}
|
}
|
||||||
|
|
||||||
QJsonObject WebFetchTool::parameters() const {
|
QJsonObject WebFetchTool::parameters() const {
|
||||||
@@ -102,9 +110,14 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WebFetchTool::execute(
|
void WebFetchTool::execute(
|
||||||
const QJsonObject& args, std::function<void(const QJsonObject&)> done) {
|
const QString& toolCallId,
|
||||||
|
const QJsonObject& args,
|
||||||
|
int outputBudgetChars,
|
||||||
|
std::function<void(const QJsonObject&)> done) {
|
||||||
auto* job = new Job;
|
auto* job = new Job;
|
||||||
job->done = std::move(done);
|
job->done = std::move(done);
|
||||||
|
job->toolCallId = toolCallId;
|
||||||
|
job->budgetChars = outputBudgetChars;
|
||||||
m_jobs.append(job);
|
m_jobs.append(job);
|
||||||
|
|
||||||
auto fail = [this, job](const QString& message) {
|
auto fail = [this, job](const QString& message) {
|
||||||
@@ -233,13 +246,63 @@ void WebFetchTool::execute(
|
|||||||
if (mime.contains(QLatin1String("text/html")) &&
|
if (mime.contains(QLatin1String("text/html")) &&
|
||||||
job->format == QLatin1String("text"))
|
job->format == QLatin1String("text"))
|
||||||
content = extractTextFromHtml(content);
|
content = extractTextFromHtml(content);
|
||||||
if (content.size() > MaxOutputChars)
|
|
||||||
content = content.left(MaxOutputChars) +
|
const QString savedPath = saveToFile(*job, content, mime);
|
||||||
QStringLiteral("\n[... truncated ...]");
|
|
||||||
completeJob(job, makeOutput(content));
|
QString output = content;
|
||||||
|
int budget = inlineBudgetChars();
|
||||||
|
if (job->budgetChars > 0) budget = qMin(budget, job->budgetChars);
|
||||||
|
if (content.size() > budget) {
|
||||||
|
output = content.left(budget);
|
||||||
|
const QString note =
|
||||||
|
QStringLiteral(
|
||||||
|
"\n\n[... truncated: showing %1 of %2 characters")
|
||||||
|
.arg(budget)
|
||||||
|
.arg(content.size());
|
||||||
|
if (!savedPath.isEmpty())
|
||||||
|
output += note + QStringLiteral(
|
||||||
|
". The full content is saved to %1; use "
|
||||||
|
"the readfile tool to read the rest.")
|
||||||
|
.arg(savedPath);
|
||||||
|
else
|
||||||
|
output += note + QStringLiteral(
|
||||||
|
". The remaining content is not "
|
||||||
|
"available.");
|
||||||
|
}
|
||||||
|
completeJob(job, makeOutput(output));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString WebFetchTool::saveToFile(
|
||||||
|
const Job& job, const QString& content, const QString& mime) const {
|
||||||
|
QDir dir(StoragePath);
|
||||||
|
if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) return QString();
|
||||||
|
|
||||||
|
QString fileName;
|
||||||
|
fileName.reserve(job.toolCallId.size());
|
||||||
|
for (const QChar& c : job.toolCallId)
|
||||||
|
fileName += (c.isLetterOrNumber() || c == QLatin1Char('-') ||
|
||||||
|
c == QLatin1Char('_'))
|
||||||
|
? c
|
||||||
|
: QLatin1Char('_');
|
||||||
|
if (fileName.isEmpty()) fileName = QStringLiteral("fetch");
|
||||||
|
|
||||||
|
QString extension = QStringLiteral("txt");
|
||||||
|
if (job.format == QLatin1String("html"))
|
||||||
|
extension = QStringLiteral("html");
|
||||||
|
else if (mime.contains(QLatin1String("json")))
|
||||||
|
extension = QStringLiteral("json");
|
||||||
|
else if (mime.contains(QLatin1String("xml")))
|
||||||
|
extension = QStringLiteral("xml");
|
||||||
|
|
||||||
|
const QString path = dir.filePath(fileName + QLatin1Char('.') + extension);
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||||
|
return QString();
|
||||||
|
file.write(content.toUtf8());
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
void WebFetchTool::cancel() {
|
void WebFetchTool::cancel() {
|
||||||
for (auto* job : m_jobs) {
|
for (auto* job : m_jobs) {
|
||||||
job->timer->stop();
|
job->timer->stop();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class WebFetchTool : public LlmTool {
|
|||||||
static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
|
static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
|
||||||
static constexpr int DefaultTimeoutSeconds = 30;
|
static constexpr int DefaultTimeoutSeconds = 30;
|
||||||
static constexpr int MaxTimeoutSeconds = 120;
|
static constexpr int MaxTimeoutSeconds = 120;
|
||||||
static constexpr int MaxOutputChars = 64 * 1024;
|
static const QString StoragePath;
|
||||||
|
|
||||||
explicit WebFetchTool(QObject* parent = nullptr);
|
explicit WebFetchTool(QObject* parent = nullptr);
|
||||||
~WebFetchTool() override;
|
~WebFetchTool() override;
|
||||||
@@ -29,7 +29,9 @@ class WebFetchTool : public LlmTool {
|
|||||||
QString description() const override;
|
QString description() const override;
|
||||||
QJsonObject parameters() const override;
|
QJsonObject parameters() const override;
|
||||||
void execute(
|
void execute(
|
||||||
|
const QString& toolCallId,
|
||||||
const QJsonObject& args,
|
const QJsonObject& args,
|
||||||
|
int outputBudgetChars,
|
||||||
std::function<void(const QJsonObject& result)> done) override;
|
std::function<void(const QJsonObject& result)> done) override;
|
||||||
void cancel() override;
|
void cancel() override;
|
||||||
|
|
||||||
@@ -43,10 +45,14 @@ class WebFetchTool : public LlmTool {
|
|||||||
QByteArray body;
|
QByteArray body;
|
||||||
bool tooLarge = false;
|
bool tooLarge = false;
|
||||||
QString format;
|
QString format;
|
||||||
|
QString toolCallId;
|
||||||
|
int budgetChars = 0;
|
||||||
std::function<void(const QJsonObject& result)> done;
|
std::function<void(const QJsonObject& result)> done;
|
||||||
};
|
};
|
||||||
|
|
||||||
void completeJob(Job* job, QJsonObject result);
|
void completeJob(Job* job, QJsonObject result);
|
||||||
|
QString saveToFile(
|
||||||
|
const Job& job, const QString& content, const QString& mime) const;
|
||||||
|
|
||||||
QNetworkAccessManager m_manager;
|
QNetworkAccessManager m_manager;
|
||||||
QList<Job*> m_jobs;
|
QList<Job*> m_jobs;
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ void Gpu::tick() {
|
|||||||
const Type t = type();
|
const Type t = type();
|
||||||
if (t == Generic) {
|
if (t == Generic) {
|
||||||
readGenericUsage();
|
readGenericUsage();
|
||||||
|
readGenericMemory();
|
||||||
readGpuTemperature();
|
readGpuTemperature();
|
||||||
} else if (t == Nvidia) {
|
} else if (t == Nvidia) {
|
||||||
startNvidiaUsage();
|
startNvidiaUsage();
|
||||||
@@ -334,8 +335,10 @@ void Gpu::startNvidiaUsage() {
|
|||||||
const qreal usage =
|
const qreal usage =
|
||||||
parts.at(0).trimmed().toDouble(&ok1) / 100.0;
|
parts.at(0).trimmed().toDouble(&ok1) / 100.0;
|
||||||
const qreal temp = parts.at(1).trimmed().toDouble(&ok2);
|
const qreal temp = parts.at(1).trimmed().toDouble(&ok2);
|
||||||
const qreal memUsed = parts.at(2).trimmed().toDouble(&ok3);
|
const qreal memUsed =
|
||||||
const qreal memTotal = parts.at(3).trimmed().toDouble(&ok4);
|
parts.at(2).trimmed().toDouble(&ok3) * 1024;
|
||||||
|
const qreal memTotal =
|
||||||
|
parts.at(3).trimmed().toDouble(&ok4) * 1024;
|
||||||
|
|
||||||
if (ok1 && std::abs(usage - m_percentage) > 0.0001) {
|
if (ok1 && std::abs(usage - m_percentage) > 0.0001) {
|
||||||
m_percentage = usage;
|
m_percentage = usage;
|
||||||
|
|||||||
+151
-6
@@ -5,13 +5,17 @@
|
|||||||
#include <QtQuick/qquickwindow.h>
|
#include <QtQuick/qquickwindow.h>
|
||||||
#include <qcontainerfwd.h>
|
#include <qcontainerfwd.h>
|
||||||
#include <qdir.h>
|
#include <qdir.h>
|
||||||
|
#include <qdiriterator.h>
|
||||||
#include <qfileinfo.h>
|
#include <qfileinfo.h>
|
||||||
#include <qfuturewatcher.h>
|
#include <qfuturewatcher.h>
|
||||||
#include <qjsprimitivevalue.h>
|
#include <qjsprimitivevalue.h>
|
||||||
#include <qloggingcategory.h>
|
#include <qloggingcategory.h>
|
||||||
|
#include <qregularexpression.h>
|
||||||
#include <qqmlengine.h>
|
#include <qqmlengine.h>
|
||||||
#include <qfile.h>
|
#include <qfile.h>
|
||||||
|
|
||||||
|
#include "util/metaenum.hpp"
|
||||||
|
|
||||||
Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg)
|
Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg)
|
||||||
|
|
||||||
namespace ZShell {
|
namespace ZShell {
|
||||||
@@ -171,13 +175,112 @@ qreal ZUtils::clamp(qreal value, qreal min, qreal max) {
|
|||||||
return qBound(min, value, max);
|
return qBound(min, value, max);
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ZUtils::settingsIndex() {
|
QString ZUtils::enumToString(
|
||||||
QFile file(QStringLiteral(":/qt/qml/ZShell/settings-index.json"));
|
QObject* target, const QString& property, const QVariant& value) {
|
||||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
if (!target) {
|
||||||
qCWarning(lcZUtils) << "Failed to open embedded settings index";
|
qCWarning(lcZUtils) << "enumToString: a target is required";
|
||||||
return QString();
|
return {};
|
||||||
}
|
}
|
||||||
return QString::fromUtf8(file.readAll());
|
|
||||||
|
const auto* meta = target->metaObject();
|
||||||
|
const auto index = meta->indexOfProperty(property.toUtf8().constData());
|
||||||
|
if (index < 0) {
|
||||||
|
qCWarning(lcZUtils)
|
||||||
|
<< "enumToString:" << target << "has no property" << property;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto prop = meta->property(index);
|
||||||
|
const auto metaEnum = prop.isEnumType()
|
||||||
|
? prop.enumerator()
|
||||||
|
: util::metaEnumFor(prop.metaType());
|
||||||
|
if (!metaEnum.isValid() || metaEnum.is64Bit()) {
|
||||||
|
qCWarning(lcZUtils) << "enumToString: property" << property << "of"
|
||||||
|
<< target << "is not a supported enum";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto val = value.isValid() ? value : prop.read(target);
|
||||||
|
const auto* key = util::enumKeyFor(metaEnum, val);
|
||||||
|
if (!key) {
|
||||||
|
qCWarning(
|
||||||
|
lcZUtils,
|
||||||
|
"enumToString: no enumerator of %s::%s has the value %lld",
|
||||||
|
metaEnum.scope(),
|
||||||
|
metaEnum.name(),
|
||||||
|
val.toLongLong());
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return QString::fromUtf8(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
template <typename Predicate>
|
||||||
|
QQuickItem* findChildDfs(QQuickItem* root, Predicate&& match) {
|
||||||
|
const auto children = root->childItems();
|
||||||
|
for (QQuickItem* const child : children) {
|
||||||
|
if (match(child)) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
if (QQuickItem* const found = findChildDfs(child, match)) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename Predicate>
|
||||||
|
void findChildrenDfs(
|
||||||
|
QQuickItem* root, Predicate&& match, QList<QQuickItem*>& out) {
|
||||||
|
const auto children = root->childItems();
|
||||||
|
for (QQuickItem* const child : children) {
|
||||||
|
if (match(child)) {
|
||||||
|
out.append(child);
|
||||||
|
}
|
||||||
|
findChildrenDfs(child, match, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QQuickItem* ZUtils::findChild(QQuickItem* root, const QString& name) {
|
||||||
|
if (!root) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return findChildDfs(root, [&name](const QQuickItem* item) {
|
||||||
|
return item->objectName() == name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<QQuickItem*> ZUtils::findChildren(QQuickItem* root, const QString& name) {
|
||||||
|
QList<QQuickItem*> children;
|
||||||
|
if (root) {
|
||||||
|
findChildrenDfs(
|
||||||
|
root,
|
||||||
|
[&name](const QQuickItem* item) {
|
||||||
|
return item->objectName() == name;
|
||||||
|
},
|
||||||
|
children);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<QQuickItem*> ZUtils::findChildrenMatching(
|
||||||
|
QQuickItem* root, const QString& pattern) {
|
||||||
|
QList<QQuickItem*> children;
|
||||||
|
if (root) {
|
||||||
|
const QRegularExpression re(pattern);
|
||||||
|
findChildrenDfs(
|
||||||
|
root,
|
||||||
|
[&re](const QQuickItem* item) {
|
||||||
|
return re.match(item->objectName()).hasMatch();
|
||||||
|
},
|
||||||
|
children);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifndef ZSHELL_VERSION
|
#ifndef ZSHELL_VERSION
|
||||||
@@ -192,4 +295,46 @@ QString ZUtils::qtVersion() const {
|
|||||||
return QStringLiteral(QT_VERSION_STR);
|
return QStringLiteral(QT_VERSION_STR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString ZUtils::gitRevision() {
|
||||||
|
#ifdef GIT_REVISION
|
||||||
|
return QStringLiteral(GIT_REVISION);
|
||||||
|
#else
|
||||||
|
return QString();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ZUtils::readTextFile(const QString& path) {
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
return QString::fromUtf8(file.readAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ZUtils::writeTextFile(const QString& path, const QString& text) {
|
||||||
|
const QFileInfo info(path);
|
||||||
|
if (!QDir().mkpath(info.absolutePath())) {
|
||||||
|
qCWarning(lcZUtils) << "Failed to create directory for" << path;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||||
|
qCWarning(lcZUtils) << "Failed to open" << path << "for writing";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return file.write(text.toUtf8()) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList ZUtils::listFiles(const QString& dir, const QString& suffix) {
|
||||||
|
QStringList out;
|
||||||
|
QDirIterator it(dir, QDirIterator::Subdirectories);
|
||||||
|
while (it.hasNext()) {
|
||||||
|
const QString path = it.next();
|
||||||
|
if (it.fileInfo().isFile() && path.endsWith(suffix)) {
|
||||||
|
out.append(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace ZShell
|
} // namespace ZShell
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
#include <qcontainerfwd.h>
|
#include <qcontainerfwd.h>
|
||||||
#include <qobject.h>
|
#include <qobject.h>
|
||||||
#include <qqmlintegration.h>
|
#include <qqmlintegration.h>
|
||||||
|
#include <qtmetamacros.h>
|
||||||
|
#include <qvariant.h>
|
||||||
|
|
||||||
namespace ZShell {
|
namespace ZShell {
|
||||||
|
|
||||||
@@ -32,7 +34,24 @@ class ZUtils : public QObject {
|
|||||||
|
|
||||||
Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max);
|
Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max);
|
||||||
|
|
||||||
Q_INVOKABLE static QString settingsIndex();
|
Q_INVOKABLE static QString enumToString(
|
||||||
|
QObject* target,
|
||||||
|
const QString& property,
|
||||||
|
const QVariant& value = QVariant());
|
||||||
|
|
||||||
|
Q_INVOKABLE static QString gitRevision();
|
||||||
|
Q_INVOKABLE static QString readTextFile(const QString& path);
|
||||||
|
Q_INVOKABLE static bool writeTextFile(
|
||||||
|
const QString& path, const QString& text);
|
||||||
|
Q_INVOKABLE static QStringList listFiles(
|
||||||
|
const QString& dir, const QString& suffix);
|
||||||
|
|
||||||
|
Q_INVOKABLE static QQuickItem* findChild(
|
||||||
|
QQuickItem* root, const QString& name);
|
||||||
|
Q_INVOKABLE static QList<QQuickItem*> findChildren(
|
||||||
|
QQuickItem* root, const QString& name);
|
||||||
|
Q_INVOKABLE static QList<QQuickItem*> findChildrenMatching(
|
||||||
|
QQuickItem* root, const QString& pattern);
|
||||||
|
|
||||||
[[nodiscard]] QString version() const;
|
[[nodiscard]] QString version() const;
|
||||||
[[nodiscard]] QString qtVersion() const;
|
[[nodiscard]] QString qtVersion() const;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
add_library(zshell-pch INTERFACE)
|
||||||
|
target_precompile_headers(zshell-pch INTERFACE
|
||||||
|
<qobject.h>
|
||||||
|
<qqmlintegration.h>
|
||||||
|
<qstring.h>
|
||||||
|
<qqmlengine.h>
|
||||||
|
<qloggingcategory.h>
|
||||||
|
<qvariant.h>
|
||||||
|
<qtimer.h>
|
||||||
|
<qdir.h>
|
||||||
|
<qlist.h>
|
||||||
|
<qstringlist.h>
|
||||||
|
<qpointer.h>
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
message(STATUS "QML install dir: ${CMAKE_INSTALL_PREFIX}/${INSTALL_QMLDIR}")
|
||||||
|
|
||||||
|
function(qml_module arg_TARGET)
|
||||||
|
cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;QML_FILES;QML_SINGLETONS;DEPENDENCIES;IMPORTS;OPTIONAL_IMPORTS;DEFAULT_IMPORTS;LIBRARIES")
|
||||||
|
|
||||||
|
set_source_files_properties(${arg_QML_SINGLETONS} PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
|
||||||
|
|
||||||
|
qt_add_qml_module(${arg_TARGET}
|
||||||
|
URI ${arg_URI}
|
||||||
|
SOURCES ${arg_SOURCES}
|
||||||
|
QML_FILES ${arg_QML_FILES} ${arg_QML_SINGLETONS}
|
||||||
|
DEPENDENCIES ${arg_DEPENDENCIES}
|
||||||
|
IMPORTS ${arg_IMPORTS}
|
||||||
|
OPTIONAL_IMPORTS ${arg_OPTIONAL_IMPORTS}
|
||||||
|
DEFAULT_IMPORTS ${arg_DEFAULT_IMPORTS}
|
||||||
|
)
|
||||||
|
|
||||||
|
qt_query_qml_module(${arg_TARGET}
|
||||||
|
URI module_uri
|
||||||
|
PLUGIN_TARGET module_plugin_target
|
||||||
|
TARGET_PATH module_target_path
|
||||||
|
QMLDIR module_qmldir
|
||||||
|
TYPEINFO module_typeinfo
|
||||||
|
)
|
||||||
|
|
||||||
|
message(STATUS "Created QML module: ${module_uri}")
|
||||||
|
|
||||||
|
string(REPLACE "/" ";" uri_parts "${module_target_path}")
|
||||||
|
list(GET uri_parts 0 top_level)
|
||||||
|
set(backing_lib_dir "${INSTALL_QMLDIR}/${top_level}/lib")
|
||||||
|
set(module_dir "${INSTALL_QMLDIR}/${module_target_path}")
|
||||||
|
|
||||||
|
install(TARGETS ${arg_TARGET}
|
||||||
|
LIBRARY DESTINATION "${backing_lib_dir}"
|
||||||
|
RUNTIME DESTINATION "${backing_lib_dir}"
|
||||||
|
)
|
||||||
|
install(TARGETS "${module_plugin_target}"
|
||||||
|
LIBRARY DESTINATION "${module_dir}"
|
||||||
|
RUNTIME DESTINATION "${module_dir}"
|
||||||
|
)
|
||||||
|
install(FILES "${module_qmldir}" DESTINATION "${module_dir}")
|
||||||
|
install(FILES "${module_typeinfo}" DESTINATION "${module_dir}")
|
||||||
|
|
||||||
|
target_link_libraries(${arg_TARGET} PRIVATE zshell-pch Qt::Core Qt::Qml ${arg_LIBRARIES})
|
||||||
|
|
||||||
|
if(arg_INCLUDE_PREFIX)
|
||||||
|
set(include_dir "${CMAKE_CURRENT_BINARY_DIR}/include")
|
||||||
|
file(MAKE_DIRECTORY "${include_dir}")
|
||||||
|
file(CREATE_LINK "${CMAKE_CURRENT_SOURCE_DIR}" "${include_dir}/${arg_INCLUDE_PREFIX}" SYMBOLIC)
|
||||||
|
target_include_directories(${arg_TARGET} PUBLIC "${include_dir}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
file(RELATIVE_PATH plugin_to_lib "/${module_target_path}" "/${top_level}/lib")
|
||||||
|
set_property(TARGET ${module_plugin_target} APPEND PROPERTY INSTALL_RPATH "$ORIGIN/${plugin_to_lib}")
|
||||||
|
endfunction()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
find_library(SENSORS_LIBRARY NAMES sensors REQUIRED)
|
||||||
|
find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED)
|
||||||
|
if(NOT TARGET Sensors::Sensors)
|
||||||
|
add_library(Sensors::Sensors UNKNOWN IMPORTED)
|
||||||
|
set_target_properties(Sensors::Sensors PROPERTIES
|
||||||
|
IMPORTED_LOCATION "${SENSORS_LIBRARY}"
|
||||||
|
INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <qmetaobject.h>
|
||||||
|
#include <qmetatype.h>
|
||||||
|
#include <qvariant.h>
|
||||||
|
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
inline QMetaEnum metaEnumFor(const QMetaType& type) {
|
||||||
|
const auto* meta = type.metaObject();
|
||||||
|
if (!meta) return {};
|
||||||
|
|
||||||
|
auto name = QByteArray(type.name());
|
||||||
|
if (const auto scope = name.lastIndexOf("::"); scope >= 0)
|
||||||
|
name = name.mid(scope + 2);
|
||||||
|
|
||||||
|
return meta->enumerator(meta->indexOfEnumerator(name.constData()));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool isSupportedEnum(const QMetaType& type) {
|
||||||
|
if (!type.flags().testFlag(QMetaType::IsEnumeration)) return false;
|
||||||
|
|
||||||
|
const auto metaEnum = metaEnumFor(type);
|
||||||
|
return metaEnum.isValid() && !metaEnum.is64Bit();
|
||||||
|
}
|
||||||
|
|
||||||
|
inline const char* enumKeyFor(const QMetaEnum& metaEnum, const QVariant& value) {
|
||||||
|
return metaEnum.valueToKey(static_cast<quint64>(value.toLongLong()));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
@@ -15,7 +15,6 @@ It includes a configurable top bar, launcher, notifications daemon + sidebar, wa
|
|||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- Multi-window shell layout driven by `Drawers/Windows.qml` and panel/interactions wrappers
|
- Multi-window shell layout driven by `Drawers/Windows.qml` and panel/interactions wrappers
|
||||||
- Configurable bar entries and popouts (audio, tray, network, power, resources, clock, active window)
|
|
||||||
- Launcher with app DB frequency tracking, action commands, fuzzy search modes, and wallpaper/scheme flows
|
- Launcher with app DB frequency tracking, action commands, fuzzy search modes, and wallpaper/scheme flows
|
||||||
- Notification server with persistence (`~/.local/state/zshell/notifs.json`) and sidebar UI
|
- Notification server with persistence (`~/.local/state/zshell/notifs.json`) and sidebar UI
|
||||||
- Dynamic Material 3 palette generation from wallpaper, optional template rendering, terminal sequence application
|
- Dynamic Material 3 palette generation from wallpaper, optional template rendering, terminal sequence application
|
||||||
@@ -47,16 +46,25 @@ Core requirements:
|
|||||||
- Hyprland (Wayland session integration)
|
- Hyprland (Wayland session integration)
|
||||||
- Python 3 for scheme/wallpaper tooling
|
- Python 3 for scheme/wallpaper tooling
|
||||||
|
|
||||||
Make sure to have the newest Quickshell version! As of writing, version `0.2.0.r136.gfb08ece-1`.
|
Here's a list of package dependencies, names are from arch-packages so they
|
||||||
|
might differ slightly depending on your distro.
|
||||||
|
|
||||||
|
```
|
||||||
|
python python-pillow python-materialyoucolor libnotify cava
|
||||||
|
app2unit wl-clipboard dconf cliphist python-typer qt6-canvaspainter
|
||||||
|
python-build python-installer python-hatch python-hatch-vcs cmake ninja
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure to have the newest Quickshell version! As of writing, version `0.3.1.r1.g0fed22a-1`.
|
||||||
|
|
||||||
Used by major features (install as needed for your setup):
|
Used by major features (install as needed for your setup):
|
||||||
|
|
||||||
- `app2unit` (launcher app execution)
|
- `app2unit` (launcher app execution)
|
||||||
- `nmcli` (network integration)
|
- `nmcli` (network integration)
|
||||||
- `brightnessctl`, `ddcutil` (brightness controls)
|
- `brightnessctl`, `ddcutil` (brightness controls)
|
||||||
- `wl-copy`, `swappy` (picker/screenshot flow)
|
- `wl-copy` (clipboard integration)
|
||||||
- `libqalculate` (launcher calculator)
|
|
||||||
- `PipeWire` + audio stack + `aubio`/`cava` paths for media visualization
|
- `PipeWire` + audio stack + `aubio`/`cava` paths for media visualization
|
||||||
|
- `libqalculate` (launcher calculator)
|
||||||
- `gsettings` (optional GTK dark/light mode sync)
|
- `gsettings` (optional GTK dark/light mode sync)
|
||||||
|
|
||||||
## Build and Install
|
## Build and Install
|
||||||
@@ -180,7 +188,7 @@ Important state/cache files:
|
|||||||
- `~/.local/state/zshell/apps.sqlite`
|
- `~/.local/state/zshell/apps.sqlite`
|
||||||
- `~/.cache/zshell/`
|
- `~/.cache/zshell/`
|
||||||
|
|
||||||
Config is hot-reloaded and saved through `Config/Config.qml` serializers. Top-level sections include:
|
Config is hot-reloaded and saved through the `Config` singleton. Top-level sections include:
|
||||||
|
|
||||||
- `general`
|
- `general`
|
||||||
- `appearance`
|
- `appearance`
|
||||||
@@ -265,7 +273,6 @@ Note: Template rendering (Jinja2) applies generated colors to `~/.config/zshell/
|
|||||||
### `screenshot` — area picker
|
### `screenshot` — area picker
|
||||||
|
|
||||||
- `start` — open interactive area picker
|
- `start` — open interactive area picker
|
||||||
- `start-freeze` — freeze screen then pick
|
|
||||||
|
|
||||||
### `wallpaper` — wallpaper management
|
### `wallpaper` — wallpaper management
|
||||||
|
|
||||||
|
|||||||
@@ -1,452 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from collections import defaultdict
|
|
||||||
from functools import cache
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def read_lines(path: Path) -> tuple[str, ...]:
|
|
||||||
return tuple(path.read_text().splitlines())
|
|
||||||
|
|
||||||
|
|
||||||
ROW_RE = re.compile(
|
|
||||||
r"^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|DefaultRow)\s*\{"
|
|
||||||
)
|
|
||||||
LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)')
|
|
||||||
ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"')
|
|
||||||
CHECKED_RE = re.compile(r"^\s*checked:\s*(?:Config)\.([\w.]+)\s*$")
|
|
||||||
ONTOGGLED_RE = re.compile(
|
|
||||||
r"^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$"
|
|
||||||
)
|
|
||||||
ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
|
|
||||||
SKIP_LABELS = {"Muted", "None"}
|
|
||||||
FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4}
|
|
||||||
STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "on", "in", "for"}
|
|
||||||
|
|
||||||
|
|
||||||
def find_pages_dir(settings: Path) -> Path:
|
|
||||||
return settings / "Pages"
|
|
||||||
|
|
||||||
|
|
||||||
def discover_files(settings: Path) -> dict[str, Path]:
|
|
||||||
files: dict[str, Path] = {}
|
|
||||||
for p in find_pages_dir(settings).rglob("*.qml"):
|
|
||||||
files[p.stem] = p
|
|
||||||
return files
|
|
||||||
|
|
||||||
|
|
||||||
PAGE_NAME_RE = re.compile(r'^\s*name:\s*qsTr\("([^"]+)"\)')
|
|
||||||
PAGE_ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
|
|
||||||
|
|
||||||
|
|
||||||
def parse_page_registry(settings: Path) -> list[tuple[str, str]]:
|
|
||||||
text = (settings / "PageRegistry.qml").read_text().splitlines()
|
|
||||||
|
|
||||||
start = next(
|
|
||||||
i for i, line in enumerate(text) if re.search(r"\bpages\s*:\s*\[", line)
|
|
||||||
)
|
|
||||||
|
|
||||||
out: list[tuple[str, str]] = []
|
|
||||||
i = start + 1
|
|
||||||
|
|
||||||
while i < len(text):
|
|
||||||
line = text[i].strip()
|
|
||||||
|
|
||||||
if line.startswith("]"):
|
|
||||||
break
|
|
||||||
|
|
||||||
if line.startswith("//") or not line:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if line.startswith("{"):
|
|
||||||
name = None
|
|
||||||
icon = None
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
while i < len(text):
|
|
||||||
s = text[i].strip()
|
|
||||||
|
|
||||||
if s.startswith("}"):
|
|
||||||
if name is not None:
|
|
||||||
out.append((icon or "tune", name))
|
|
||||||
break
|
|
||||||
|
|
||||||
if name is None:
|
|
||||||
m = PAGE_NAME_RE.match(text[i])
|
|
||||||
if m:
|
|
||||||
name = m.group(1)
|
|
||||||
|
|
||||||
if icon is None:
|
|
||||||
mi = PAGE_ICON_RE.match(text[i])
|
|
||||||
if mi:
|
|
||||||
icon = mi.group(1)
|
|
||||||
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
BLOCK_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$")
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_comment(line: str) -> str:
|
|
||||||
return line.split("//", 1)[0].rstrip()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_block(
|
|
||||||
lines: list[str], i: int
|
|
||||||
) -> tuple[str, list[tuple[str, list]], int]:
|
|
||||||
line = _strip_comment(lines[i]).strip()
|
|
||||||
m = BLOCK_RE.match(line)
|
|
||||||
if not m:
|
|
||||||
raise ValueError(f"Expected block start at line {i + 1}: {lines[i]!r}")
|
|
||||||
|
|
||||||
name = m.group(1)
|
|
||||||
i += 1
|
|
||||||
children: list[tuple[str, list]] = []
|
|
||||||
|
|
||||||
while i < len(lines):
|
|
||||||
s = _strip_comment(lines[i]).strip()
|
|
||||||
if not s:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if s.startswith("}"):
|
|
||||||
return name, children, i + 1
|
|
||||||
|
|
||||||
if BLOCK_RE.match(s):
|
|
||||||
child_name, child_children, i = parse_block(lines, i)
|
|
||||||
children.append((child_name, child_children))
|
|
||||||
continue
|
|
||||||
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
raise ValueError(f"Unterminated block: {name}")
|
|
||||||
|
|
||||||
|
|
||||||
def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]:
|
|
||||||
name, children = block
|
|
||||||
|
|
||||||
if name != "Component":
|
|
||||||
return [name]
|
|
||||||
|
|
||||||
for child_name, child_children in children:
|
|
||||||
if child_name == "StackPage":
|
|
||||||
out: list[str] = []
|
|
||||||
for grand_name, grand_children in child_children:
|
|
||||||
if grand_name == "Component":
|
|
||||||
out.extend(collect_page_names((grand_name, grand_children)))
|
|
||||||
return out
|
|
||||||
|
|
||||||
if child_name != "Component":
|
|
||||||
return [child_name]
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def parse_page_comps(settings: Path) -> list[list[str]]:
|
|
||||||
text = (settings / "PageCompRegistry.qml").read_text().splitlines()
|
|
||||||
|
|
||||||
start = next(
|
|
||||||
i
|
|
||||||
for i, line in enumerate(text)
|
|
||||||
if re.search(r"\bpageComps\s*:\s*\[", _strip_comment(line))
|
|
||||||
)
|
|
||||||
|
|
||||||
comps: list[list[str]] = []
|
|
||||||
i = start + 1
|
|
||||||
|
|
||||||
while i < len(text):
|
|
||||||
s = _strip_comment(text[i]).strip()
|
|
||||||
if not s:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
if s.startswith("]"):
|
|
||||||
break
|
|
||||||
|
|
||||||
if BLOCK_RE.match(s) and BLOCK_RE.match(s).group(1) == "Component":
|
|
||||||
block = parse_block(text, i)
|
|
||||||
names = collect_page_names((block[0], block[1]))
|
|
||||||
if names:
|
|
||||||
comps.append(names)
|
|
||||||
i = block[2]
|
|
||||||
continue
|
|
||||||
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
return comps
|
|
||||||
|
|
||||||
|
|
||||||
def dedup_crumbs(
|
|
||||||
labels: list[str], icons: list[str]
|
|
||||||
) -> tuple[list[str], list[str]]:
|
|
||||||
out_labels: list[str] = []
|
|
||||||
out_icons: list[str] = []
|
|
||||||
for lbl, ico in zip(labels, icons, strict=False):
|
|
||||||
if out_labels and out_labels[-1] == lbl:
|
|
||||||
continue
|
|
||||||
out_labels.append(lbl)
|
|
||||||
out_icons.append(ico)
|
|
||||||
return out_labels, out_icons
|
|
||||||
|
|
||||||
|
|
||||||
def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
|
|
||||||
comps = parse_page_comps(settings)
|
|
||||||
registry = parse_page_registry(settings)
|
|
||||||
|
|
||||||
top_meta: dict[int, tuple[str, str]] = {}
|
|
||||||
for i, (icon, label) in enumerate(registry):
|
|
||||||
top_meta[i] = (icon, label)
|
|
||||||
|
|
||||||
nav_children: dict[str, dict[int, tuple[str, str, str]]] = {}
|
|
||||||
for names in comps:
|
|
||||||
for name in names:
|
|
||||||
pf = files.get(name)
|
|
||||||
if not pf:
|
|
||||||
continue
|
|
||||||
pending_icon = pending_label = None
|
|
||||||
section = ""
|
|
||||||
expect_section = False
|
|
||||||
for ln in read_lines(pf):
|
|
||||||
if SECTION_RE.match(ln):
|
|
||||||
expect_section = True
|
|
||||||
continue
|
|
||||||
ml = LABEL_RE.match(ln)
|
|
||||||
if ml:
|
|
||||||
if expect_section:
|
|
||||||
section = ml.group(1)
|
|
||||||
expect_section = False
|
|
||||||
else:
|
|
||||||
pending_label = ml.group(1)
|
|
||||||
continue
|
|
||||||
mi = ICON_RE.match(ln)
|
|
||||||
if mi:
|
|
||||||
pending_icon = mi.group(1)
|
|
||||||
mo = re.search(r"openSubPage\((\d+)\)", ln)
|
|
||||||
if mo:
|
|
||||||
pos = int(mo.group(1))
|
|
||||||
nav_children.setdefault(name, {})[pos] = (
|
|
||||||
pending_icon or "tune",
|
|
||||||
pending_label or "",
|
|
||||||
section,
|
|
||||||
)
|
|
||||||
pending_icon = pending_label = None
|
|
||||||
|
|
||||||
nav: dict[str, dict] = {}
|
|
||||||
for top_idx, names in enumerate(comps):
|
|
||||||
if not names:
|
|
||||||
continue
|
|
||||||
main = names[0]
|
|
||||||
main_icon, main_label = top_meta.get(top_idx, ("tune", main))
|
|
||||||
nav[main] = {
|
|
||||||
"pageIdx": top_idx,
|
|
||||||
"subPath": [],
|
|
||||||
"crumbIcons": [main_icon],
|
|
||||||
"crumbLabels": [main_label],
|
|
||||||
}
|
|
||||||
children = dict(nav_children.get(main, {}))
|
|
||||||
opened_via_subpage = set()
|
|
||||||
for owner, kids in nav_children.items():
|
|
||||||
owner_group = next((ns for ns in comps if owner in ns), None)
|
|
||||||
if not owner_group:
|
|
||||||
continue
|
|
||||||
for kpos in kids:
|
|
||||||
if kpos < len(owner_group):
|
|
||||||
opened_via_subpage.add(owner_group[kpos])
|
|
||||||
for pos in range(1, len(names)):
|
|
||||||
if pos not in children and names[pos] not in opened_via_subpage:
|
|
||||||
label = re.sub(r"(Detail)?Page$", "", names[pos])
|
|
||||||
label = re.sub(r"(?<!^)(?=[A-Z])", " ", label)
|
|
||||||
children[pos] = (main_icon, label, "")
|
|
||||||
for pos, (icon, label, section) in children.items():
|
|
||||||
if pos >= len(names):
|
|
||||||
continue
|
|
||||||
child = names[pos]
|
|
||||||
labels = [main_label] + ([section] if section else []) + [label]
|
|
||||||
icons = [main_icon] + ([icon] if section else []) + [icon]
|
|
||||||
labels, icons = dedup_crumbs(labels, icons)
|
|
||||||
nav[child] = {
|
|
||||||
"pageIdx": top_idx,
|
|
||||||
"subPath": [pos],
|
|
||||||
"crumbIcons": icons,
|
|
||||||
"crumbLabels": labels,
|
|
||||||
}
|
|
||||||
for gpos, (gicon, glabel, gsection) in nav_children.get(
|
|
||||||
child, {}
|
|
||||||
).items():
|
|
||||||
if gpos >= len(names):
|
|
||||||
continue
|
|
||||||
glabels = labels + ([gsection] if gsection else []) + [glabel]
|
|
||||||
gicons = icons + ([gicon] if gsection else []) + [gicon]
|
|
||||||
glabels, gicons = dedup_crumbs(glabels, gicons)
|
|
||||||
nav[names[gpos]] = {
|
|
||||||
"pageIdx": top_idx,
|
|
||||||
"subPath": [pos, gpos],
|
|
||||||
"crumbIcons": gicons,
|
|
||||||
"crumbLabels": glabels,
|
|
||||||
}
|
|
||||||
return nav
|
|
||||||
|
|
||||||
|
|
||||||
def tokenize(text: str) -> list[str]:
|
|
||||||
toks: list[str] = []
|
|
||||||
for word in text.lower().split():
|
|
||||||
parts = [p for p in re.split(r"[^a-z0-9]+", word) if p]
|
|
||||||
for p in parts:
|
|
||||||
if p not in STOPWORDS and p not in toks:
|
|
||||||
toks.append(p)
|
|
||||||
if len(parts) > 1:
|
|
||||||
joined = "".join(parts)
|
|
||||||
if joined not in toks:
|
|
||||||
toks.append(joined)
|
|
||||||
return toks
|
|
||||||
|
|
||||||
|
|
||||||
SUBTEXT_RE = re.compile(r'^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)')
|
|
||||||
SECTION_RE = re.compile(r"^\s*SectionHeader\s*\{")
|
|
||||||
|
|
||||||
|
|
||||||
def extract_settings(
|
|
||||||
files: dict[str, Path], nav: dict[str, dict]
|
|
||||||
) -> list[dict]:
|
|
||||||
entries: list[dict] = []
|
|
||||||
for comp, meta in nav.items():
|
|
||||||
pf = files.get(comp)
|
|
||||||
if not pf:
|
|
||||||
continue
|
|
||||||
lines = read_lines(pf)
|
|
||||||
section = ""
|
|
||||||
i = 0
|
|
||||||
while i < len(lines):
|
|
||||||
if SECTION_RE.match(lines[i]):
|
|
||||||
for j in range(i + 1, min(i + 4, len(lines))):
|
|
||||||
m = LABEL_RE.match(lines[j])
|
|
||||||
if m:
|
|
||||||
section = m.group(1)
|
|
||||||
break
|
|
||||||
row_match = ROW_RE.match(lines[i])
|
|
||||||
if row_match:
|
|
||||||
row_type = row_match.group(1)
|
|
||||||
label = anchor = subtext = None
|
|
||||||
checked_path = toggled_path = None
|
|
||||||
for j in range(i + 1, min(i + 12, len(lines))):
|
|
||||||
if label is None:
|
|
||||||
m = LABEL_RE.match(lines[j])
|
|
||||||
if m:
|
|
||||||
label = m.group(1)
|
|
||||||
if anchor is None:
|
|
||||||
a = ANCHOR_RE.match(lines[j])
|
|
||||||
if a:
|
|
||||||
anchor = a.group(1)
|
|
||||||
if subtext is None:
|
|
||||||
st = SUBTEXT_RE.match(lines[j])
|
|
||||||
if st:
|
|
||||||
subtext = st.group(1)
|
|
||||||
if checked_path is None:
|
|
||||||
ch = CHECKED_RE.match(lines[j])
|
|
||||||
if ch:
|
|
||||||
checked_path = ch.group(1)
|
|
||||||
if toggled_path is None:
|
|
||||||
tg = ONTOGGLED_RE.match(lines[j])
|
|
||||||
if tg:
|
|
||||||
toggled_path = tg.group(1)
|
|
||||||
toggle_path = (
|
|
||||||
checked_path
|
|
||||||
if row_type == "ToggleRow"
|
|
||||||
and checked_path
|
|
||||||
and checked_path == toggled_path
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
if label and label not in SKIP_LABELS and anchor:
|
|
||||||
extra = (
|
|
||||||
" ".join(meta["crumbLabels"])
|
|
||||||
+ " "
|
|
||||||
+ section
|
|
||||||
+ " "
|
|
||||||
+ (subtext or "")
|
|
||||||
)
|
|
||||||
entries.append(
|
|
||||||
{
|
|
||||||
"pageIdx": meta["pageIdx"],
|
|
||||||
"subPath": meta["subPath"],
|
|
||||||
"crumbIcons": meta["crumbIcons"],
|
|
||||||
"crumbLabels": meta["crumbLabels"],
|
|
||||||
"title": label,
|
|
||||||
"anchor": anchor,
|
|
||||||
"section": section,
|
|
||||||
"subtext": subtext or "",
|
|
||||||
"togglePath": toggle_path,
|
|
||||||
"keywords": " ".join(
|
|
||||||
sorted(set(tokenize(label + " " + extra)))
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
i += 1
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def build_inverted_and_ranking(entries: list[dict]):
|
|
||||||
inverted: dict[str, list[int]] = defaultdict(list)
|
|
||||||
ranking: dict[str, dict[int, float]] = defaultdict(dict)
|
|
||||||
for idx, e in enumerate(entries):
|
|
||||||
fields = {"title": e["title"], "keywords": e["keywords"]}
|
|
||||||
seen: set[str] = set()
|
|
||||||
for field, text in fields.items():
|
|
||||||
weight = FIELD_WEIGHT.get(field, 0.2)
|
|
||||||
for tok in tokenize(text):
|
|
||||||
if idx not in inverted[tok]:
|
|
||||||
inverted[tok].append(idx)
|
|
||||||
ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight)
|
|
||||||
seen.add(tok)
|
|
||||||
for tok, ids in inverted.items():
|
|
||||||
ids.sort(key=lambda i: ranking[tok][i], reverse=True)
|
|
||||||
return inverted, {
|
|
||||||
t: {str(k): v for k, v in d.items()} for t, d in ranking.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
print(__doc__)
|
|
||||||
return 1
|
|
||||||
settings = Path(sys.argv[1])
|
|
||||||
out = Path(sys.argv[2])
|
|
||||||
files = discover_files(settings)
|
|
||||||
nav = build_nav_map(settings, files)
|
|
||||||
entries = extract_settings(files, nav)
|
|
||||||
inverted, ranking = build_inverted_and_ranking(entries)
|
|
||||||
for e in entries:
|
|
||||||
e.pop("keywords", None)
|
|
||||||
out.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"version": 2,
|
|
||||||
"entries": entries,
|
|
||||||
"inverted": inverted,
|
|
||||||
"ranking": ranking,
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
indent=2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
f"settings index: {len(entries)} entries, "
|
|
||||||
f"{len(inverted)} tokens -> {out}"
|
|
||||||
)
|
|
||||||
print("files:", len(files))
|
|
||||||
print("comps:", len(parse_page_comps(settings)))
|
|
||||||
print("registry:", len(parse_page_registry(settings)))
|
|
||||||
print("nav:", len(nav))
|
|
||||||
print("entries:", len(entries))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
.pragma library
|
||||||
|
|
||||||
|
const STOPWORDS = [
|
||||||
|
"a",
|
||||||
|
"an",
|
||||||
|
"and",
|
||||||
|
"are",
|
||||||
|
"for",
|
||||||
|
"in",
|
||||||
|
"not",
|
||||||
|
"notification",
|
||||||
|
"of",
|
||||||
|
"on",
|
||||||
|
"or",
|
||||||
|
"out",
|
||||||
|
"the",
|
||||||
|
"to",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FIELD_WEIGHT = {
|
||||||
|
title: 1.0,
|
||||||
|
keywords: 0.4,
|
||||||
|
};
|
||||||
|
const SKIP_LABELS = ["Muted", "None"];
|
||||||
|
|
||||||
|
function cleanLabel(text) {
|
||||||
|
return String(text ?? "")
|
||||||
|
.replace(/\s*\(?%\d+\)?/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_NAME_RE = /^\s*name:\s*qsTr\("([^"]+)"\)/;
|
||||||
|
const ROW_RE =
|
||||||
|
/^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|RowButton|InfoRow|PopupRow|DefaultRow|TextFieldRow|IconTextButton|TimeDialogSelect)\s*\{/;
|
||||||
|
const NAV_ROW_TYPES = ["NavRow", "IconTextButton"];
|
||||||
|
const LABEL_RE = /^\s*(?:label|text):\s*qsTr\("([^"]+)"\)/;
|
||||||
|
const ANCHOR_RE = /^\s*(?:property\s+string\s+)?settingAnchor:\s*"([^"]+)"/;
|
||||||
|
const CHECKED_RE = /^\s*checked:\s*(?:GlobalConfig|Config)\.([\w.]+)\s*$/;
|
||||||
|
const ONTOGGLED_RE =
|
||||||
|
/^\s*onToggled:\s*(?:GlobalConfig|Config)\.([\w.]+)\s*=\s*checked\s*$/;
|
||||||
|
const ICON_RE = /^\s*icon:\s*"([^"]+)"/;
|
||||||
|
const SUBTEXT_RE = /^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)/;
|
||||||
|
const SECTION_RE = /^\s*SectionHeader\s*\{/;
|
||||||
|
|
||||||
|
function tokenize(text) {
|
||||||
|
const toks = [];
|
||||||
|
for (const word of text.toLowerCase().split(/\s+/)) {
|
||||||
|
if (!word) continue;
|
||||||
|
const parts = word.split(/[^a-z0-9]+/).filter((p) => p);
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!STOPWORDS.includes(p) && !toks.includes(p)) toks.push(p);
|
||||||
|
}
|
||||||
|
if (parts.length > 1) {
|
||||||
|
const joined = parts.join("");
|
||||||
|
if (!toks.includes(joined)) toks.push(joined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverFiles(settingsDir, listFiles) {
|
||||||
|
const files = {};
|
||||||
|
for (const p of listFiles(`${settingsDir}/Pages`, ".qml")) {
|
||||||
|
const name = p.slice(p.lastIndexOf("/") + 1).replace(/\.qml$/, "");
|
||||||
|
files[name] = p;
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePageRegistry(settingsDir, readLines) {
|
||||||
|
const lines = readLines(`${settingsDir}/PageRegistry.qml`);
|
||||||
|
const out = [];
|
||||||
|
let inArray = false;
|
||||||
|
let depth = 0;
|
||||||
|
let label = null;
|
||||||
|
let icon = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
const s = line.trim();
|
||||||
|
if (s.includes("pages:") && s.includes("[")) {
|
||||||
|
inArray = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inArray) continue;
|
||||||
|
if (s.startsWith("//")) continue;
|
||||||
|
if (s.startsWith("]")) break;
|
||||||
|
if (s.startsWith("{")) {
|
||||||
|
depth++;
|
||||||
|
label = icon = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (s.startsWith("}")) {
|
||||||
|
if (label !== null) out.push([icon || "tune", label]);
|
||||||
|
depth--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (depth >= 1) {
|
||||||
|
const m = PAGE_NAME_RE.exec(line);
|
||||||
|
if (m && label === null) label = m[1];
|
||||||
|
const mi = ICON_RE.exec(line);
|
||||||
|
if (mi && icon === null) icon = mi[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePageComps(settingsDir, readFile) {
|
||||||
|
const text = readFile(`${settingsDir}/PageCompRegistry.qml`);
|
||||||
|
const start = text.indexOf("pageComps:");
|
||||||
|
if (start === -1) return [];
|
||||||
|
const comps = [];
|
||||||
|
let current = null;
|
||||||
|
let depth = 0;
|
||||||
|
for (const raw of text.slice(start).split("\n")) {
|
||||||
|
const line = raw.split("//")[0];
|
||||||
|
const s = line.trim();
|
||||||
|
if (s.startsWith("]")) break;
|
||||||
|
const atTop = depth === 0;
|
||||||
|
depth +=
|
||||||
|
(line.match(/\{/g) ?? []).length -
|
||||||
|
(line.match(/\}/g) ?? []).length;
|
||||||
|
if (atTop && /^Component\s*\{/.test(s)) {
|
||||||
|
current = [];
|
||||||
|
comps.push(current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current !== null) {
|
||||||
|
const m = /(^|\s)([A-Z][A-Za-z0-9]*)\s*\{\s*\}/.exec(s);
|
||||||
|
if (m) current.push(m[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return comps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupCrumbs(labels, icons) {
|
||||||
|
const outLabels = [];
|
||||||
|
const outIcons = [];
|
||||||
|
for (let i = 0; i < labels.length; i++) {
|
||||||
|
if (
|
||||||
|
outLabels.length > 0 &&
|
||||||
|
outLabels[outLabels.length - 1] === labels[i]
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
outLabels.push(labels[i]);
|
||||||
|
outIcons.push(icons[i]);
|
||||||
|
}
|
||||||
|
return [outLabels, outIcons];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNavMap(settingsDir, files, readFile, readLines) {
|
||||||
|
const comps = parsePageComps(settingsDir, readFile);
|
||||||
|
const registry = parsePageRegistry(settingsDir, readLines);
|
||||||
|
|
||||||
|
const navChildren = {};
|
||||||
|
for (const names of comps) {
|
||||||
|
for (const name of names) {
|
||||||
|
const pf = files[name];
|
||||||
|
if (!pf) continue;
|
||||||
|
let pendingIcon = null;
|
||||||
|
let pendingLabel = null;
|
||||||
|
let section = "";
|
||||||
|
let expectSection = false;
|
||||||
|
for (const ln of readLines(pf)) {
|
||||||
|
if (SECTION_RE.test(ln)) {
|
||||||
|
expectSection = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ml = LABEL_RE.exec(ln);
|
||||||
|
if (ml) {
|
||||||
|
if (expectSection) {
|
||||||
|
section = ml[1];
|
||||||
|
expectSection = false;
|
||||||
|
} else {
|
||||||
|
pendingLabel = ml[1];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const mi = ICON_RE.exec(ln);
|
||||||
|
if (mi) pendingIcon = mi[1];
|
||||||
|
const mo = /openSubPage\((\d+)\)/.exec(ln);
|
||||||
|
if (mo) {
|
||||||
|
const pos = parseInt(mo[1], 10);
|
||||||
|
if (!navChildren[name]) navChildren[name] = {};
|
||||||
|
navChildren[name][pos] = [
|
||||||
|
pendingIcon || "tune",
|
||||||
|
pendingLabel || "",
|
||||||
|
section,
|
||||||
|
];
|
||||||
|
pendingIcon = pendingLabel = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav = {};
|
||||||
|
for (let topIdx = 0; topIdx < comps.length; topIdx++) {
|
||||||
|
const names = comps[topIdx];
|
||||||
|
if (names.length === 0) continue;
|
||||||
|
const main = names[0];
|
||||||
|
const [mainIcon, mainLabel] = registry[topIdx] ?? ["tune", main];
|
||||||
|
nav[main] = {
|
||||||
|
pageIdx: topIdx,
|
||||||
|
subPath: [],
|
||||||
|
crumbIcons: [mainIcon],
|
||||||
|
crumbLabels: [mainLabel],
|
||||||
|
};
|
||||||
|
const children = Object.assign({}, navChildren[main] ?? {});
|
||||||
|
const openedViaSubpage = [];
|
||||||
|
for (const owner in navChildren) {
|
||||||
|
const ownerGroup = comps.find((ns) => ns.includes(owner));
|
||||||
|
if (!ownerGroup) continue;
|
||||||
|
for (const kpos in navChildren[owner]) {
|
||||||
|
const k = parseInt(kpos, 10);
|
||||||
|
if (k < ownerGroup.length) openedViaSubpage.push(ownerGroup[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let pos = 1; pos < names.length; pos++) {
|
||||||
|
if (!(pos in children) && !openedViaSubpage.includes(names[pos])) {
|
||||||
|
let label = names[pos].replace(/(Detail)?Page$/, "");
|
||||||
|
label = label.replace(/(?<!^)(?=[A-Z])/g, " ");
|
||||||
|
children[pos] = [mainIcon, label, ""];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const posKey in children) {
|
||||||
|
const pos = parseInt(posKey, 10);
|
||||||
|
if (pos >= names.length) continue;
|
||||||
|
const [icon, label, section] = children[posKey];
|
||||||
|
const child = names[pos];
|
||||||
|
let labels = [mainLabel]
|
||||||
|
.concat(section ? [section] : [])
|
||||||
|
.concat([label]);
|
||||||
|
let icons = [mainIcon].concat(section ? [icon] : []).concat([icon]);
|
||||||
|
[labels, icons] = dedupCrumbs(labels, icons);
|
||||||
|
nav[child] = {
|
||||||
|
pageIdx: topIdx,
|
||||||
|
subPath: [pos],
|
||||||
|
crumbIcons: icons,
|
||||||
|
crumbLabels: labels,
|
||||||
|
};
|
||||||
|
const grandChildren = navChildren[child] ?? {};
|
||||||
|
for (const gposKey in grandChildren) {
|
||||||
|
const gpos = parseInt(gposKey, 10);
|
||||||
|
if (gpos >= names.length) continue;
|
||||||
|
const [gicon, glabel, gsection] = grandChildren[gposKey];
|
||||||
|
let glabels = labels
|
||||||
|
.concat(gsection ? [gsection] : [])
|
||||||
|
.concat([glabel]);
|
||||||
|
let gicons = icons
|
||||||
|
.concat(gsection ? [gicon] : [])
|
||||||
|
.concat([gicon]);
|
||||||
|
[glabels, gicons] = dedupCrumbs(glabels, gicons);
|
||||||
|
nav[names[gpos]] = {
|
||||||
|
pageIdx: topIdx,
|
||||||
|
subPath: [pos, gpos],
|
||||||
|
crumbIcons: gicons,
|
||||||
|
crumbLabels: glabels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nav;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBlockEnd(lines, start) {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = start; i < lines.length; i++) {
|
||||||
|
const line = lines[i].split("//")[0];
|
||||||
|
depth +=
|
||||||
|
(line.match(/\{/g) ?? []).length -
|
||||||
|
(line.match(/\}/g) ?? []).length;
|
||||||
|
if (i > start && depth <= 0) return i;
|
||||||
|
}
|
||||||
|
return lines.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSettings(files, nav, comps, readLines) {
|
||||||
|
const entries = [];
|
||||||
|
for (const comp in nav) {
|
||||||
|
const meta = nav[comp];
|
||||||
|
const pf = files[comp];
|
||||||
|
if (!pf) continue;
|
||||||
|
const lines = readLines(pf);
|
||||||
|
let section = "";
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (SECTION_RE.test(lines[i])) {
|
||||||
|
for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
|
||||||
|
const m = LABEL_RE.exec(lines[j]);
|
||||||
|
if (m) {
|
||||||
|
section = m[1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rowMatch = ROW_RE.exec(lines[i]);
|
||||||
|
if (!rowMatch) continue;
|
||||||
|
const rowType = rowMatch[1];
|
||||||
|
let label = null;
|
||||||
|
let anchor = null;
|
||||||
|
let subtext = null;
|
||||||
|
let checkedPath = null;
|
||||||
|
let toggledPath = null;
|
||||||
|
let targetPos = null;
|
||||||
|
const rowEnd = findBlockEnd(lines, i);
|
||||||
|
for (let j = i + 1; j < rowEnd; j++) {
|
||||||
|
if (label === null) {
|
||||||
|
const m = LABEL_RE.exec(lines[j]);
|
||||||
|
if (m) label = m[1];
|
||||||
|
}
|
||||||
|
if (anchor === null) {
|
||||||
|
const a = ANCHOR_RE.exec(lines[j]);
|
||||||
|
if (a) anchor = a[1];
|
||||||
|
}
|
||||||
|
if (subtext === null) {
|
||||||
|
const st = SUBTEXT_RE.exec(lines[j]);
|
||||||
|
if (st) subtext = st[1];
|
||||||
|
}
|
||||||
|
if (checkedPath === null) {
|
||||||
|
const ch = CHECKED_RE.exec(lines[j]);
|
||||||
|
if (ch) checkedPath = ch[1];
|
||||||
|
}
|
||||||
|
if (toggledPath === null) {
|
||||||
|
const tg = ONTOGGLED_RE.exec(lines[j]);
|
||||||
|
if (tg) toggledPath = tg[1];
|
||||||
|
}
|
||||||
|
if (NAV_ROW_TYPES.includes(rowType) && targetPos === null) {
|
||||||
|
const sp = /openSubPage\((\d+)\)/.exec(lines[j]);
|
||||||
|
if (sp) targetPos = parseInt(sp[1], 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const togglePath =
|
||||||
|
rowType === "ToggleRow" &&
|
||||||
|
checkedPath &&
|
||||||
|
checkedPath === toggledPath
|
||||||
|
? checkedPath
|
||||||
|
: "";
|
||||||
|
if (label && !SKIP_LABELS.includes(label) && anchor) {
|
||||||
|
const extra =
|
||||||
|
meta.crumbLabels.join(" ") +
|
||||||
|
" " +
|
||||||
|
section +
|
||||||
|
" " +
|
||||||
|
(subtext && !/%\d/.test(subtext) ? subtext : "");
|
||||||
|
const group = comps[meta.pageIdx] ?? [];
|
||||||
|
const targetSubPath =
|
||||||
|
targetPos !== null && targetPos < group.length
|
||||||
|
? meta.subPath.concat([targetPos])
|
||||||
|
: [];
|
||||||
|
entries.push({
|
||||||
|
rowType: rowType,
|
||||||
|
pageIdx: meta.pageIdx,
|
||||||
|
subPath: meta.subPath,
|
||||||
|
targetSubPath: targetSubPath,
|
||||||
|
crumbIcons: meta.crumbIcons,
|
||||||
|
crumbLabels: meta.crumbLabels,
|
||||||
|
trailKey: meta.crumbLabels.join("/"),
|
||||||
|
title: cleanLabel(label),
|
||||||
|
anchor: anchor,
|
||||||
|
section: section,
|
||||||
|
subtext: subtext && !/%\d/.test(subtext) ? subtext : "",
|
||||||
|
togglePath: togglePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergeInfoRows(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeInfoRows(entries) {
|
||||||
|
const out = [];
|
||||||
|
const merged = {};
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const isInfo = entry.rowType === "InfoRow";
|
||||||
|
delete entry.rowType;
|
||||||
|
|
||||||
|
if (!isInfo || !entry.section) {
|
||||||
|
out.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key =
|
||||||
|
entry.anchor.split("-")[0] +
|
||||||
|
"/" +
|
||||||
|
entry.trailKey +
|
||||||
|
"/" +
|
||||||
|
entry.section;
|
||||||
|
const existing = merged[key];
|
||||||
|
if (existing === undefined) {
|
||||||
|
entry.keywords = entry.title;
|
||||||
|
entry.title = entry.section;
|
||||||
|
merged[key] = entry;
|
||||||
|
out.push(entry);
|
||||||
|
} else {
|
||||||
|
existing.keywords += " " + entry.title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInvertedAndRanking(entries) {
|
||||||
|
const inverted = {};
|
||||||
|
const ranking = {};
|
||||||
|
for (let idx = 0; idx < entries.length; idx++) {
|
||||||
|
const e = entries[idx];
|
||||||
|
const extra =
|
||||||
|
e.crumbLabels.join(" ") +
|
||||||
|
" " +
|
||||||
|
e.section +
|
||||||
|
" " +
|
||||||
|
e.subtext +
|
||||||
|
" " +
|
||||||
|
(e.keywords ?? "");
|
||||||
|
const fields = {
|
||||||
|
title: e.title,
|
||||||
|
keywords: tokenize(e.title + " " + extra)
|
||||||
|
.sort()
|
||||||
|
.join(" "),
|
||||||
|
};
|
||||||
|
for (const field in fields) {
|
||||||
|
const weight = FIELD_WEIGHT[field] ?? 0.2;
|
||||||
|
for (const tok of tokenize(fields[field])) {
|
||||||
|
if (!inverted[tok]) inverted[tok] = [];
|
||||||
|
if (!inverted[tok].includes(idx)) inverted[tok].push(idx);
|
||||||
|
if (!ranking[tok]) ranking[tok] = {};
|
||||||
|
ranking[tok][idx] = Math.max(ranking[tok][idx] ?? 0.0, weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const tok in inverted)
|
||||||
|
inverted[tok].sort((a, b) => ranking[tok][b] - ranking[tok][a]);
|
||||||
|
return [inverted, ranking];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIndex(settingsDir, readFile, listFiles) {
|
||||||
|
const lineCache = {};
|
||||||
|
const readLines = (path) => {
|
||||||
|
if (!(path in lineCache)) lineCache[path] = readFile(path).split("\n");
|
||||||
|
return lineCache[path];
|
||||||
|
};
|
||||||
|
const files = discoverFiles(settingsDir, listFiles);
|
||||||
|
const nav = buildNavMap(settingsDir, files, readFile, readLines);
|
||||||
|
const comps = parsePageComps(settingsDir, readFile);
|
||||||
|
const entries = extractSettings(files, nav, comps, readLines);
|
||||||
|
const [inverted, ranking] = buildInvertedAndRanking(entries);
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
entries: entries,
|
||||||
|
inverted: inverted,
|
||||||
|
ranking: ranking,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user