fix: truncate webfetch content to fit context
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 2m18s
C++ / clang-tidy (pull_request) Failing after 2m7s
JS/TS / fmt (pull_request) Failing after 9s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m38s
Rust / fmt (pull_request) Successful in 26s
Rust / clippy (pull_request) Successful in 47s

This commit is contained in:
2026-09-03 22:46:46 +02:00
parent ae5c1fa148
commit fcb848b6d2
31 changed files with 1386 additions and 135 deletions
+1 -2
View File
@@ -6,7 +6,6 @@ Rectangle {
color: "transparent" color: "transparent"
Behavior on color { Behavior on color {
CAnim { CAnim {}
}
} }
} }
+3 -3
View File
@@ -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() {
+18 -1
View File
@@ -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();
+2
View File
@@ -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 {
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+2 -3
View File
@@ -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 {}
}
} }
} }
+141
View File
@@ -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
}
}
}
}
+1 -1
View File
@@ -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);
+194
View File
@@ -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;
}
}
}
@@ -161,7 +161,7 @@ Item {
scrollAnim.start(); scrollAnim.start();
} }
cacheBuffer: Math.max(height * 2, 0) cacheBuffer: Math.max(height * 20, 0)
clip: true clip: true
fadeAmount: 0.05 fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium fadeThreshold: Tokens.padding.medium
@@ -16,6 +16,7 @@ Item {
property bool highlight: false property bool highlight: false
property bool slim: 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
@@ -112,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;
} }
@@ -133,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
@@ -148,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;
@@ -248,16 +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 // opacity: root.slim ? 0 : 1
visible: opacity > 0 // visible: opacity > 0
//
Behavior on opacity { // Behavior on opacity {
Anim { // Anim {
type: Anim.DefaultEffects // type: Anim.DefaultEffects
} // }
} // }
label.transform: Rotation { label.transform: Rotation {
origin.y: fabRoot.label.height / 2 origin.y: fabRoot.label.height / 2
@@ -270,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,10 @@ 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 bool windowIsWide: false
property ShellScreen screen property ShellScreen screen
property bool sidebarVisible: Visibilities.getForActive().sidebar property bool sidebarVisible: Visibilities.getForActive().sidebar
@@ -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
@@ -160,6 +160,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 +178,7 @@ MouseArea {
delegate: ProcessBlock { delegate: ProcessBlock {
width: root.width width: root.width
blocks: root.blocks blocks: root.blocks
current: root.current
onExpandedChanged: root.handleReasoningToggle(expanded) onExpandedChanged: root.handleReasoningToggle(expanded)
} }
@@ -1,23 +1,39 @@
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 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).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 { Behavior on implicitHeight {
Anim { Anim {
@@ -57,6 +73,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 +94,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 +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 { CustomRect {
id: expandedRect id: expandedRect
@@ -98,7 +275,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 +323,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 +374,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))
} }
} }
} }
@@ -36,6 +36,7 @@ Item {
} }
BlobInvertedRect { BlobInvertedRect {
id: invertedRect
anchors.fill: parent anchors.fill: parent
borderBottom: Tokens.padding.small borderBottom: Tokens.padding.small
borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium
@@ -53,9 +54,10 @@ Item {
anchors.left: parent.left anchors.left: parent.left
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium 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 highlight: true
slim: !root.isWide slim: !root.isWide && !ChatState.narrowSidebarExpanded
z: 1
Behavior on implicitWidth { Behavior on implicitWidth {
Anim { Anim {
@@ -84,6 +86,36 @@ Item {
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 {
@@ -91,12 +123,12 @@ Item {
property int leftMargin: sidebar.implicitWidth property int leftMargin: sidebar.implicitWidth
z: 1 z: 0
anchors.top: parent.top anchors.top: parent.top
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium * 2 anchors.margins: Tokens.padding.medium
anchors.right: parent.right 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 { states: State {
name: "wide" name: "wide"
@@ -152,6 +184,27 @@ Item {
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 { Binding {
@@ -160,6 +213,24 @@ Item {
value: root.showList 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 {
id: conversationView id: conversationView
@@ -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 {}
}
} }
} }
+1
View File
@@ -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
+3
View File
@@ -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());
@@ -137,6 +139,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*) {
+151
View File
@@ -0,0 +1,151 @@
#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,
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));
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
+26
View File
@@ -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<void(const QJsonObject& result)> done) override;
};
} // namespace ZShell::llm
+47 -2
View File
@@ -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,9 @@ 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); // Awaiting user approval until LlmClient::approveTools() promotes it
// to Running; denyTools()/endStream() finalize it as an error.
segment->setStatus(LlmSegment::Status::Pending);
segment->begin(); segment->begin();
addSegment(segment); addSegment(segment);
Q_EMIT toolStateChanged(); Q_EMIT toolStateChanged();
+20
View File
@@ -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;
}; };
+242 -61
View File
@@ -17,6 +17,81 @@
namespace ZShell::llm { 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<QJsonObject> 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<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;
}
}
} // 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();
@@ -97,7 +172,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
if (m_busy || !session || !target) return; if (m_busy || !session || !target) return;
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,6 +200,7 @@ 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(); sendRound();
} }
@@ -146,9 +222,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,7 +234,13 @@ void LlmClient::sendRound() {
return; 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) for (const QJsonValue& value : m_transcript)
messages.append(value); messages.append(value);
@@ -205,67 +289,103 @@ void LlmClient::sendRound() {
} }
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) {
// 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; 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 +401,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,6 +411,7 @@ 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;
@@ -306,10 +427,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 +458,9 @@ void LlmClient::roundFinished() {
m_transcript.append(assistant); m_transcript.append(assistant);
m_round++; 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() { void LlmClient::executeAllCalls() {
@@ -375,7 +498,7 @@ void LlmClient::executeAllCalls() {
} }
++m_pendingCalls; ++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; if (!m_streaming) return;
const bool success = result.contains(QStringLiteral("output")); const bool success = result.contains(QStringLiteral("output"));
const QString content = const QString content =
@@ -407,12 +530,60 @@ void LlmClient::flushCallResults() {
sendRound(); 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() { 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);
@@ -426,11 +597,19 @@ void LlmClient::stop() {
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);
// 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() && 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())) {
@@ -570,6 +749,7 @@ 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();
} }
@@ -611,12 +791,13 @@ void LlmClient::probeContextSize() {
} }
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(
+16 -3
View File
@@ -56,6 +56,13 @@ class LlmClient : public QObject {
void clearOnFinish(ChatSession* session); void clearOnFinish(ChatSession* session);
void sessionRemoved(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 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 +90,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 +118,11 @@ 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; // QPointer: these objects are owned by the ChatStore tree, which Qt
ChatGeneration* m_streaming = nullptr; // destroys *before* this client on shutdown (children die in creation
// order). The pointers must self-null instead of dangling.
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 +145,7 @@ 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;
int m_pendingCalls = 0; int m_pendingCalls = 0;
static constexpr int kMaxToolRounds = 12; static constexpr int kMaxToolRounds = 12;
}; };
+3 -1
View File
@@ -29,7 +29,9 @@ 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 }; // 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) Q_ENUM(Status)
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
+22
View File
@@ -6,6 +6,20 @@ 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;
// 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() {} void LlmTool::cancel() {}
QJsonObject LlmTool::specification() const { QJsonObject LlmTool::specification() const {
@@ -30,6 +44,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 +67,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
+25
View File
@@ -21,12 +21,33 @@ class LlmTool : public QObject {
[[nodiscard]] virtual QString description() const = 0; [[nodiscard]] virtual QString description() const = 0;
[[nodiscard]] virtual QJsonObject parameters() 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( virtual void execute(
const QString& toolCallId,
const QJsonObject& args, const QJsonObject& args,
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;
// 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 { class ToolRegistry : public QObject {
@@ -45,11 +66,15 @@ class ToolRegistry : public QObject {
[[nodiscard]] QJsonArray specifications() const; [[nodiscard]] QJsonArray specifications() const;
void cancelAll(); void cancelAll();
// Propagated to every registered tool, including later ones.
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;
}; };
+68 -6
View File
@@ -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,12 @@ 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,
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;
m_jobs.append(job); m_jobs.append(job);
auto fail = [this, job](const QString& message) { auto fail = [this, job](const QString& message) {
@@ -233,13 +244,64 @@ 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;
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() { void WebFetchTool::cancel() {
for (auto* job : m_jobs) { for (auto* job : m_jobs) {
job->timer->stop(); job->timer->stop();
+9 -1
View File
@@ -20,7 +20,9 @@ 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; // 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); explicit WebFetchTool(QObject* parent = nullptr);
~WebFetchTool() override; ~WebFetchTool() override;
@@ -29,6 +31,7 @@ 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,
std::function<void(const QJsonObject& result)> done) override; std::function<void(const QJsonObject& result)> done) override;
void cancel() override; void cancel() override;
@@ -43,10 +46,15 @@ class WebFetchTool : public LlmTool {
QByteArray body; QByteArray body;
bool tooLarge = false; bool tooLarge = false;
QString format; QString format;
QString toolCallId;
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);
// 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; QNetworkAccessManager m_manager;
QList<Job*> m_jobs; QList<Job*> m_jobs;