From fcb848b6d253f988e38635948e977b43d503b745 Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 3 Sep 2026 22:46:46 +0200 Subject: [PATCH] fix: truncate webfetch content to fit context --- Components/CustomRect.qml | 3 +- Components/CustomScrollBar.qml | 6 +- Modules/Launcher/AppList.qml | 19 +- Modules/Launcher/Content.qml | 2 + Modules/Launcher/Items/ActionItem.qml | 2 +- Modules/Launcher/Items/AppItem.qml | 2 +- Modules/Launcher/Items/CalcItem.qml | 5 +- Modules/Launcher/Items/FilesItem.qml | 141 ++++++++ Modules/Launcher/Items/VariantItem.qml | 2 +- Modules/Launcher/Services/Files.qml | 194 +++++++++++ .../Sidebar/Chat/ChatContent.qml | 2 +- .../Notifications/Sidebar/Chat/ChatList.qml | 35 +- .../Notifications/Sidebar/Chat/ChatState.qml | 4 + .../Sidebar/Chat/Content/ContentBubble.qml | 53 ++- .../Sidebar/Chat/Content/MessageDelegate.qml | 2 + .../Sidebar/Chat/Content/ProcessBlock.qml | 227 ++++++++++++- .../Sidebar/Chat/SidebarView.qml | 81 ++++- .../Sidebar/Utils/Cards/RecordingList.qml | 34 +- Plugins/ZShell/Llm/CMakeLists.txt | 1 + Plugins/ZShell/Llm/chat.cpp | 3 + Plugins/ZShell/Llm/filetool.cpp | 151 +++++++++ Plugins/ZShell/Llm/filetool.hpp | 26 ++ Plugins/ZShell/Llm/generation.cpp | 49 ++- Plugins/ZShell/Llm/generation.hpp | 20 ++ Plugins/ZShell/Llm/llmclient.cpp | 303 ++++++++++++++---- Plugins/ZShell/Llm/llmclient.hpp | 19 +- Plugins/ZShell/Llm/segment.hpp | 4 +- Plugins/ZShell/Llm/tool.cpp | 22 ++ Plugins/ZShell/Llm/tool.hpp | 25 ++ Plugins/ZShell/Llm/webfetchtool.cpp | 74 ++++- Plugins/ZShell/Llm/webfetchtool.hpp | 10 +- 31 files changed, 1386 insertions(+), 135 deletions(-) create mode 100644 Modules/Launcher/Items/FilesItem.qml create mode 100644 Modules/Launcher/Services/Files.qml create mode 100644 Plugins/ZShell/Llm/filetool.cpp create mode 100644 Plugins/ZShell/Llm/filetool.hpp diff --git a/Components/CustomRect.qml b/Components/CustomRect.qml index 2f5da64..b163d99 100644 --- a/Components/CustomRect.qml +++ b/Components/CustomRect.qml @@ -6,7 +6,6 @@ Rectangle { color: "transparent" Behavior on color { - CAnim { - } + CAnim {} } } diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 6df6539..a70e9a8 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -12,7 +12,7 @@ ScrollBar { readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root readonly property real axisSize: isHorizontal ? flickable.width : flickable.height readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight - readonly property real axisContentPos: isHorizontal ? (flickable.contentX - flickable.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 effectiveSize: Math.max(nonAnimHeight, root.minimumSize) readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize) @@ -102,9 +102,9 @@ ScrollBar { var newPos = contentPosFromThumbStart(thumbStart); if (root.isHorizontal) - root.flickable.contentX = newPos + root.flickable.originX; + root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0)); else - root.flickable.contentY = newPos + root.flickable.originY; + root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0)); } function visualThumbStart() { diff --git a/Modules/Launcher/AppList.qml b/Modules/Launcher/AppList.qml index 4a0e862..72025b9 100644 --- a/Modules/Launcher/AppList.qml +++ b/Modules/Launcher/AppList.qml @@ -25,6 +25,8 @@ CustomListView { return [0]; case "variant": return SchemeVariants.query(text); + case "files": + return Files.query(text); default: return Apps.search(text); } @@ -33,7 +35,7 @@ CustomListView { function stateForText(text: string): string { const prefix = Config.launcher.actionPrefix; if (text.startsWith(prefix)) { - for (const action of ["calc", "scheme", "variant"]) + for (const action of ["calc", "scheme", "variant", "files"]) if (text.startsWith(`${prefix}${action} `)) return action; @@ -163,6 +165,13 @@ CustomListView { PropertyChanges { root.delegate: variantItem } + }, + State { + name: "files" + + PropertyChanges { + root.delegate: filesItem + } } ] transitions: Transition { @@ -264,6 +273,14 @@ CustomListView { } } + Component { + id: filesItem + + FilesItem { + list: root + } + } + Connections { function onTextChanged() { root.syncDisplayText(); diff --git a/Modules/Launcher/Content.qml b/Modules/Launcher/Content.qml index 9bb4338..3d86f0a 100644 --- a/Modules/Launcher/Content.qml +++ b/Modules/Launcher/Content.qml @@ -85,6 +85,8 @@ Item { } else if (text.startsWith(Config.launcher.actionPrefix)) { if (text.startsWith(`${Config.launcher.actionPrefix}calc `)) currentItem.onClicked(); + else if (text.startsWith(`${Config.launcher.actionPrefix}files `)) + currentItem.onClicked(); else currentItem.modelData.onClicked(list.currentList); } else { diff --git a/Modules/Launcher/Items/ActionItem.qml b/Modules/Launcher/Items/ActionItem.qml index b6ee987..6ccd88b 100644 --- a/Modules/Launcher/Items/ActionItem.qml +++ b/Modules/Launcher/Items/ActionItem.qml @@ -16,7 +16,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.modelData?.onClicked(root.list); diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index 24d46ba..932f69a 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -19,7 +19,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { Apps.launch(root.modelData); diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index 59da218..282f361 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -24,7 +24,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.onClicked(); @@ -97,8 +97,7 @@ Item { text: qsTr("Open in calculator") Behavior on opacity { - Anim { - } + Anim {} } } diff --git a/Modules/Launcher/Items/FilesItem.qml b/Modules/Launcher/Items/FilesItem.qml new file mode 100644 index 0000000..4c95075 --- /dev/null +++ b/Modules/Launcher/Items/FilesItem.qml @@ -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 + } + } + } +} diff --git a/Modules/Launcher/Items/VariantItem.qml b/Modules/Launcher/Items/VariantItem.qml index ca5d6cd..cb37cd3 100644 --- a/Modules/Launcher/Items/VariantItem.qml +++ b/Modules/Launcher/Items/VariantItem.qml @@ -15,7 +15,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.modelData?.onClicked(root.list); diff --git a/Modules/Launcher/Services/Files.qml b/Modules/Launcher/Services/Files.qml new file mode 100644 index 0000000..aebbbb0 --- /dev/null +++ b/Modules/Launcher/Services/Files.qml @@ -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 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): 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 { + 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; + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 4ae1718..85e2a6d 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -161,7 +161,7 @@ Item { scrollAnim.start(); } - cacheBuffer: Math.max(height * 2, 0) + cacheBuffer: Math.max(height * 20, 0) clip: true fadeAmount: 0.05 fadeThreshold: Tokens.padding.medium diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index 7f0c912..d9a17fe 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -16,6 +16,7 @@ Item { property bool highlight: false property bool slim: false + signal requestExpand signal deleteChatRequest(content: ChatSession) signal loadChatRequest(content: ChatSession, index: int) signal newChatRequest @@ -112,8 +113,15 @@ Item { radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2 function prettyModelName(): string { + if (modelsList.count < 1) + return qsTr("No models found"); + const pathList = Chat.model.split("\/"); const name = pathList[pathList.length - 1]; + + if (!name) + return qsTr("No model selected"); + return name; } @@ -133,6 +141,7 @@ Item { implicitHeight: fabRoot.implicitHeight CustomText { + id: label text: modelsContainer.prettyModelName() color: Colors.palette.m3onSurfaceVariant anchors.left: parent.left @@ -148,10 +157,13 @@ Item { text: modelsContainer.expanded ? "unfold_less" : "unfold_more" animate: true font.pointSize: Tokens.font.size.large + visible: modelsList.count > 0 } StateLayer { id: modelLayer + enabled: modelsList.count > 0 + onClicked: { ChatState.fabExpanded = false; modelsContainer.expanded = !modelsContainer.expanded; @@ -248,16 +260,16 @@ Item { anchors.margins: Tokens.padding.small padding: 8 font.pointSize: Math.round(18 * 1.2) - icon: "add" + icon: root.slim ? "left_panel_open" : "add" isRound: ChatState.fabExpanded - opacity: root.slim ? 0 : 1 - visible: opacity > 0 - - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } + // opacity: root.slim ? 0 : 1 + // visible: opacity > 0 + // + // Behavior on opacity { + // Anim { + // type: Anim.DefaultEffects + // } + // } label.transform: Rotation { origin.y: fabRoot.label.height / 2 @@ -270,6 +282,11 @@ Item { } onClicked: { + if (root.slim && !ChatState.narrowSidebarExpanded) { + root.requestExpand(); + return; + } + modelsContainer.expanded = false; ChatState.fabExpanded = !ChatState.fabExpanded; } diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml index 1847b35..df83c8e 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatState.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -14,6 +14,10 @@ Singleton { property bool inChat: false property bool fabExpanded: 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 bool sidebarVisible: Visibilities.getForActive().sidebar diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml index 75666af..86581c4 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import ZShell.Llm import ZShell.Config +import qs.Modules.Notifications.Sidebar.Chat import qs.Components import qs.Services @@ -16,32 +17,60 @@ Item { required property ChatMessage message required property ChatGeneration current - // Widest the bubble may grow to; assistant bubbles always use it so - // the markdown blocks have a deterministic width to wrap against. readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge signal edit(text: string) - implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin + implicitHeight: bubble.implicitHeight + (actionsRow.shouldBeActive ? actionsRow.implicitHeight + actionsRow.anchors.topMargin : 0) CustomClippingRect { 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 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 anchors.right: root.isUser ? parent.right : undefined + layer.enabled: false + layer.smooth: true - // Behavior on implicitHeight { - // enabled: !root.segment.running - // - // Anim { - // type: Anim.DefaultEffects - // } - // } + transform: Scale { + id: stretchScale + origin.x: root.isUser ? bubble.width : 0 + xScale: bubble.layoutFrozen && bubble.frozenWidth > 0 ? bubble.liveTargetWidth / bubble.frozenWidth : 1.0 + } + + 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 { id: msgText diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 7f267b1..2ad31af 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -160,6 +160,7 @@ MouseArea { anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right + spacing: Tokens.spacing.medium Repeater { id: segmentRep @@ -177,6 +178,7 @@ MouseArea { delegate: ProcessBlock { width: root.width blocks: root.blocks + current: root.current onExpandedChanged: root.handleReasoningToggle(expanded) } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index c681bfb..9323d64 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -1,23 +1,39 @@ import QtQuick import QtQuick.Layouts +import ZShell.Blobs import ZShell.Config +import ZShell.Components import ZShell.Llm import qs.Components import qs.Services -CustomClippingRect { +Item { id: root required property int index required property var modelData required property var blocks readonly property var segments: modelData.segments + required property ChatGeneration current readonly property bool isActive: index === blocks.length - 1 property bool expanded: false readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] readonly property 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).every(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 { Anim { @@ -57,6 +73,7 @@ CustomClippingRect { inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline anchors.centerIn: parent opacity: root.isActive ? 0 : 1 + enabled: opacity > 0 rotation: root.expanded ? 180 : 0 type: IconButton.Text @@ -77,6 +94,9 @@ CustomClippingRect { Layout.leftMargin: Tokens.spacing.medium text: { if (root.isActive) { + if (root.pendingApproval) + return qsTr("Waiting for approval..."); + if (root.lastSegment.type === LlmSegment.Type.ToolCall) return qsTr("Using %1...").arg(root.lastSegment.name); return qsTr("Thinking..."); @@ -90,6 +110,163 @@ 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 + + implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2 + Layout.fillWidth: true + + BlobGroup { + id: blobGroup + + color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer + + Behavior on color { + CAnim {} + } + } + + Item { + id: toolWrapper + + width: toolsBg.width + height: toolList.implicitHeight + toolList.anchors.margins * 2 + + BlobRect { + id: dialogBg + + 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.fill: parent + anchors.margins: Tokens.padding.medium + + ColumnLayout { + id: toolList + + 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 { id: expandedRect @@ -98,7 +275,6 @@ CustomClippingRect { anchors.top: layout.bottom anchors.bottom: parent.bottom anchors.topMargin: Tokens.spacing.small - anchors.bottomMargin: Tokens.spacing.small color: Colors.palette.m3surfaceContainerLow implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2 opacity: root.expanded ? 1 : 0 @@ -147,6 +323,49 @@ CustomClippingRect { CustomText { 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.leftMargin: Tokens.padding.small anchors.top: header.bottom @@ -155,7 +374,7 @@ CustomClippingRect { color: Colors.palette.m3outline font.pointSize: Tokens.font.size.small wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere - text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website") + text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData)) } } } diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml index 210d780..499b669 100644 --- a/Modules/Notifications/Sidebar/Chat/SidebarView.qml +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -36,6 +36,7 @@ Item { } BlobInvertedRect { + id: invertedRect anchors.fill: parent borderBottom: Tokens.padding.small borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium @@ -53,9 +54,10 @@ Item { anchors.left: parent.left anchors.bottom: parent.bottom anchors.margins: Tokens.padding.medium - implicitWidth: root.isWide ? Config.sidebar.sizes.width : root.iconSize + implicitWidth: root.isWide || ChatState.narrowSidebarExpanded ? Config.sidebar.sizes.width : root.iconSize highlight: true - slim: !root.isWide + slim: !root.isWide && !ChatState.narrowSidebarExpanded + z: 1 Behavior on implicitWidth { Anim { @@ -84,6 +86,36 @@ Item { ChatState.currentIdx = 0; 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 { @@ -91,12 +123,12 @@ Item { property int leftMargin: sidebar.implicitWidth - z: 1 + z: 0 anchors.top: parent.top anchors.bottom: parent.bottom - anchors.margins: Tokens.padding.medium * 2 + anchors.margins: Tokens.padding.medium anchors.right: parent.right - implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins + implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins * 2 states: State { name: "wide" @@ -152,6 +184,27 @@ Item { 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 { @@ -160,6 +213,24 @@ Item { 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 { id: conversationView diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index c113f97..cea59cc 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -110,7 +110,10 @@ ColumnLayout { onClicked: { 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: { 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 { - Anim { - } + Anim {} } model: FileSystemModel { nameFilters: ["recording_*.mp4"] @@ -162,8 +167,7 @@ ColumnLayout { opacity: list.count === 0 ? 1 : 0 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: ColumnLayout { spacing: Tokens.spacing.small @@ -178,16 +182,13 @@ ColumnLayout { text: "scan_delete" Behavior on Layout.preferredHeight { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } @@ -203,16 +204,13 @@ ColumnLayout { text: "scan_delete" Behavior on Layout.preferredWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index bca7ec0..97697aa 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -288,6 +288,7 @@ qml_module(ZShell-llm chat.hpp chat.cpp chatstore.hpp chatstore.cpp codehighlighter.hpp codehighlighter.cpp + filetool.hpp filetool.cpp generation.hpp generation.cpp llmclient.hpp llmclient.cpp markdownblock.hpp diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index b7adb9b..85e8010 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -1,6 +1,7 @@ #include "chat.hpp" #include "config.hpp" +#include "filetool.hpp" #include "llm.hpp" #include "llmclient.hpp" #include "webfetchtool.hpp" @@ -17,6 +18,7 @@ Chat::Chat(QObject* parent) m_store->setLlmClient(m_client); 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(); m_client->setEndpoint(llm->endpoint()); @@ -137,6 +139,7 @@ QString Chat::streamingChatId() const { return m_client->streamingChatId(); } + Chat* Chat::s_instance = nullptr; Chat* Chat::create(QQmlEngine*, QJSEngine*) { diff --git a/Plugins/ZShell/Llm/filetool.cpp b/Plugins/ZShell/Llm/filetool.cpp new file mode 100644 index 0000000..a8e9b81 --- /dev/null +++ b/Plugins/ZShell/Llm/filetool.cpp @@ -0,0 +1,151 @@ +#include "filetool.hpp" + +#include +#include +#include + +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, + std::function 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)); + const int budget = inlineBudgetChars(); + 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; + } + // UTF-8 uses at most 4 bytes per character, so this many bytes always + // cover `limit` characters. + 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 diff --git a/Plugins/ZShell/Llm/filetool.hpp b/Plugins/ZShell/Llm/filetool.hpp new file mode 100644 index 0000000..30b6480 --- /dev/null +++ b/Plugins/ZShell/Llm/filetool.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "tool.hpp" + +namespace ZShell::llm { + +// Lets the model read text files from the local filesystem, in +// byte-offset windows small enough to fit the context. Primarily used to +// page through the full webfetch output stored under +// /tmp/zshell-llm/webfetch/. +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, + std::function done) override; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp index 129de52..7082930 100644 --- a/Plugins/ZShell/Llm/generation.cpp +++ b/Plugins/ZShell/Llm/generation.cpp @@ -1,5 +1,9 @@ #include "generation.hpp" +#include "llmclient.hpp" +#include "messagemodel.hpp" +#include "session.hpp" + #include namespace ZShell::llm { @@ -46,7 +50,7 @@ QString ChatGeneration::reasoning() const { bool ChatGeneration::reasoningActive() const { if (!m_streaming) return false; if (!content().isEmpty()) return false; - return !hasRunningTool(); + return !hasRunningTool() && !hasPendingTool(); } qint64 ChatGeneration::reasoningElapsedMs() const { @@ -87,6 +91,45 @@ bool ChatGeneration::hasRunningTool() const { 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(parent())) + if (auto* model = qobject_cast(message->parent())) + if (auto* session = model->session()) return session->client(); + return nullptr; +} + void ChatGeneration::updateReasoningActive() { const bool active = reasoningActive(); if (m_reasoningActive == active) return; @@ -177,7 +220,9 @@ LlmSegment* ChatGeneration::beginToolCall( LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); segment->setName(name); segment->setToolCallId(toolCallId); - segment->setStatus(LlmSegment::Status::Running); + // Awaiting user approval until LlmClient::approveTools() promotes it + // to Running; denyTools()/endStream() finalize it as an error. + segment->setStatus(LlmSegment::Status::Pending); segment->begin(); addSegment(segment); Q_EMIT toolStateChanged(); diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp index 20b4709..e10c595 100644 --- a/Plugins/ZShell/Llm/generation.hpp +++ b/Plugins/ZShell/Llm/generation.hpp @@ -6,10 +6,13 @@ #include #include #include +#include #include namespace ZShell::llm { +class LlmClient; + class ChatGeneration : public QObject { Q_OBJECT QML_ELEMENT @@ -33,6 +36,12 @@ class ChatGeneration : public QObject { QList segments READ segments 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: explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); @@ -48,6 +57,14 @@ class ChatGeneration : public QObject { [[nodiscard]] QList segments() const { return m_segments; } [[nodiscard]] int toolCallCount() const; [[nodiscard]] bool hasRunningTool() const; + [[nodiscard]] bool hasPendingTool() const; + + [[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; } + void setApprovalPending(bool value); + [[nodiscard]] QVariantList pendingToolCalls() const; + + Q_INVOKABLE void approveTools(); + Q_INVOKABLE void denyTools(); void setContent(const QString& value); void appendContent(const QString& piece); @@ -69,14 +86,17 @@ class ChatGeneration : public QObject { void streamingChanged(); void toolStateChanged(); void segmentsChanged(); + void toolApprovalPendingChanged(); private: void updateReasoningActive(); + LlmClient* client() const; QTimer m_timer; QList m_segments; bool m_reasoningActive = false; bool m_streaming = false; + bool m_approvalPending = false; qint64 m_timestamp; }; diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index b29f92b..c85635e 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -17,6 +17,81 @@ namespace ZShell::llm { +namespace { + +// Minimum prompt budget kept even when the transcript alone nearly fills +// the window, so the model always sees the triggering message. +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; +} + +// One message model row and the JSON messages it produces. Trimming works +// in whole units so an assistant turn and its tool results always travel +// together. +struct ContextUnit { + QList messages; + int tokens = 0; +}; + +// Shrink the free-text fields of a unit until it fits the budget. Used +// when a single unit (e.g. one huge user message) alone exceeds it. +void shrinkUnitToBudget(QList& 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(32, text.size() * 3 / 4)); + const QString marker = QStringLiteral("\n[…]"); + if (!text.endsWith(marker)) text += marker; + unit[bestMessage][bestKey] = text; + } +} + +} // namespace + QString LlmClient::completionsPath( const QString& endpoint, const QString& subpath) { QString base = endpoint.trimmed(); @@ -97,7 +172,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { if (m_busy || !session || !target) return; m_active = session; m_streaming = target; - m_streaming->setStreaming(true); + target->setStreaming(true); setBusy(true); setStreamingChatId(session->id()); @@ -125,6 +200,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { m_round = 0; m_contentMark = 0; m_reasoningMark = 0; + setApprovalPending(false); sendRound(); } @@ -146,9 +222,11 @@ void LlmClient::sendRound() { return; } - const auto* model = m_active->messagesModel(); + ChatSession* active = m_active; + ChatGeneration* streaming = m_streaming; + const auto* model = active->messagesModel(); const int targetRow = - model->rowOf(qobject_cast(m_streaming->parent())); + model->rowOf(qobject_cast(streaming->parent())); if (targetRow < 0) { fail(QStringLiteral( "Internal error: generation target is not in the " @@ -156,7 +234,13 @@ void LlmClient::sendRound() { return; } - QJsonArray messages = buildContextMessages(m_active, targetRow); + // The transcript (this generation so far) is always sent in full, so + // reserve its estimated size out of the context budget. + 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) messages.append(value); @@ -205,67 +289,103 @@ void LlmClient::sendRound() { } QJsonArray LlmClient::buildContextMessages( - ChatSession* session, int stopBeforeRow) const { + ChatSession* session, int stopBeforeRow, int extraReserveTokens) const { const auto* model = session->messagesModel(); - QJsonArray messages; + QList units; for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { const auto* message = model->at(row); const auto* generation = message->activeGeneration(); if (!generation) continue; + ContextUnit unit; if (message->role() == ChatMessage::Role::User) { QJsonObject user; user[QStringLiteral("role")] = QStringLiteral("user"); user[QStringLiteral("content")] = generation->content(); - messages.append(user); - continue; - } + unit.messages.append(user); + } else { + QList toolSegments; + for (const auto* segment : generation->segments()) + if (segment->type() == LlmSegment::Type::ToolCall) + toolSegments.append(segment); - QList toolSegments; - for (const auto* segment : generation->segments()) - if (segment->type() == LlmSegment::Type::ToolCall) - toolSegments.append(segment); - - QJsonObject assistant; - assistant[QStringLiteral("role")] = QStringLiteral("assistant"); - if (toolSegments.isEmpty()) - assistant[QStringLiteral("content")] = generation->content(); - else if (!generation->content().isEmpty()) - assistant[QStringLiteral("content")] = generation->content(); - if (!generation->reasoning().isEmpty()) - assistant[QStringLiteral("reasoning_content")] = - generation->reasoning(); - - if (toolSegments.isEmpty()) { - messages.append(assistant); - continue; - } - QJsonArray calls; - for (const auto* segment : toolSegments) { - QJsonObject function; - function[QStringLiteral("name")] = segment->name(); - function[QStringLiteral("arguments")] = segment->arguments(); - QJsonObject call; - call[QStringLiteral("id")] = segment->toolCallId(); - call[QStringLiteral("type")] = QStringLiteral("function"); - call[QStringLiteral("function")] = function; - calls.append(call); - } - assistant[QStringLiteral("tool_calls")] = calls; - messages.append(assistant); - for (const auto* segment : toolSegments) { - QJsonObject toolMessage; - toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); - toolMessage[QStringLiteral("tool_call_id")] = segment->toolCallId(); - toolMessage[QStringLiteral("content")] = segment->result(); - messages.append(toolMessage); + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + if (toolSegments.isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + else if (!generation->content().isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + if (!generation->reasoning().isEmpty()) + assistant[QStringLiteral("reasoning_content")] = + generation->reasoning(); + if (!toolSegments.isEmpty()) { + QJsonArray calls; + for (const auto* segment : toolSegments) { + QJsonObject function; + function[QStringLiteral("name")] = segment->name(); + function[QStringLiteral("arguments")] = + segment->arguments(); + QJsonObject call; + call[QStringLiteral("id")] = segment->toolCallId(); + call[QStringLiteral("type")] = QStringLiteral("function"); + call[QStringLiteral("function")] = function; + calls.append(call); + } + assistant[QStringLiteral("tool_calls")] = calls; + } + unit.messages.append(assistant); + for (const auto* segment : toolSegments) { + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = + segment->toolCallId(); + toolMessage[QStringLiteral("content")] = segment->result(); + unit.messages.append(toolMessage); + } } + for (const QJsonObject& messageJson : unit.messages) + unit.tokens += estimateMessageTokens(messageJson); + units.append(unit); } + + if (m_contextSize > 0) { + // Keep headroom for the model's own reply so the prompt cannot + // fill the whole window. + const int completionReserve = qBound(1024, m_contextSize / 8, 8192); + int budget = m_contextSize - completionReserve - extraReserveTokens; + budget = qMax(budget, kMinContextTokens); + + int total = 0; + for (const ContextUnit& unit : units) + total += unit.tokens; + // Drop oldest units until the prompt fits the budget. + while (units.size() > 1 && total > budget) { + total -= units.first().tokens; + units.removeFirst(); + } + // A conversation cannot start with an assistant turn. + while ( + units.size() > 1 && + units.first().messages.first()[QStringLiteral("role")].toString() == + QLatin1String("assistant")) { + total -= units.first().tokens; + units.removeFirst(); + } + // A single oversized unit still gets sent, shrunk to fit. + 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; } 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); if (index < 0) return; while (m_callBuilders.size() <= index) @@ -281,8 +401,8 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) { if (!arguments.isEmpty()) builder.arguments += arguments; if (!builder.segment) { - m_streaming->closeOpenSegments(); - builder.segment = m_streaming->beginToolCall(builder.name, builder.id); + streaming->closeOpenSegments(); + builder.segment = streaming->beginToolCall(builder.name, builder.id); } builder.segment->setName(builder.name); builder.segment->setToolCallId(builder.id); @@ -291,6 +411,7 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) { void LlmClient::roundFinished() { if (m_roundDone || !m_streaming) return; + ChatGeneration* streaming = m_streaming; m_roundDone = true; bool hasCalls = false; @@ -306,10 +427,10 @@ void LlmClient::roundFinished() { return; } - m_streaming->closeOpenSegments(); + streaming->closeOpenSegments(); - const QString content = m_streaming->content(); - const QString reasoning = m_streaming->reasoning(); + const QString content = streaming->content(); + const QString reasoning = streaming->reasoning(); QJsonObject assistant; assistant[QStringLiteral("role")] = QStringLiteral("assistant"); if (content.size() > m_contentMark) { @@ -337,7 +458,9 @@ void LlmClient::roundFinished() { m_transcript.append(assistant); m_round++; - executeAllCalls(); + // The whole batch of this round waits for a single user decision; + // approveTools() runs all of it, denyTools() ends the turn. + setApprovalPending(true); } void LlmClient::executeAllCalls() { @@ -375,7 +498,7 @@ void LlmClient::executeAllCalls() { } ++m_pendingCalls; - tool->execute(args, [this, i, call](const QJsonObject& result) { + tool->execute(call.id, args, [this, i, call](const QJsonObject& result) { if (!m_streaming) return; const bool success = result.contains(QStringLiteral("output")); const QString content = @@ -407,12 +530,60 @@ void LlmClient::flushCallResults() { sendRound(); } +void LlmClient::setApprovalPending(bool value) { + if (m_approvalPending == value) return; + m_approvalPending = value; + // Mirror onto the streaming generation; QML reads and answers from + // there, so only the affected ProcessBlock reacts. + 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; + // Restart the segment timer so elapsed time covers execution, not + // the wait for approval. + 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() { if (!m_busy) return; + if (m_approvalPending) { + setApprovalPending(false); + finishPendingCalls(QStringLiteral("Cancelled")); + finishTurn(); + return; + } if (m_toolPhase) { m_tools->cancelAll(); - if (m_streaming) { - for (auto* segment : m_streaming->segments()) { + if (ChatGeneration* streaming = m_streaming) { + for (auto* segment : streaming->segments()) { if (segment->type() == LlmSegment::Type::ToolCall && segment->running()) segment->finishTool(QStringLiteral("Cancelled"), false); @@ -426,11 +597,19 @@ void LlmClient::stop() { void LlmClient::endStream() { if (!m_streaming) return; - auto* generation = m_streaming; - auto* session = m_active; + ChatGeneration* generation = m_streaming; + ChatSession* session = m_active; m_streaming = nullptr; m_active = nullptr; generation->setStreaming(false); + m_approvalPending = false; + generation->setApprovalPending(false); + // Finalize tool calls that never received an approval (turn cancelled, + // round limit reached, ...). + 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() && generation->toolCallCount() == 0) { if (auto* message = qobject_cast(generation->parent())) { @@ -570,6 +749,7 @@ void LlmClient::refreshModels() { void LlmClient::setContextSize(int size) { if (size <= 0 || m_contextSize == size) return; m_contextSize = size; + m_tools->setContextSize(size); Q_EMIT contextSizeChanged(); } @@ -611,12 +791,13 @@ void LlmClient::probeContextSize() { } 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(); if (usage.isEmpty()) return; const double used = usage.value("prompt_tokens").toDouble() + usage.value("completion_tokens").toDouble(); - if (used > 0) m_active->setLastTokenCount(static_cast(used)); + if (used > 0) active->setLastTokenCount(static_cast(used)); } void LlmClient::shortRequest( diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index 56fdfc4..f0346ff 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -56,6 +56,13 @@ class LlmClient : public QObject { void clearOnFinish(ChatSession* session); void sessionRemoved(ChatSession* session); + // Tool calls are executed only after the user approves the batch the + // model requested. The per-generation state (toolApprovalPending, + // pendingToolCalls) is mirrored onto ChatGeneration; these are the + // entry points QML reaches through it. + void approveTools(); + void denyTools(); + void refreshModels(); void requestTitle(ChatSession* session, const QString& userText); void requestIcon(ChatSession* session, const QString& userText); @@ -83,11 +90,13 @@ class LlmClient : public QObject { void sendRound(); QJsonArray buildContextMessages( - ChatSession* session, int stopBeforeRow) const; + ChatSession* session, int stopBeforeRow, int extraReserveTokens) const; void applyToolCallDelta(const QJsonObject& call); void roundFinished(); void executeAllCalls(); void flushCallResults(); + void setApprovalPending(bool value); + void finishPendingCalls(const QString& resultText); void finishTurn(); void fail(const QString& message); void handleLine(const QByteArray& line); @@ -109,8 +118,11 @@ class LlmClient : public QObject { ToolRegistry* m_tools = nullptr; QNetworkReply* m_reply = nullptr; QByteArray m_buffer; - ChatSession* m_active = nullptr; - ChatGeneration* m_streaming = nullptr; + // QPointer: these objects are owned by the ChatStore tree, which Qt + // destroys *before* this client on shutdown (children die in creation + // order). The pointers must self-null instead of dangling. + QPointer m_active; + QPointer m_streaming; QPointer m_pendingClear; bool m_busy = false; QString m_streamingChatId; @@ -133,6 +145,7 @@ class LlmClient : public QObject { qsizetype m_reasoningMark = 0; bool m_roundDone = false; bool m_toolPhase = false; + bool m_approvalPending = false; int m_pendingCalls = 0; static constexpr int kMaxToolRounds = 12; }; diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp index ce125d1..589ec12 100644 --- a/Plugins/ZShell/Llm/segment.hpp +++ b/Plugins/ZShell/Llm/segment.hpp @@ -29,7 +29,9 @@ class LlmSegment : public QObject { enum class Type : int { Reasoning = 0, ToolCall, Content }; Q_ENUM(Type) - enum class Status : int { None = 0, Running, Success, Error }; + // Pending is appended last: statuses are persisted as integers in the + // chat database, so existing values must not move. + enum class Status : int { None = 0, Running, Success, Error, Pending }; Q_ENUM(Status) explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp index 05ddb35..ab8b052 100644 --- a/Plugins/ZShell/Llm/tool.cpp +++ b/Plugins/ZShell/Llm/tool.cpp @@ -6,6 +6,20 @@ LlmTool::LlmTool(QObject* parent) : QObject(parent) {} 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; + // About a quarter of the context window, clamped so tiny contexts + // still get a usable result and huge ones stay sane. + const int tokens = + qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken); + return tokens * CharsPerToken; +} + void LlmTool::cancel() {} QJsonObject LlmTool::specification() const { @@ -30,6 +44,7 @@ void ToolRegistry::setEnabled(bool value) { void ToolRegistry::registerTool(LlmTool* tool) { if (!tool || m_tools.contains(tool)) return; tool->setParent(this); + tool->setContextSize(m_contextSize); m_tools.append(tool); } @@ -52,4 +67,11 @@ void ToolRegistry::cancelAll() { 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 diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp index f8e12df..8ca95a1 100644 --- a/Plugins/ZShell/Llm/tool.hpp +++ b/Plugins/ZShell/Llm/tool.hpp @@ -21,12 +21,33 @@ class LlmTool : public QObject { [[nodiscard]] virtual QString description() const = 0; [[nodiscard]] virtual QJsonObject parameters() const = 0; + // toolCallId identifies the call within the conversation; tools may use + // it to name files they write for later retrieval. virtual void execute( + const QString& toolCallId, const QJsonObject& args, std::function done) = 0; virtual void cancel(); [[nodiscard]] QJsonObject specification() const; + + // The endpoint's context size in tokens (0 = unknown). Set by LlmClient. + [[nodiscard]] int contextSize() const { return m_contextSize; } + void setContextSize(int value); + + // Rough cap, in characters, for tool output embedded in a tool result + // message. Scales with the context size so a single result cannot + // consume the whole window. + [[nodiscard]] int inlineBudgetChars() const; + + // ~4 characters per token; a deliberately coarse estimate used for + // budgeting (never for exact accounting). + static constexpr int CharsPerToken = 4; + // Inline budget when the context size is unknown. + static constexpr int DefaultInlineChars = 64 * 1024; + + protected: + int m_contextSize = 0; }; class ToolRegistry : public QObject { @@ -45,11 +66,15 @@ class ToolRegistry : public QObject { [[nodiscard]] QJsonArray specifications() const; void cancelAll(); + // Propagated to every registered tool, including later ones. + void setContextSize(int value); + Q_SIGNALS: void enabledChanged(); private: bool m_enabled = true; + int m_contextSize = 0; QList m_tools; }; diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp index ff86b8f..2fb799e 100644 --- a/Plugins/ZShell/Llm/webfetchtool.cpp +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -1,5 +1,7 @@ #include "webfetchtool.hpp" +#include +#include #include #include #include @@ -8,6 +10,9 @@ namespace ZShell::llm { +const QString WebFetchTool::StoragePath = + QStringLiteral("/tmp/zshell-llm/webfetch"); + namespace { const char* kUserAgent = @@ -46,7 +51,10 @@ QString WebFetchTool::description() const { return QStringLiteral( "Fetch content from an HTTP or HTTPS URL and return it as plain " "text or raw HTML. HTML pages are reduced to their visible text " - "by default. This tool is read-only."); + "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 { @@ -102,9 +110,12 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) { } void WebFetchTool::execute( - const QJsonObject& args, std::function done) { + const QString& toolCallId, + const QJsonObject& args, + std::function done) { auto* job = new Job; job->done = std::move(done); + job->toolCallId = toolCallId; m_jobs.append(job); auto fail = [this, job](const QString& message) { @@ -233,13 +244,64 @@ void WebFetchTool::execute( if (mime.contains(QLatin1String("text/html")) && job->format == QLatin1String("text")) content = extractTextFromHtml(content); - if (content.size() > MaxOutputChars) - content = content.left(MaxOutputChars) + - QStringLiteral("\n[... truncated ...]"); - completeJob(job, makeOutput(content)); + + const QString savedPath = saveToFile(*job, content, mime); + + QString output = content; + const int budget = inlineBudgetChars(); + 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() { for (auto* job : m_jobs) { job->timer->stop(); diff --git a/Plugins/ZShell/Llm/webfetchtool.hpp b/Plugins/ZShell/Llm/webfetchtool.hpp index b7bfd6b..1c69bb9 100644 --- a/Plugins/ZShell/Llm/webfetchtool.hpp +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -20,7 +20,9 @@ class WebFetchTool : public LlmTool { static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int DefaultTimeoutSeconds = 30; static constexpr int MaxTimeoutSeconds = 120; - static constexpr int MaxOutputChars = 64 * 1024; + // Where the full (untruncated) output of each call is stored so the + // model can page through it with the readfile tool. + static const QString StoragePath; explicit WebFetchTool(QObject* parent = nullptr); ~WebFetchTool() override; @@ -29,6 +31,7 @@ class WebFetchTool : public LlmTool { QString description() const override; QJsonObject parameters() const override; void execute( + const QString& toolCallId, const QJsonObject& args, std::function done) override; void cancel() override; @@ -43,10 +46,15 @@ class WebFetchTool : public LlmTool { QByteArray body; bool tooLarge = false; QString format; + QString toolCallId; std::function done; }; void completeJob(Job* job, QJsonObject result); + // Writes the full output to StoragePath; returns the file path, or an + // empty string when saving failed. + QString saveToFile(const Job& job, const QString& content, + const QString& mime) const; QNetworkAccessManager m_manager; QList m_jobs;