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
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:
@@ -6,7 +6,6 @@ Rectangle {
|
|||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ ScrollBar {
|
|||||||
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
|
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
|
||||||
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
|
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
|
||||||
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
|
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
|
||||||
readonly property real axisContentPos: isHorizontal ? (flickable.contentX - flickable.originX) : (flickable.contentY - flickable.originY)
|
readonly property real axisContentPos: isHorizontal ? (reversed ? flickable.contentX : (flickable.contentX - flickable.originX)) : (reversed ? flickable.contentY : (flickable.contentY - flickable.originY))
|
||||||
readonly property real axisLength: isHorizontal ? root.width : root.height
|
readonly property real axisLength: isHorizontal ? root.width : root.height
|
||||||
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
|
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
|
||||||
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
|
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
|
||||||
@@ -102,9 +102,9 @@ ScrollBar {
|
|||||||
|
|
||||||
var newPos = contentPosFromThumbStart(thumbStart);
|
var newPos = contentPosFromThumbStart(thumbStart);
|
||||||
if (root.isHorizontal)
|
if (root.isHorizontal)
|
||||||
root.flickable.contentX = newPos + root.flickable.originX;
|
root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0));
|
||||||
else
|
else
|
||||||
root.flickable.contentY = newPos + root.flickable.originY;
|
root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function visualThumbStart() {
|
function visualThumbStart() {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ CustomListView {
|
|||||||
return [0];
|
return [0];
|
||||||
case "variant":
|
case "variant":
|
||||||
return SchemeVariants.query(text);
|
return SchemeVariants.query(text);
|
||||||
|
case "files":
|
||||||
|
return Files.query(text);
|
||||||
default:
|
default:
|
||||||
return Apps.search(text);
|
return Apps.search(text);
|
||||||
}
|
}
|
||||||
@@ -33,7 +35,7 @@ CustomListView {
|
|||||||
function stateForText(text: string): string {
|
function stateForText(text: string): string {
|
||||||
const prefix = Config.launcher.actionPrefix;
|
const prefix = Config.launcher.actionPrefix;
|
||||||
if (text.startsWith(prefix)) {
|
if (text.startsWith(prefix)) {
|
||||||
for (const action of ["calc", "scheme", "variant"])
|
for (const action of ["calc", "scheme", "variant", "files"])
|
||||||
if (text.startsWith(`${prefix}${action} `))
|
if (text.startsWith(`${prefix}${action} `))
|
||||||
return action;
|
return action;
|
||||||
|
|
||||||
@@ -163,6 +165,13 @@ CustomListView {
|
|||||||
PropertyChanges {
|
PropertyChanges {
|
||||||
root.delegate: variantItem
|
root.delegate: variantItem
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
State {
|
||||||
|
name: "files"
|
||||||
|
|
||||||
|
PropertyChanges {
|
||||||
|
root.delegate: filesItem
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
transitions: Transition {
|
transitions: Transition {
|
||||||
@@ -264,6 +273,14 @@ CustomListView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: filesItem
|
||||||
|
|
||||||
|
FilesItem {
|
||||||
|
list: root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
function onTextChanged() {
|
function onTextChanged() {
|
||||||
root.syncDisplayText();
|
root.syncDisplayText();
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ Item {
|
|||||||
} else if (text.startsWith(Config.launcher.actionPrefix)) {
|
} else if (text.startsWith(Config.launcher.actionPrefix)) {
|
||||||
if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
|
if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
|
||||||
currentItem.onClicked();
|
currentItem.onClicked();
|
||||||
|
else if (text.startsWith(`${Config.launcher.actionPrefix}files `))
|
||||||
|
currentItem.onClicked();
|
||||||
else
|
else
|
||||||
currentItem.modelData.onClicked(list.currentList);
|
currentItem.modelData.onClicked(list.currentList);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.modelData?.onClicked(root.list);
|
root.modelData?.onClicked(root.list);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
Apps.launch(root.modelData);
|
Apps.launch(root.modelData);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.onClicked();
|
root.onClicked();
|
||||||
@@ -97,8 +97,7 @@ Item {
|
|||||||
text: qsTr("Open in calculator")
|
text: qsTr("Open in calculator")
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Widgets
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Components
|
||||||
|
import qs.Helpers
|
||||||
|
import qs.Modules.Launcher.Services
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var list
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
anchors.left: parent?.left
|
||||||
|
anchors.right: parent?.right
|
||||||
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
|
function onClicked(): void {
|
||||||
|
root.list.visibilities.launcher = false;
|
||||||
|
if (root.modelData?.path)
|
||||||
|
Files.launch(["xdg-open", root.modelData.path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
StateLayer {
|
||||||
|
id: sLayer
|
||||||
|
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
|
manualHoverOverride: openLocation.hovered
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
root.onClicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: openLocation
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Tokens.padding.medium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
icon: "open_in_new"
|
||||||
|
isRound: true
|
||||||
|
|
||||||
|
scale: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0.4
|
||||||
|
opacity: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on scale {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
root.list.visibilities.launcher = false;
|
||||||
|
if (root.modelData?.path)
|
||||||
|
Files.showInFolder(root.modelData.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: Tokens.padding.small
|
||||||
|
anchors.margins: Tokens.padding.small
|
||||||
|
anchors.rightMargin: Tokens.padding.small
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
id: icon
|
||||||
|
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitSize: Tokens.font.size.extraLarge
|
||||||
|
source: Quickshell.iconPath(root.modelData?.icon) ?? ""
|
||||||
|
visible: !root.modelData?.isImage
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
active: opacity > 0
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
asynchronous: true
|
||||||
|
opacity: root.modelData?.isImage && image.status === Image.Loading ? 1 : 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceComponent: LoadingIndicator {
|
||||||
|
color: Colors.palette.m3primaryContainer
|
||||||
|
implicitSize: Tokens.font.size.extraLarge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FadeImage {
|
||||||
|
id: image
|
||||||
|
|
||||||
|
property int implicitSize: Tokens.font.size.extraLarge
|
||||||
|
|
||||||
|
width: implicitSize
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
source: root.modelData?.isImage ? root.modelData?.path ?? "" : ""
|
||||||
|
visible: root.modelData?.isImage
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.left: icon.right
|
||||||
|
anchors.leftMargin: Tokens.spacing.medium
|
||||||
|
anchors.verticalCenter: icon.verticalCenter
|
||||||
|
implicitHeight: name.implicitHeight + desc.implicitHeight
|
||||||
|
implicitWidth: parent.width - icon.width
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: name
|
||||||
|
|
||||||
|
font.pointSize: Tokens.font.size.normal
|
||||||
|
text: root.modelData?.name ?? ""
|
||||||
|
elide: Text.ElideRight
|
||||||
|
width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: desc
|
||||||
|
|
||||||
|
anchors.top: name.bottom
|
||||||
|
color: Colors.palette.m3outline
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.pointSize: Tokens.font.size.small
|
||||||
|
text: root.modelData?.path ?? ""
|
||||||
|
width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.modelData?.onClicked(root.list);
|
root.modelData?.onClicked(root.list);
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// qs/Services/Files.qml
|
||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import ZShell.Config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property list<var> results: []
|
||||||
|
|
||||||
|
property string _pendingQuery: ""
|
||||||
|
property int _generation: 0
|
||||||
|
|
||||||
|
readonly property var _imageExts: ({
|
||||||
|
"png": true,
|
||||||
|
"jpg": true,
|
||||||
|
"jpeg": true,
|
||||||
|
"gif": true,
|
||||||
|
"bmp": true,
|
||||||
|
"webp": true,
|
||||||
|
"svg": true,
|
||||||
|
"tiff": true,
|
||||||
|
"tif": true,
|
||||||
|
"ico": true,
|
||||||
|
"heic": true,
|
||||||
|
"avif": true
|
||||||
|
})
|
||||||
|
|
||||||
|
// freedesktop mimetype-icon names; extend as needed
|
||||||
|
readonly property var _extIcons: ({
|
||||||
|
// images
|
||||||
|
"png": "image-x-generic",
|
||||||
|
"jpg": "image-x-generic",
|
||||||
|
"jpeg": "image-x-generic",
|
||||||
|
"gif": "image-x-generic",
|
||||||
|
"bmp": "image-x-generic",
|
||||||
|
"webp": "image-x-generic",
|
||||||
|
"svg": "image-x-generic",
|
||||||
|
"tiff": "image-x-generic",
|
||||||
|
"tif": "image-x-generic",
|
||||||
|
"ico": "image-x-generic",
|
||||||
|
"heic": "image-x-generic",
|
||||||
|
"avif": "image-x-generic",
|
||||||
|
// documents
|
||||||
|
"pdf": "application-pdf",
|
||||||
|
"doc": "x-office-document",
|
||||||
|
"docx": "x-office-document",
|
||||||
|
"odt": "x-office-document",
|
||||||
|
"txt": "text-x-generic",
|
||||||
|
"md": "text-x-generic",
|
||||||
|
"xls": "x-office-spreadsheet",
|
||||||
|
"xlsx": "x-office-spreadsheet",
|
||||||
|
"ods": "x-office-spreadsheet",
|
||||||
|
"ppt": "x-office-presentation",
|
||||||
|
"pptx": "x-office-presentation",
|
||||||
|
"odp": "x-office-presentation",
|
||||||
|
// archives
|
||||||
|
"zip": "package-x-generic",
|
||||||
|
"tar": "package-x-generic",
|
||||||
|
"gz": "package-x-generic",
|
||||||
|
"xz": "package-x-generic",
|
||||||
|
"7z": "package-x-generic",
|
||||||
|
"rar": "package-x-generic",
|
||||||
|
// audio/video
|
||||||
|
"mp3": "audio-x-generic",
|
||||||
|
"flac": "audio-x-generic",
|
||||||
|
"wav": "audio-x-generic",
|
||||||
|
"ogg": "audio-x-generic",
|
||||||
|
"mp4": "video-x-generic",
|
||||||
|
"mkv": "video-x-generic",
|
||||||
|
"webm": "video-x-generic",
|
||||||
|
"avi": "video-x-generic",
|
||||||
|
"mov": "video-x-generic",
|
||||||
|
// code
|
||||||
|
"js": "text-x-script",
|
||||||
|
"ts": "text-x-script",
|
||||||
|
"py": "text-x-script",
|
||||||
|
"sh": "text-x-script",
|
||||||
|
"qml": "text-x-script",
|
||||||
|
"cpp": "text-x-c++src",
|
||||||
|
"c": "text-x-csrc",
|
||||||
|
"h": "text-x-chdr",
|
||||||
|
"json": "application-json",
|
||||||
|
"html": "text-html",
|
||||||
|
"css": "text-css"
|
||||||
|
})
|
||||||
|
|
||||||
|
function showInFolder(path: string): void {
|
||||||
|
const explorer = Config.general.apps.explorer;
|
||||||
|
let args = [];
|
||||||
|
|
||||||
|
if (explorer.includes("dolphin"))
|
||||||
|
args = [...explorer, "--select", path];
|
||||||
|
else
|
||||||
|
args = [...explorer, path];
|
||||||
|
|
||||||
|
if (Config.launcher.uwsm)
|
||||||
|
Quickshell.execDetached(["app2unit", "--", ...args]);
|
||||||
|
else
|
||||||
|
Quickshell.execDetached([...args]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(command: list<string>): void {
|
||||||
|
if (Config.launcher.uwsm)
|
||||||
|
Quickshell.execDetached(["app2unit", "--"].concat(command));
|
||||||
|
else
|
||||||
|
Quickshell.execDetached(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformSearch(search: string): string {
|
||||||
|
return search.slice(Config.launcher.actionPrefix.length + "files ".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(search: string): list<var> {
|
||||||
|
search = transformSearch(search);
|
||||||
|
if (search !== _pendingQuery) {
|
||||||
|
_pendingQuery = search;
|
||||||
|
debounceTimer.restart();
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extOf(name: string): string {
|
||||||
|
const i = name.lastIndexOf(".");
|
||||||
|
// no dot, or dotfile with no real extension (".bashrc" -> treat as no ext)
|
||||||
|
if (i <= 0)
|
||||||
|
return "";
|
||||||
|
return name.slice(i + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _iconFor(name: string, isDir: bool): string {
|
||||||
|
if (isDir)
|
||||||
|
return "folder";
|
||||||
|
const ext = _extOf(name);
|
||||||
|
return _extIcons[ext] ?? "text-x-generic";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildEntry(path: string): var {
|
||||||
|
const name = path.split("/").pop();
|
||||||
|
const ext = _extOf(name);
|
||||||
|
// trailing slash from plocate's directory output (if -d/dir results ever appear)
|
||||||
|
const isDir = path.endsWith("/");
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: path,
|
||||||
|
name: name,
|
||||||
|
icon: _iconFor(name, isDir),
|
||||||
|
isImage: !isDir && (_imageExts[ext] ?? false)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: debounceTimer
|
||||||
|
interval: 120
|
||||||
|
onTriggered: root._runQuery(root._pendingQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
function _runQuery(search: string): void {
|
||||||
|
_generation++;
|
||||||
|
|
||||||
|
if (!search) {
|
||||||
|
proc.running = false;
|
||||||
|
results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proc.running)
|
||||||
|
proc.running = false;
|
||||||
|
|
||||||
|
proc.buffer = [];
|
||||||
|
proc.myGeneration = _generation;
|
||||||
|
proc.command = ["plocate", "-i", "-b", "--limit", "50", search];
|
||||||
|
proc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: proc
|
||||||
|
|
||||||
|
property var buffer: []
|
||||||
|
property int myGeneration: 0
|
||||||
|
|
||||||
|
stdout: SplitParser {
|
||||||
|
onRead: line => proc.buffer.push(root._buildEntry(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
onRunningChanged: {
|
||||||
|
if (!running && myGeneration === root._generation)
|
||||||
|
root.results = buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ qml_module(ZShell-llm
|
|||||||
chat.hpp chat.cpp
|
chat.hpp chat.cpp
|
||||||
chatstore.hpp chatstore.cpp
|
chatstore.hpp chatstore.cpp
|
||||||
codehighlighter.hpp codehighlighter.cpp
|
codehighlighter.hpp codehighlighter.cpp
|
||||||
|
filetool.hpp filetool.cpp
|
||||||
generation.hpp generation.cpp
|
generation.hpp generation.cpp
|
||||||
llmclient.hpp llmclient.cpp
|
llmclient.hpp llmclient.cpp
|
||||||
markdownblock.hpp
|
markdownblock.hpp
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "chat.hpp"
|
#include "chat.hpp"
|
||||||
|
|
||||||
#include "config.hpp"
|
#include "config.hpp"
|
||||||
|
#include "filetool.hpp"
|
||||||
#include "llm.hpp"
|
#include "llm.hpp"
|
||||||
#include "llmclient.hpp"
|
#include "llmclient.hpp"
|
||||||
#include "webfetchtool.hpp"
|
#include "webfetchtool.hpp"
|
||||||
@@ -17,6 +18,7 @@ Chat::Chat(QObject* parent)
|
|||||||
|
|
||||||
m_store->setLlmClient(m_client);
|
m_store->setLlmClient(m_client);
|
||||||
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
|
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
|
||||||
|
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
|
||||||
|
|
||||||
const auto* llm = config::Config::instance()->llm();
|
const auto* llm = config::Config::instance()->llm();
|
||||||
m_client->setEndpoint(llm->endpoint());
|
m_client->setEndpoint(llm->endpoint());
|
||||||
@@ -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*) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user