new chatlistview component, file attachments, performance & primitive test
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Failing after 11s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Failing after 31s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m37s
Rust / fmt (pull_request) Successful in 27s
Rust / clippy (pull_request) Successful in 58s
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Failing after 11s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Failing after 31s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m37s
Rust / fmt (pull_request) Successful in 27s
Rust / clippy (pull_request) Successful in 58s
This commit is contained in:
@@ -9,6 +9,7 @@ ScrollBar {
|
||||
id: root
|
||||
|
||||
required property Flickable flickable
|
||||
|
||||
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
|
||||
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
|
||||
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
|
||||
@@ -20,9 +21,11 @@ ScrollBar {
|
||||
readonly property real nonAnimY: root.axisContentPos / root.axisContentSize
|
||||
readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight)
|
||||
readonly property bool reversed: isHorizontal ? (flickable instanceof ListView && flickable.layoutDirection === Qt.RightToLeft) : (flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop)
|
||||
property bool shouldBeActive
|
||||
readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0
|
||||
|
||||
property bool shouldBeActive
|
||||
property bool inverted
|
||||
|
||||
enabled: !Visibilities.getForActive().isDrawing
|
||||
implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2
|
||||
implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0
|
||||
@@ -119,7 +122,7 @@ ScrollBar {
|
||||
}
|
||||
|
||||
var delta = event.angleDelta.y > 0 ? -0.1 : 0.1;
|
||||
var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta));
|
||||
var newPos = Math.max(0, Math.min(1 - root.size, root.position + (root.inverted ? -delta : delta)));
|
||||
root.position = newPos;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ CustomListView {
|
||||
id: root
|
||||
|
||||
property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1
|
||||
property bool fadeEnabled: true
|
||||
property real fadeAmount: 0.1
|
||||
property real fadeThreshold: 0.0
|
||||
readonly property bool horizontal: orientation === ListView.Horizontal
|
||||
@@ -17,7 +18,9 @@ CustomListView {
|
||||
}
|
||||
|
||||
function fadeShouldBeActive(isStart: bool): bool {
|
||||
// When content is smaller than flickable size, hide fade when rebound starts.
|
||||
if (!fadeEnabled)
|
||||
return false;
|
||||
|
||||
if (contentSize() + marginStart() + marginEnd() < viewportSize() && rebound.running && ((isStart ? overshootStart() > 0 : overshootStart() < 0))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Components
|
||||
import ZShell.Config
|
||||
@@ -11,7 +14,6 @@ Item {
|
||||
id: root
|
||||
|
||||
property ChatSession chatData
|
||||
property bool following: true
|
||||
// qmlformat off
|
||||
readonly property var keys: [
|
||||
Qt.Key_A,
|
||||
@@ -87,12 +89,33 @@ Item {
|
||||
|
||||
signal requestClose
|
||||
|
||||
readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4
|
||||
|
||||
property var pendingAttachments: []
|
||||
|
||||
function attachFiles(): void {
|
||||
Files.pickFiles().then(files => {
|
||||
if (!files || files.length === 0)
|
||||
return;
|
||||
const added = chatData.addAttachments(files);
|
||||
root.pendingAttachments = root.pendingAttachments.concat(added);
|
||||
});
|
||||
}
|
||||
|
||||
function send(text: string): void {
|
||||
if (text.trim() === "")
|
||||
if (text.trim() === "" && root.pendingAttachments.length === 0)
|
||||
return;
|
||||
following = true;
|
||||
chatData.sendMessage(text);
|
||||
root.scrollToLatest();
|
||||
chatData.sendMessage(text, root.pendingAttachments);
|
||||
input.text = "";
|
||||
root.pendingAttachments = [];
|
||||
}
|
||||
|
||||
function scrollToLatest(): void {
|
||||
if (!listLoader.item || root.atNewestEnd)
|
||||
return;
|
||||
scrollAnim.stop();
|
||||
scrollAnim.start();
|
||||
}
|
||||
|
||||
function focusInput(): void {
|
||||
@@ -110,6 +133,18 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Anim {
|
||||
id: scrollAnim
|
||||
|
||||
target: listLoader.item
|
||||
property: "contentY"
|
||||
to: 0
|
||||
type: Anim.DefaultEffects
|
||||
|
||||
onFinished: if (target)
|
||||
target.contentY = 0
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: header
|
||||
|
||||
@@ -151,41 +186,112 @@ Item {
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
active: root.chatData && root.chatData.loaded
|
||||
|
||||
sourceComponent: VerticalFadeListView {
|
||||
id: list
|
||||
sourceComponent: Flickable {
|
||||
id: flick
|
||||
|
||||
property bool userScrolledUp: false
|
||||
property real lastCHeight: 0.0
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: list.layoutHeight
|
||||
|
||||
function scrollToBottom(): void {
|
||||
scrollAnim.start();
|
||||
transform: Rotation {
|
||||
origin.y: flick.height / 2
|
||||
origin.x: flick.width / 2
|
||||
angle: 180
|
||||
}
|
||||
|
||||
cacheBuffer: Math.max(height * 20, 0)
|
||||
clip: true
|
||||
fadeAmount: 0.05
|
||||
fadeThreshold: Tokens.padding.medium
|
||||
model: root.chatData.messagesModel
|
||||
spacing: 0
|
||||
rotation: 180
|
||||
add: Transition {
|
||||
Anim {
|
||||
from: -100
|
||||
property: "y"
|
||||
WheelInverter {
|
||||
target: flick
|
||||
}
|
||||
|
||||
Anim {
|
||||
id: restoreAnim
|
||||
|
||||
target: flick
|
||||
type: Anim.DefaultEffects
|
||||
property: "contentY"
|
||||
}
|
||||
|
||||
ChatListView {
|
||||
id: list
|
||||
|
||||
width: flick.width
|
||||
height: flick.height
|
||||
yAnimDuration: Tokens.anim.durations.expressiveDefaultEffects
|
||||
|
||||
asynchronous: true
|
||||
cacheBuffer: 50
|
||||
estimatedHeight: 100
|
||||
readyDelay: 5
|
||||
removeDuration: Tokens.anim.durations.normal
|
||||
spacing: 0
|
||||
|
||||
model: root.chatData.messagesModel
|
||||
|
||||
useCustomViewport: true
|
||||
viewport: {
|
||||
tWatcher.transform;
|
||||
return Qt.rect(0, flick.contentY, width, flick.height);
|
||||
}
|
||||
onViewportAdjustNeeded: delta => {
|
||||
restoreAnim.stop();
|
||||
flick.contentY += delta;
|
||||
}
|
||||
onViewportRestoreRequested: target => {
|
||||
restoreAnim.to = target;
|
||||
restoreAnim.start();
|
||||
}
|
||||
|
||||
delegate: MessageDelegate {
|
||||
id: message
|
||||
|
||||
rootParent: flick
|
||||
chatList: list
|
||||
ChatListView.preferredHeight: implicitHeight
|
||||
ChatListView.trackViewport: true
|
||||
ChatListView.visibleHeight: ChatListView.removing ? 0 : implicitHeight
|
||||
opacity: ChatListView.removing || ChatListView.adding ? 0 : 1
|
||||
transform: Rotation {
|
||||
origin.y: message.height / 2
|
||||
origin.x: message.width / 2
|
||||
angle: 180
|
||||
}
|
||||
|
||||
Behavior on y {
|
||||
enabled: message.ChatListView.ready
|
||||
|
||||
Anim {
|
||||
id: yAnim
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on x {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomScrollBar.vertical: CustomScrollBar {
|
||||
id: scrollBar
|
||||
|
||||
flickable: list
|
||||
parent: list.parent
|
||||
anchors.top: list.top
|
||||
anchors.right: list.right
|
||||
anchors.bottom: list.bottom
|
||||
flickable: flick
|
||||
parent: flick.parent
|
||||
anchors.top: flick.top
|
||||
anchors.right: flick.right
|
||||
anchors.bottom: flick.bottom
|
||||
inverted: true
|
||||
|
||||
transform: Rotation {
|
||||
origin.y: list.height / 2
|
||||
origin.y: flick.height / 2
|
||||
origin.x: scrollBar.width / 2
|
||||
angle: 180
|
||||
|
||||
@@ -196,57 +302,15 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
delegate: MessageDelegate {
|
||||
rotation: 180
|
||||
rootParent: list
|
||||
}
|
||||
|
||||
displaced: Transition {
|
||||
Anim {
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
move: Transition {
|
||||
Anim {
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
TransformWatcher {
|
||||
id: tWatcher
|
||||
|
||||
Component.onCompleted: {
|
||||
positionViewAtBeginning();
|
||||
}
|
||||
onAtYBeginningChanged: {
|
||||
if (atYBeginning)
|
||||
userScrolledUp = false;
|
||||
}
|
||||
onContentHeightChanged: {
|
||||
if (userScrolledUp && Chat.busy) {
|
||||
const delta = contentHeight - lastCHeight;
|
||||
contentY += delta;
|
||||
}
|
||||
|
||||
lastCHeight = contentHeight;
|
||||
}
|
||||
onMovingChanged: {
|
||||
if (moving)
|
||||
userScrolledUp = !atYBeginning;
|
||||
}
|
||||
|
||||
Anim {
|
||||
id: scrollAnim
|
||||
|
||||
property: "contentY"
|
||||
target: list
|
||||
type: Anim.DefaultEffects
|
||||
to: list.originY
|
||||
}
|
||||
|
||||
WheelInverter {
|
||||
target: list
|
||||
a: flick.contentItem
|
||||
b: list
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger the load even before we're ready to show the list.
|
||||
Component.onCompleted: if (root.chatData)
|
||||
root.chatData.messagesModel
|
||||
}
|
||||
@@ -265,7 +329,7 @@ Item {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
implicitHeight: scrollToBottom.implicitHeight
|
||||
implicitWidth: scrollToBottom.implicitWidth
|
||||
scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0
|
||||
scale: root.atNewestEnd || !listLoader.item || listLoader.item.contentHeight <= listLoader.item.height ? 0 : 1
|
||||
visible: scale > 0
|
||||
|
||||
Behavior on scale {
|
||||
@@ -289,7 +353,180 @@ Item {
|
||||
type: IconButton.Tonal
|
||||
|
||||
onClicked: {
|
||||
listLoader.item.scrollToBottom();
|
||||
root.scrollToLatest();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GridView {
|
||||
id: attachments
|
||||
|
||||
readonly property int columns: ChatState.isWindow ? 5 : 3
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: input.top
|
||||
anchors.bottomMargin: Tokens.spacing.medium
|
||||
|
||||
flow: GridView.LeftToRight
|
||||
verticalLayoutDirection: GridView.BottomToTop
|
||||
|
||||
cellWidth: width / columns
|
||||
cellHeight: cellWidth
|
||||
|
||||
implicitHeight: Math.ceil(count / columns) * cellHeight
|
||||
|
||||
displaced: Transition {
|
||||
Anim {
|
||||
property: "x"
|
||||
}
|
||||
Anim {
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
|
||||
add: Transition {
|
||||
id: addTrans
|
||||
Anim {
|
||||
property: "y"
|
||||
from: addTrans.ViewTransition.destination.y + attachments.cellHeight / 4
|
||||
}
|
||||
Anim {
|
||||
property: "opacity"
|
||||
from: 0
|
||||
to: 1
|
||||
}
|
||||
}
|
||||
remove: Transition {
|
||||
id: removeTrans
|
||||
Anim {
|
||||
property: "y"
|
||||
to: removeTrans.ViewTransition.destination.y + attachments.cellHeight / 4
|
||||
}
|
||||
Anim {
|
||||
property: "opacity"
|
||||
from: 1
|
||||
to: 0
|
||||
}
|
||||
}
|
||||
|
||||
model: ScriptModel {
|
||||
objectProp: "path"
|
||||
values: root.pendingAttachments
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
id: thumb
|
||||
|
||||
required property var modelData
|
||||
function fileIcon(mime): string {
|
||||
if (mime.startsWith("archive/") || mime === "application/zip")
|
||||
return "folder_zip";
|
||||
if (mime === "application/pdf")
|
||||
return "picture_as_pdf";
|
||||
if (mime.startsWith("audio/"))
|
||||
return "audio_file";
|
||||
if (mime.startsWith("video/"))
|
||||
return "video_file";
|
||||
if (mime.startsWith("text/"))
|
||||
return "description";
|
||||
return "insert_drive_file";
|
||||
}
|
||||
|
||||
width: attachments.cellWidth
|
||||
height: width
|
||||
opacity: modelData ? 1 : 0
|
||||
CustomClippingRect {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.spacing.extraSmall
|
||||
|
||||
radius: Tokens.rounding.medium
|
||||
color: Colors.tPalette.m3surfaceContainer
|
||||
|
||||
FadeImage {
|
||||
id: attImage
|
||||
|
||||
anchors.fill: parent
|
||||
fillMode: FadeImage.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
source: visible ? thumb.modelData?.path ?? "" : ""
|
||||
visible: thumb.modelData?.isImage ?? false
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
anchors.centerIn: parent
|
||||
font.pointSize: Tokens.font.size.extraLarge
|
||||
text: thumb.fileIcon(thumb.modelData?.mimeType ?? "")
|
||||
visible: !thumb.modelData?.isImage ?? false
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: attIndicatorLoader
|
||||
|
||||
anchors.centerIn: parent
|
||||
|
||||
opacity: 0
|
||||
active: opacity > 0 && (thumb.modelData?.isImage ?? false)
|
||||
|
||||
sourceComponent: CustomRect {
|
||||
implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
|
||||
implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
|
||||
|
||||
color: Colors.palette.m3primaryContainer
|
||||
radius: Tokens.rounding.full
|
||||
|
||||
LoadingIndicator {
|
||||
id: attLoadingIndicator
|
||||
|
||||
anchors.centerIn: parent
|
||||
containsIcon: true
|
||||
implicitSize: thumb.implicitWidth * 0.4
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: attLoadDebounceTimer
|
||||
|
||||
interval: 100
|
||||
onTriggered: {
|
||||
if (attImage.status !== Image.Ready)
|
||||
attIndicatorLoader.opacity = 1;
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: hover
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: undefined
|
||||
|
||||
IconButton {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Tokens.padding.medium
|
||||
|
||||
icon: "close"
|
||||
visible: opacity > 0
|
||||
enabled: thumb.opacity > 0
|
||||
opacity: hover.containsMouse ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: root.pendingAttachments = root.pendingAttachments.filter(a => a !== thumb.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,7 +547,9 @@ Item {
|
||||
sendIcon.icon: "arrow_upward"
|
||||
sendIcon.padding: Tokens.padding.extraSmall
|
||||
|
||||
Component.onCompleted: root.focusInput()
|
||||
Component.onCompleted: {
|
||||
root.focusInput();
|
||||
}
|
||||
Keys.onPressed: e => {
|
||||
if (e.key == Qt.Key_Return) {
|
||||
if (!(e.modifiers & Qt.ShiftModifier)) {
|
||||
@@ -318,6 +557,14 @@ Item {
|
||||
e.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key == Qt.Key_V && e.modifiers & Qt.ControlModifier) {
|
||||
if (root.chatData.clipboardHasAttachment()) {
|
||||
const att = root.chatData.pasteAttachments();
|
||||
root.pendingAttachments = [...root.pendingAttachments, ...att];
|
||||
e.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
onSendPressed: root.send(text)
|
||||
}
|
||||
|
||||
@@ -16,10 +16,15 @@ CustomRect {
|
||||
property bool copied: false
|
||||
property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
|
||||
property color codeHeaderColor: Colors.palette.m3outline
|
||||
property var codeSpans: []
|
||||
property string html: ""
|
||||
property bool colored: false
|
||||
property string requestedCode: ""
|
||||
property string requestedLanguage: ""
|
||||
property int highlightToken: 0
|
||||
|
||||
function refresh() {
|
||||
requestedCode = root.code;
|
||||
requestedLanguage = root.language;
|
||||
const token = ++root.highlightToken;
|
||||
CodeHighlighter.highlight(root.code, root.language, root, token);
|
||||
}
|
||||
@@ -27,7 +32,11 @@ CustomRect {
|
||||
function onHighlightSpans(token, spans) {
|
||||
if (token !== root.highlightToken)
|
||||
return;
|
||||
codeSpans = spans;
|
||||
if (root.code !== root.requestedCode ||
|
||||
root.language !== root.requestedLanguage)
|
||||
return;
|
||||
html = spans.length ? root.highlightedHtml(root.code, spans) : "";
|
||||
colored = root.visible && html.length > 0;
|
||||
}
|
||||
|
||||
function roleColor(kind) {
|
||||
@@ -71,6 +80,21 @@ CustomRect {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Mirrors the whitespace rules of the rich text rendering below (leading
|
||||
// whitespace kept, mid-line runs collapsed, tabs as single spaces) so the
|
||||
// plain and colored layouts are identical.
|
||||
function plainForDisplay(code): string {
|
||||
return code.split("\n")
|
||||
.map(line => {
|
||||
const i = line.search(/\S/);
|
||||
if (i < 0)
|
||||
return "";
|
||||
const lead = line.slice(0, i).replace(/\t/g, " ");
|
||||
return lead + line.slice(i).replace(/\s+/g, " ").replace(/\s+$/, "");
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function highlightedHtml(code, spans): string {
|
||||
let out;
|
||||
if (!spans.length) {
|
||||
@@ -101,10 +125,26 @@ CustomRect {
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
onLanguageChanged: {
|
||||
codeSpans = [];
|
||||
refresh();
|
||||
html = "";
|
||||
colored = false;
|
||||
if (root.visible)
|
||||
refreshTimer.restart();
|
||||
}
|
||||
onCodeChanged: {
|
||||
colored = false;
|
||||
if (root.visible)
|
||||
refreshTimer.restart();
|
||||
}
|
||||
onVisibleChanged: if (root.visible)
|
||||
refreshTimer.restart();
|
||||
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
|
||||
interval: 200
|
||||
repeat: false
|
||||
onTriggered: refresh()
|
||||
}
|
||||
onCodeChanged: refresh()
|
||||
|
||||
RowLayout {
|
||||
id: headerRow
|
||||
@@ -229,21 +269,6 @@ CustomRect {
|
||||
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
|
||||
color: CodeColors.active.bg
|
||||
|
||||
CustomText {
|
||||
id: code
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small
|
||||
anchors.margins: Tokens.padding.small
|
||||
text: root.highlightedHtml(root.code, root.codeSpans)
|
||||
textFormat: Text.RichText
|
||||
color: CodeColors.active.normal
|
||||
layer.enabled: true
|
||||
clip: false
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: codeFlick
|
||||
|
||||
@@ -263,12 +288,11 @@ CustomRect {
|
||||
|
||||
color: CodeColors.active.normal
|
||||
layer.enabled: true
|
||||
leftInset: Tokens.padding.small
|
||||
clip: false
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
textFormat: Text.RichText
|
||||
text: code.text
|
||||
textFormat: root.colored ? Text.RichText : Text.PlainText
|
||||
text: root.colored ? root.html : root.plainForDisplay(root.code)
|
||||
readOnly: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Llm
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property ChatGeneration current
|
||||
|
||||
signal dismiss
|
||||
|
||||
implicitHeight: 0
|
||||
|
||||
Component.onCompleted: {
|
||||
root.implicitHeight = Qt.binding(() => errorInner.implicitHeight + errorInner.anchors.margins * 2);
|
||||
enterAnim.start();
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: enterAnim
|
||||
|
||||
Anim {
|
||||
target: container
|
||||
property: "y"
|
||||
from: container.implicitHeight
|
||||
to: 0
|
||||
}
|
||||
|
||||
Anim {
|
||||
target: container
|
||||
property: "opacity"
|
||||
from: 0
|
||||
to: 1
|
||||
}
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: exitAnim
|
||||
|
||||
Anim {
|
||||
target: root
|
||||
property: "implicitHeight"
|
||||
from: root.implicitHeight
|
||||
to: 0
|
||||
}
|
||||
|
||||
Anim {
|
||||
target: container
|
||||
property: "y"
|
||||
to: container.implicitHeight
|
||||
from: 0
|
||||
}
|
||||
|
||||
Anim {
|
||||
target: container
|
||||
property: "opacity"
|
||||
to: 0
|
||||
from: 1
|
||||
}
|
||||
|
||||
onFinished: root.dismiss()
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: container
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
radius: Tokens.rounding.medium
|
||||
color: Colors.palette.m3errorContainer
|
||||
implicitHeight: errorInner.implicitHeight + errorInner.anchors.margins * 2
|
||||
|
||||
ColumnLayout {
|
||||
id: errorInner
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
CustomText {
|
||||
id: header
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
font.pointSize: Tokens.font.size.large
|
||||
elide: CustomText.ElideRight
|
||||
color: Colors.palette.m3onErrorContainer
|
||||
|
||||
text: qsTr("An error occured")
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: body
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
wrapMode: CustomText.WordWrap
|
||||
color: Colors.palette.m3onErrorContainer
|
||||
|
||||
text: root.current.errorMessage || Chat.serverError
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
|
||||
isRound: true
|
||||
icon: "close"
|
||||
type: IconButton.Text
|
||||
font.pointSize: Tokens.font.size.large
|
||||
|
||||
onClicked: exitAnim.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,7 @@ Item {
|
||||
id: equationImage
|
||||
|
||||
anchors.centerIn: parent
|
||||
source: math.imageUrl
|
||||
asynchronous: true
|
||||
source: math.imageId.length ? "image://llmmath/" + math.imageId : ""
|
||||
visible: math.ok
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
|
||||
@@ -3,45 +3,50 @@ pragma ComponentBehavior: Bound
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import ZShell.Components
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Modules.Notifications.Sidebar.Chat
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
MouseArea {
|
||||
id: root
|
||||
|
||||
property ChatGeneration current: modelData.activeGeneration
|
||||
required property int index
|
||||
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
||||
required property Item rootParent
|
||||
required property ChatMessage modelData
|
||||
property bool reasoningExpanded: false
|
||||
readonly property var blocks: blockify(current.segments)
|
||||
property real savedOffset: -1
|
||||
property bool stickActive: false
|
||||
required property ChatListView chatList
|
||||
|
||||
function handleReasoningToggle(expanded: bool): void {
|
||||
const view = root.ListView.view;
|
||||
if (!view)
|
||||
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
||||
readonly property var blocks: blockify(current.segments, current.error)
|
||||
|
||||
property ChatGeneration current: modelData.activeGeneration
|
||||
property bool reasoningExpanded: false
|
||||
property real savedOffset: NaN
|
||||
property bool anchored: false
|
||||
|
||||
function handleReasoningToggle(expanded: bool, block: Item): void {
|
||||
if (!root.rootParent)
|
||||
return;
|
||||
|
||||
if (expanded) {
|
||||
root.savedOffset = view.contentY - root.y;
|
||||
root.reasoningExpanded = true;
|
||||
root.savedOffset = root.rootParent.contentY - root.y;
|
||||
root.anchored = true;
|
||||
chatList.anchorItem(block, ChatListView.TopEdge);
|
||||
settleTimer.start();
|
||||
} else {
|
||||
root.reasoningExpanded = false;
|
||||
root.stickActive = false;
|
||||
settleTimer.stop();
|
||||
root.anchored = false;
|
||||
chatList.clearAnchor();
|
||||
if (!isNaN(root.savedOffset)) {
|
||||
restoreAnim.to = root.y + root.savedOffset;
|
||||
restoreAnim.restart();
|
||||
chatList.requestRestore(root.y + root.savedOffset);
|
||||
root.savedOffset = NaN;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function blockify(segs) {
|
||||
function blockify(segs, errored) {
|
||||
const blocks = [];
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const seg = segs[i];
|
||||
@@ -65,35 +70,32 @@ MouseArea {
|
||||
}
|
||||
}
|
||||
|
||||
if (errored)
|
||||
blocks.push({
|
||||
kind: "error",
|
||||
id: segs.length
|
||||
});
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
hoverEnabled: true
|
||||
preventStealing: false
|
||||
implicitHeight: layout.implicitHeight + Tokens.padding.medium
|
||||
implicitHeight: layout.implicitHeight + Tokens.padding.medium + (attachments.visible ? attachments.implicitHeight : 0)
|
||||
onImplicitHeightChanged: if (root.anchored)
|
||||
settleTimer.restart()
|
||||
|
||||
Timer {
|
||||
id: settleTimer
|
||||
interval: 16
|
||||
|
||||
interval: Tokens.anim.durations.expressiveDefaultEffects
|
||||
repeat: false
|
||||
|
||||
onTriggered: {
|
||||
root.stickActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
onImplicitHeightChanged: {
|
||||
if (root.reasoningExpanded) {
|
||||
root.stickActive = true;
|
||||
settleTimer.restart();
|
||||
|
||||
const view = root.ListView.view;
|
||||
if (!view)
|
||||
return;
|
||||
const bottomEdge = root.y + root.height + Tokens.padding.large * 2;
|
||||
const viewportBottom = view.contentY + view.height;
|
||||
if (bottomEdge > viewportBottom)
|
||||
view.contentY = bottomEdge - view.height;
|
||||
if (root.anchored) {
|
||||
root.anchored = false;
|
||||
chatList.clearAnchor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,20 +149,144 @@ MouseArea {
|
||||
}
|
||||
}
|
||||
|
||||
Anim {
|
||||
id: restoreAnim
|
||||
GridView {
|
||||
id: attachments
|
||||
|
||||
property: "contentY"
|
||||
target: root.ListView.view
|
||||
type: Anim.DefaultEffects
|
||||
readonly property int columns: ChatState.isWindow ? 5 : 3
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
|
||||
flow: GridView.LeftToRight
|
||||
verticalLayoutDirection: GridView.BottomToTop
|
||||
|
||||
cellWidth: width / columns
|
||||
cellHeight: cellWidth
|
||||
|
||||
implicitHeight: Math.ceil(count / columns) * cellHeight
|
||||
|
||||
displaced: Transition {
|
||||
Anim {
|
||||
property: "x"
|
||||
}
|
||||
Anim {
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
|
||||
model: ScriptModel {
|
||||
objectProp: "path"
|
||||
values: {
|
||||
const list = [...root.modelData.attachments];
|
||||
|
||||
if (list.length > 0 && list.length < attachments.columns) {
|
||||
while (list.length < attachments.columns)
|
||||
list.unshift(null);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
id: thumb
|
||||
|
||||
required property var modelData
|
||||
|
||||
function fileIcon(mime): string {
|
||||
if (mime.startsWith("archive/") || mime === "application/zip")
|
||||
return "folder_zip";
|
||||
if (mime === "application/pdf")
|
||||
return "picture_as_pdf";
|
||||
if (mime.startsWith("audio/"))
|
||||
return "audio_file";
|
||||
if (mime.startsWith("video/"))
|
||||
return "video_file";
|
||||
if (mime.startsWith("text/"))
|
||||
return "description";
|
||||
return "insert_drive_file";
|
||||
}
|
||||
|
||||
width: attachments.cellWidth
|
||||
height: width
|
||||
opacity: modelData ? 1 : 0
|
||||
|
||||
CustomClippingRect {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.spacing.extraSmall
|
||||
|
||||
radius: Tokens.rounding.medium
|
||||
color: Colors.tPalette.m3surfaceContainer
|
||||
|
||||
FadeImage {
|
||||
id: attImage
|
||||
|
||||
anchors.fill: parent
|
||||
fillMode: FadeImage.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
source: visible ? thumb.modelData?.path ?? "" : ""
|
||||
visible: thumb.modelData?.isImage ?? false
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
anchors.centerIn: parent
|
||||
font.pointSize: Tokens.font.size.extraLarge
|
||||
text: thumb.fileIcon(thumb.modelData?.mimeType ?? "")
|
||||
visible: !thumb.modelData?.isImage ?? false
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: attIndicatorLoader
|
||||
|
||||
anchors.centerIn: parent
|
||||
|
||||
opacity: 0
|
||||
active: opacity > 0 && (thumb.modelData?.isImage ?? false)
|
||||
|
||||
sourceComponent: CustomRect {
|
||||
implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
|
||||
implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
|
||||
|
||||
color: Colors.palette.m3primaryContainer
|
||||
radius: Tokens.rounding.full
|
||||
|
||||
LoadingIndicator {
|
||||
id: attLoadingIndicator
|
||||
|
||||
anchors.centerIn: parent
|
||||
containsIcon: true
|
||||
implicitSize: thumb.implicitWidth * 0.4
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: attLoadDebounceTimer
|
||||
|
||||
interval: 100
|
||||
onTriggered: {
|
||||
if (attImage.status !== Image.Ready)
|
||||
attIndicatorLoader.opacity = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: layout
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.top: attachments.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
Repeater {
|
||||
@@ -173,6 +299,17 @@ MouseArea {
|
||||
delegate: DelegateChooser {
|
||||
role: "kind"
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: "error"
|
||||
|
||||
delegate: ErrorBubble {
|
||||
width: root.width
|
||||
current: root.current
|
||||
|
||||
onDismiss: root.current.dismissError()
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: "process"
|
||||
|
||||
@@ -182,7 +319,7 @@ MouseArea {
|
||||
current: root.current
|
||||
rootParent: root.rootParent
|
||||
|
||||
onExpandedChanged: root.handleReasoningToggle(expanded)
|
||||
onExpandedChanged: root.handleReasoningToggle(expanded, this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ Item {
|
||||
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
|
||||
readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s))
|
||||
|
||||
clip: true
|
||||
implicitHeight: {
|
||||
const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin;
|
||||
const collapsed = layout.implicitHeight;
|
||||
@@ -36,6 +35,8 @@ Item {
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
onCurrentChanged: expanded = false
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
@@ -147,19 +148,30 @@ Item {
|
||||
|
||||
property bool open: false
|
||||
property Item rootParent: root.rootParent
|
||||
property int openHeight: toolsBg.rootParent.height - Tokens.padding.medium * 2
|
||||
property int maxHeight: rootParent.height - Tokens.padding.medium * 2
|
||||
property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight)
|
||||
property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2
|
||||
property real openHeightProg: openHeight / maxHeight
|
||||
property int collapsedY: 0
|
||||
|
||||
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
|
||||
z: open || dialogTransition.running ? 2 : 0
|
||||
Layout.fillWidth: true
|
||||
|
||||
function reparentWrapper(): void {
|
||||
const newParent = open ? toolsBg.rootParent : toolsBg;
|
||||
const pos = toolsWrapper.mapToItem(newParent, 0, 0);
|
||||
const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2);
|
||||
toolsBg.collapsedY = toolsWrapper.y;
|
||||
toolsWrapper.parent = newParent;
|
||||
toolsWrapper.x = pos.x;
|
||||
toolsWrapper.y = pos.y;
|
||||
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
|
||||
toolsWrapper.x = c.x - toolsWrapper.width / 2;
|
||||
toolsWrapper.y = c.y - toolsWrapper.height / 2;
|
||||
}
|
||||
|
||||
onOpenChanged: {
|
||||
if (open)
|
||||
toolsBg.rootParent.interactive = false;
|
||||
else
|
||||
toolsBg.rootParent.interactive = true;
|
||||
}
|
||||
|
||||
BlobGroup {
|
||||
@@ -189,6 +201,8 @@ Item {
|
||||
|
||||
width: toolsBg.width
|
||||
height: toolBox.implicitHeight
|
||||
rotation: parent === toolsBg.rootParent ? 180 : 0
|
||||
z: 1
|
||||
|
||||
states: State {
|
||||
name: "open"
|
||||
@@ -200,11 +214,11 @@ Item {
|
||||
toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased
|
||||
toolsBackground.topRightRadius: Tokens.rounding.largeIncreased
|
||||
toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased
|
||||
// toolsContent.opacity: 1
|
||||
toolsContent.opacity: 1
|
||||
toolsWrapper.height: toolsBg.openHeight
|
||||
toolsWrapper.width: toolsBg.openWidth
|
||||
toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2
|
||||
toolsWrapper.y: (toolsBg.rootParent.height - toolsBg.openHeight) / 2
|
||||
toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium
|
||||
elevation.opacity: 1
|
||||
toolBox.opacity: 0
|
||||
}
|
||||
@@ -274,7 +288,6 @@ Item {
|
||||
|
||||
StateLayer {
|
||||
onClicked: {
|
||||
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
|
||||
toolsBg.open = true;
|
||||
}
|
||||
}
|
||||
@@ -287,7 +300,10 @@ Item {
|
||||
anchors.margins: Tokens.padding.medium
|
||||
|
||||
Repeater {
|
||||
model: root.current.pendingToolCalls ?? []
|
||||
id: repeater
|
||||
|
||||
model: root.current.pendingToolCalls?.slice(0, 4) ?? []
|
||||
|
||||
delegate: RowLayout {
|
||||
id: toolText
|
||||
|
||||
@@ -309,10 +325,10 @@ Item {
|
||||
return String(seg.arguments);
|
||||
}
|
||||
|
||||
CustomText {
|
||||
MaterialIcon {
|
||||
color: Colors.palette.m3onPrimaryContainer
|
||||
text: String(toolText.index + 1)
|
||||
font.pointSize: Tokens.font.size.large
|
||||
text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet"
|
||||
fill: 1
|
||||
}
|
||||
|
||||
CustomText {
|
||||
@@ -331,12 +347,134 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
color: Colors.palette.m3onPrimaryContainer
|
||||
text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length)
|
||||
visible: repeater.count < root.current.pendingToolCalls.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: toolsContent
|
||||
|
||||
active: opacity > 0
|
||||
anchors.fill: parent
|
||||
asynchronous: false
|
||||
opacity: 0
|
||||
|
||||
sourceComponent: MouseArea {
|
||||
onWheel: event => event.accepted = true
|
||||
preventStealing: true
|
||||
implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2
|
||||
|
||||
VerticalFadeListView {
|
||||
id: innerLayout
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.small
|
||||
cacheBuffer: height * 10
|
||||
fadeAmount: 0.1
|
||||
fadeEnabled: interactive
|
||||
spacing: 0
|
||||
clip: true
|
||||
interactive: contentHeight > toolsBg.maxHeight
|
||||
|
||||
model: root.current.pendingToolCalls ?? []
|
||||
|
||||
delegate: Item {
|
||||
id: toolCall
|
||||
|
||||
required property LlmSegment modelData
|
||||
property bool expanded: false
|
||||
|
||||
implicitWidth: CustomListView.view.width
|
||||
implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins)
|
||||
|
||||
function toolTarget(seg: LlmSegment): string {
|
||||
var a = null;
|
||||
try {
|
||||
a = JSON.parse(seg.arguments);
|
||||
} catch (e) {}
|
||||
if (a && typeof a === "object") {
|
||||
const v = a.url ?? a.path;
|
||||
if (v !== undefined && v !== null)
|
||||
return String(v);
|
||||
}
|
||||
return String(seg.arguments);
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: collapsedRow
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
|
||||
IconButton {
|
||||
id: toolExpandBtn
|
||||
|
||||
font.pointSize: Tokens.font.size.large
|
||||
icon: "keyboard_arrow_down"
|
||||
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||
rotation: toolCall.expanded ? 180 : 0
|
||||
type: IconButton.Text
|
||||
|
||||
Behavior on rotation {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: toolCall.expanded = !toolCall.expanded
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: preview
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
text: toolCall.modelData.name
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
id: expandedTool
|
||||
|
||||
anchors.top: collapsedRow.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
|
||||
implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2
|
||||
radius: Tokens.rounding.medium
|
||||
color: Colors.palette.m3surfaceContainerLow
|
||||
|
||||
CustomText {
|
||||
id: fullToolCall
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.medium
|
||||
|
||||
text: toolCall.toolTarget(toolCall.modelData)
|
||||
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ButtonRow {
|
||||
id: confirmationRow
|
||||
Layout.alignment: Qt.AlignRight
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
@@ -366,7 +504,7 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
CustomClippingRect {
|
||||
id: expandedRect
|
||||
|
||||
anchors.left: parent.left
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter)
|
||||
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick QuickPrivate Concurrent Sql Network DBus CanvasPainter)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
|
||||
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
qml_module(ZShell-components
|
||||
URI ZShell.Components
|
||||
SOURCES
|
||||
lazylistview.hpp lazylistview.cpp
|
||||
SOURCES
|
||||
lazylistview.hpp lazylistview.cpp
|
||||
chatlistview.hpp chatlistview.cpp
|
||||
wavyline.hpp wavyline.cpp
|
||||
buttonrow.hpp buttonrow.cpp
|
||||
carouselview.hpp carouselview.cpp
|
||||
wheelinverter.hpp
|
||||
LIBRARIES
|
||||
Qt::Quick
|
||||
LIBRARIES
|
||||
Qt::Quick
|
||||
Qt::QuickPrivate
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
#pragma once
|
||||
|
||||
#include <QAbstractItemModel>
|
||||
#include <QElapsedTimer>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QQmlComponent>
|
||||
#include <QQuickItem>
|
||||
#include <QRect>
|
||||
#include <QTimer>
|
||||
#include <QVector>
|
||||
|
||||
namespace ZShell::components {
|
||||
|
||||
class ChatListViewAttached : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
Q_PROPERTY(
|
||||
qreal preferredHeight READ preferredHeight WRITE setPreferredHeight
|
||||
NOTIFY preferredHeightChanged)
|
||||
Q_PROPERTY(
|
||||
qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY
|
||||
visibleHeightChanged)
|
||||
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
|
||||
Q_PROPERTY(bool adding READ adding NOTIFY addingChanged)
|
||||
Q_PROPERTY(bool removing READ removing NOTIFY removingChanged)
|
||||
Q_PROPERTY(
|
||||
bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY
|
||||
trackViewportChanged)
|
||||
|
||||
public:
|
||||
explicit ChatListViewAttached(QObject* parent = nullptr);
|
||||
|
||||
[[nodiscard]] qreal preferredHeight() const;
|
||||
void setPreferredHeight(qreal height);
|
||||
|
||||
[[nodiscard]] qreal visibleHeight() const;
|
||||
void setVisibleHeight(qreal height);
|
||||
|
||||
[[nodiscard]] bool ready() const;
|
||||
void setReady(bool ready);
|
||||
|
||||
[[nodiscard]] bool adding() const;
|
||||
void setAdding(bool adding);
|
||||
|
||||
[[nodiscard]] bool removing() const;
|
||||
void setRemoving(bool removing);
|
||||
|
||||
[[nodiscard]] bool trackViewport() const;
|
||||
void setTrackViewport(bool track);
|
||||
|
||||
signals:
|
||||
void preferredHeightChanged();
|
||||
void visibleHeightChanged();
|
||||
void readyChanged();
|
||||
void addingChanged();
|
||||
void removingChanged();
|
||||
void trackViewportChanged();
|
||||
|
||||
private:
|
||||
qreal m_preferredHeight = -1;
|
||||
qreal m_visibleHeight = -1;
|
||||
bool m_ready = false;
|
||||
bool m_adding = false;
|
||||
bool m_removing = false;
|
||||
bool m_trackViewport = false;
|
||||
};
|
||||
|
||||
class ChatListView : public QQuickItem {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_ATTACHED(ChatListViewAttached)
|
||||
|
||||
public:
|
||||
Q_PROPERTY(
|
||||
QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged)
|
||||
Q_PROPERTY(
|
||||
QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY
|
||||
delegateChanged)
|
||||
|
||||
Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged)
|
||||
Q_PROPERTY(
|
||||
qreal contentHeight READ contentHeight NOTIFY contentHeightChanged)
|
||||
Q_PROPERTY(qreal layoutHeight READ layoutHeight NOTIFY layoutHeightChanged)
|
||||
Q_PROPERTY(
|
||||
qreal contentY READ contentY WRITE setContentY NOTIFY contentYChanged)
|
||||
|
||||
Q_PROPERTY(
|
||||
QRectF viewport READ viewport WRITE setViewport NOTIFY viewportChanged)
|
||||
Q_PROPERTY(
|
||||
bool useCustomViewport READ useCustomViewport WRITE setUseCustomViewport
|
||||
NOTIFY useCustomViewportChanged)
|
||||
Q_PROPERTY(
|
||||
qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY
|
||||
cacheBufferChanged)
|
||||
|
||||
Q_PROPERTY(
|
||||
qreal estimatedHeight READ estimatedHeight WRITE setEstimatedHeight
|
||||
NOTIFY estimatedHeightChanged)
|
||||
|
||||
Q_PROPERTY(
|
||||
bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY
|
||||
asynchronousChanged)
|
||||
|
||||
Q_PROPERTY(
|
||||
int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY
|
||||
removeDurationChanged)
|
||||
Q_PROPERTY(
|
||||
int readyDelay READ readyDelay WRITE setReadyDelay NOTIFY
|
||||
readyDelayChanged)
|
||||
Q_PROPERTY(
|
||||
int yAnimDuration READ yAnimDuration WRITE setYAnimDuration NOTIFY
|
||||
yAnimDurationChanged)
|
||||
|
||||
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||
|
||||
enum Edge {
|
||||
TopEdge,
|
||||
BottomEdge,
|
||||
};
|
||||
Q_ENUM(Edge)
|
||||
|
||||
Q_INVOKABLE void anchorItem(QQuickItem* item, Edge edge);
|
||||
Q_INVOKABLE void clearAnchor();
|
||||
Q_INVOKABLE void requestRestore(qreal targetY);
|
||||
|
||||
explicit ChatListView(QQuickItem* parent = nullptr);
|
||||
~ChatListView() override;
|
||||
|
||||
static ChatListViewAttached* qmlAttachedProperties(QObject* object);
|
||||
|
||||
[[nodiscard]] QAbstractItemModel* model() const;
|
||||
void setModel(QAbstractItemModel* model);
|
||||
|
||||
[[nodiscard]] QQmlComponent* delegate() const;
|
||||
void setDelegate(QQmlComponent* delegate);
|
||||
|
||||
[[nodiscard]] qreal spacing() const;
|
||||
void setSpacing(qreal spacing);
|
||||
|
||||
[[nodiscard]] qreal contentHeight() const;
|
||||
[[nodiscard]] qreal layoutHeight() const;
|
||||
|
||||
[[nodiscard]] qreal contentY() const;
|
||||
void setContentY(qreal contentY);
|
||||
|
||||
[[nodiscard]] QRectF viewport() const;
|
||||
void setViewport(const QRectF& viewport);
|
||||
|
||||
[[nodiscard]] bool useCustomViewport() const;
|
||||
void setUseCustomViewport(bool use);
|
||||
|
||||
[[nodiscard]] qreal cacheBuffer() const;
|
||||
void setCacheBuffer(qreal buffer);
|
||||
|
||||
[[nodiscard]] qreal estimatedHeight() const;
|
||||
void setEstimatedHeight(qreal height);
|
||||
|
||||
[[nodiscard]] bool asynchronous() const;
|
||||
void setAsynchronous(bool async);
|
||||
|
||||
[[nodiscard]] int removeDuration() const;
|
||||
void setRemoveDuration(int duration);
|
||||
|
||||
[[nodiscard]] int readyDelay() const;
|
||||
void setReadyDelay(int delay);
|
||||
|
||||
[[nodiscard]] int yAnimDuration() const;
|
||||
void setYAnimDuration(int duration);
|
||||
|
||||
[[nodiscard]] int count() const;
|
||||
|
||||
signals:
|
||||
void modelChanged();
|
||||
void delegateChanged();
|
||||
void spacingChanged();
|
||||
void contentHeightChanged();
|
||||
void layoutHeightChanged();
|
||||
void contentYChanged();
|
||||
void viewportChanged();
|
||||
void useCustomViewportChanged();
|
||||
void cacheBufferChanged();
|
||||
void estimatedHeightChanged();
|
||||
void asynchronousChanged();
|
||||
void removeDurationChanged();
|
||||
void readyDelayChanged();
|
||||
void yAnimDurationChanged();
|
||||
void countChanged();
|
||||
void viewportAdjustNeeded(qreal delta);
|
||||
void viewportRestoreRequested(qreal targetY);
|
||||
|
||||
protected:
|
||||
void componentComplete() override;
|
||||
void geometryChange(
|
||||
const QRectF& newGeometry, const QRectF& oldGeometry) override;
|
||||
void updatePolish() override;
|
||||
bool event(QEvent* e) override;
|
||||
|
||||
private:
|
||||
struct ItemRecord {
|
||||
qreal targetY = 0;
|
||||
qreal height = 0;
|
||||
bool heightKnown = false;
|
||||
bool isNew = false;
|
||||
qreal measuredWidth = -1;
|
||||
};
|
||||
|
||||
struct DelegateEntry {
|
||||
int modelIndex = -1;
|
||||
QQuickItem* item = nullptr;
|
||||
bool pendingRemoval = false;
|
||||
bool pendingInsert = false;
|
||||
bool readyDelayStarted = false;
|
||||
bool measureOnly = false;
|
||||
qreal propY = 0;
|
||||
bool hasPropY = false;
|
||||
qint64 lastPropTime = 0;
|
||||
qint64 lastHeightChange = 0;
|
||||
};
|
||||
|
||||
void relayout();
|
||||
[[nodiscard]] std::pair<int, int> computeVisibleRange() const;
|
||||
[[nodiscard]] QRectF effectiveViewport() const;
|
||||
[[nodiscard]] qreal effectiveEstimatedHeight() const;
|
||||
[[nodiscard]] static bool isStaleRecord(const ItemRecord& record,
|
||||
qreal viewWidth);
|
||||
[[nodiscard]] static qreal delegateHeight(QQuickItem* item);
|
||||
[[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item);
|
||||
[[nodiscard]] static bool isDelegateReady(QQuickItem* item);
|
||||
void trackHeight(qreal height);
|
||||
void untrackHeight(qreal height);
|
||||
|
||||
void syncDelegates();
|
||||
void pumpMeasureQueue();
|
||||
void buildMeasureQueue();
|
||||
DelegateEntry createDelegate(int modelIndex);
|
||||
void destroyDelegate(DelegateEntry& entry);
|
||||
void updateDelegateData(DelegateEntry& entry);
|
||||
|
||||
void connectModel();
|
||||
void disconnectModel();
|
||||
void resetContent();
|
||||
void onRowsInserted(const QModelIndex& parent, int first, int last);
|
||||
void onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last);
|
||||
void onRowsRemoved(const QModelIndex& parent, int first, int last);
|
||||
void onDataChanged(
|
||||
const QModelIndex& topLeft,
|
||||
const QModelIndex& bottomRight,
|
||||
const QList<int>& roles);
|
||||
void onModelReset();
|
||||
|
||||
void applyAnchor();
|
||||
|
||||
QAbstractItemModel* m_model = nullptr;
|
||||
QQmlComponent* m_delegate = nullptr;
|
||||
|
||||
qreal m_spacing = 0;
|
||||
qreal m_contentHeight = 0;
|
||||
qreal m_layoutHeight = 0;
|
||||
qreal m_contentY = 0;
|
||||
|
||||
QRectF m_viewport;
|
||||
bool m_useCustomViewport = false;
|
||||
qreal m_cacheBuffer = 0;
|
||||
|
||||
qreal m_estimatedHeight = -1;
|
||||
qreal m_knownHeightSum = 0;
|
||||
int m_knownHeightCount = 0;
|
||||
bool m_asynchronous = false;
|
||||
|
||||
int m_removeDuration = 300;
|
||||
int m_readyDelay = 0;
|
||||
int m_yAnimDuration = 300;
|
||||
QElapsedTimer m_clock;
|
||||
|
||||
QVector<ItemRecord> m_layout;
|
||||
QHash<int, DelegateEntry> m_delegates;
|
||||
QHash<QQuickItem*, int> m_itemToIndex;
|
||||
QVector<DelegateEntry> m_dyingDelegates;
|
||||
|
||||
QVector<int> m_measureQueue;
|
||||
QTimer* m_measureTimer = nullptr;
|
||||
|
||||
bool m_componentComplete = false;
|
||||
bool m_relayoutPending = false;
|
||||
bool m_animateY = false;
|
||||
|
||||
QPointer<QQuickItem> m_anchorItem;
|
||||
Edge m_anchorEdge = TopEdge;
|
||||
int m_restoreIndex = -1;
|
||||
bool m_restoreActive = false;
|
||||
qint64 m_restoreQuietUntil = 0;
|
||||
|
||||
QList<QMetaObject::Connection> m_modelConnections;
|
||||
};
|
||||
|
||||
} // namespace ZShell::components
|
||||
@@ -24,6 +24,7 @@ class Llm : public ConfigObject {
|
||||
CFG_PROPERTY(QString, model, "")
|
||||
CFG_PROPERTY(double, temperature, 0.7)
|
||||
CFG_PROPERTY(bool, tools, true)
|
||||
CFG_PROPERTY(bool, sandbox, true)
|
||||
CFG_PROPERTY(bool, enabled, true)
|
||||
CONFIG_SUBOBJECT(LlmAppearance, appearance)
|
||||
|
||||
@@ -32,4 +33,4 @@ class Llm : public ConfigObject {
|
||||
: ConfigObject(parent), m_appearance(new LlmAppearance(this)) {}
|
||||
};
|
||||
|
||||
} // namespace ZShell::config
|
||||
}
|
||||
|
||||
@@ -285,6 +285,7 @@ file(WRITE "${LM_FONTS_HPP}" "${_lm_header}")
|
||||
qml_module(ZShell-llm
|
||||
URI ZShell.Llm
|
||||
SOURCES
|
||||
attachment.hpp attachment.cpp
|
||||
chat.hpp chat.cpp
|
||||
chatstore.hpp chatstore.cpp
|
||||
codehighlighter.hpp codehighlighter.cpp
|
||||
@@ -298,6 +299,7 @@ qml_module(ZShell-llm
|
||||
messagemodel.hpp messagemodel.cpp
|
||||
segment.hpp segment.cpp
|
||||
session.hpp session.cpp
|
||||
shelltool.hpp shelltool.cpp
|
||||
tool.hpp tool.cpp
|
||||
webfetchtool.hpp webfetchtool.cpp
|
||||
LIBRARIES
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#include "attachment.hpp"
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
Attachment::Attachment(
|
||||
const QString& name,
|
||||
const QString& path,
|
||||
Kind kind,
|
||||
const QString& mimeType,
|
||||
qint64 size,
|
||||
QObject* parent)
|
||||
: QObject(parent)
|
||||
, m_name(name)
|
||||
, m_path(path)
|
||||
, m_kind(kind)
|
||||
, m_mimeType(mimeType)
|
||||
, m_size(size) {}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QtQml>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
class Attachment : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("Attachments are created by ChatSession.addAttachments")
|
||||
|
||||
Q_PROPERTY(QString name READ name CONSTANT)
|
||||
Q_PROPERTY(QString path READ path CONSTANT)
|
||||
Q_PROPERTY(Kind kind READ kind CONSTANT)
|
||||
Q_PROPERTY(bool isImage READ isImage CONSTANT)
|
||||
Q_PROPERTY(QString mimeType READ mimeType CONSTANT)
|
||||
Q_PROPERTY(qint64 size READ size CONSTANT)
|
||||
|
||||
public:
|
||||
enum class Kind : int { Image = 0, File = 1 };
|
||||
Q_ENUM(Kind)
|
||||
|
||||
Attachment(
|
||||
const QString& name,
|
||||
const QString& path,
|
||||
Kind kind,
|
||||
const QString& mimeType,
|
||||
qint64 size,
|
||||
QObject* parent = nullptr);
|
||||
|
||||
[[nodiscard]] QString name() const { return m_name; }
|
||||
[[nodiscard]] QString path() const { return m_path; }
|
||||
[[nodiscard]] Kind kind() const { return m_kind; }
|
||||
[[nodiscard]] bool isImage() const { return m_kind == Kind::Image; }
|
||||
[[nodiscard]] QString mimeType() const { return m_mimeType; }
|
||||
[[nodiscard]] qint64 size() const { return m_size; }
|
||||
|
||||
private:
|
||||
QString m_name;
|
||||
QString m_path;
|
||||
Kind m_kind;
|
||||
QString m_mimeType;
|
||||
qint64 m_size = 0;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
+38
-10
@@ -4,6 +4,7 @@
|
||||
#include "filetool.hpp"
|
||||
#include "llm.hpp"
|
||||
#include "llmclient.hpp"
|
||||
#include "shelltool.hpp"
|
||||
#include "webfetchtool.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
@@ -19,12 +20,14 @@ Chat::Chat(QObject* parent)
|
||||
m_store->setLlmClient(m_client);
|
||||
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
|
||||
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
|
||||
m_client->tools()->registerTool(new ShellTool(m_client, m_client->tools()));
|
||||
|
||||
const auto* llm = config::Config::instance()->llm();
|
||||
m_client->setEndpoint(llm->endpoint());
|
||||
m_client->setModel(llm->model());
|
||||
m_client->setTemperature(llm->temperature());
|
||||
m_client->setToolsEnabled(llm->tools());
|
||||
m_client->setSandboxEnabled(llm->sandbox());
|
||||
|
||||
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
|
||||
m_client->setEndpoint(llm->endpoint());
|
||||
@@ -38,11 +41,16 @@ Chat::Chat(QObject* parent)
|
||||
connect(llm, &config::Llm::toolsChanged, this, [this, llm]() {
|
||||
m_client->setToolsEnabled(llm->tools());
|
||||
});
|
||||
connect(llm, &config::Llm::sandboxChanged, this, [this, llm]() {
|
||||
m_client->setSandboxEnabled(llm->sandbox());
|
||||
Q_EMIT sandboxEnabledChanged();
|
||||
});
|
||||
|
||||
connect(m_client, &LlmClient::busyChanged, this, [this]() {
|
||||
if (m_client->busy() && !m_lastError.isEmpty()) {
|
||||
m_lastError.clear();
|
||||
Q_EMIT lastErrorChanged();
|
||||
if (m_client->busy() && (m_hasError || !m_errorMessage.isEmpty())) {
|
||||
m_hasError = false;
|
||||
m_errorMessage.clear();
|
||||
Q_EMIT errorChanged();
|
||||
}
|
||||
Q_EMIT busyChanged();
|
||||
});
|
||||
@@ -73,10 +81,16 @@ Chat::Chat(QObject* parent)
|
||||
&LlmClient::errorOccurred,
|
||||
this,
|
||||
[this](const QString& message) {
|
||||
m_lastError = message;
|
||||
Q_EMIT lastErrorChanged();
|
||||
m_hasError = !message.isEmpty();
|
||||
m_errorMessage = message;
|
||||
Q_EMIT errorChanged();
|
||||
Q_EMIT errorOccurred(message);
|
||||
});
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::serverErrorChanged,
|
||||
this,
|
||||
&Chat::serverErrorChanged);
|
||||
connect(
|
||||
m_store,
|
||||
&ChatStore::sessionRemoved,
|
||||
@@ -134,11 +148,24 @@ void Chat::setToolsEnabled(bool value) {
|
||||
config->llm()->set_tools(value);
|
||||
}
|
||||
|
||||
bool Chat::sandboxEnabled() const {
|
||||
return m_client->sandboxEnabled();
|
||||
}
|
||||
|
||||
void Chat::setSandboxEnabled(bool value) {
|
||||
m_client->setSandboxEnabled(value);
|
||||
if (auto* config = config::Config::instance())
|
||||
config->llm()->set_sandbox(value);
|
||||
}
|
||||
|
||||
QString Chat::serverError() const {
|
||||
return m_client->serverError();
|
||||
}
|
||||
|
||||
QString Chat::streamingChatId() const {
|
||||
return m_client->streamingChatId();
|
||||
}
|
||||
|
||||
|
||||
Chat* Chat::s_instance = nullptr;
|
||||
|
||||
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
|
||||
@@ -151,9 +178,10 @@ void Chat::stop() {
|
||||
}
|
||||
|
||||
void Chat::dismissError() {
|
||||
if (m_lastError.isEmpty()) return;
|
||||
m_lastError.clear();
|
||||
Q_EMIT lastErrorChanged();
|
||||
if (!m_hasError && m_errorMessage.isEmpty()) return;
|
||||
m_hasError = false;
|
||||
m_errorMessage.clear();
|
||||
Q_EMIT errorChanged();
|
||||
}
|
||||
|
||||
void Chat::refreshModels() {
|
||||
@@ -161,7 +189,7 @@ void Chat::refreshModels() {
|
||||
}
|
||||
|
||||
void Chat::refreshFromServer() {
|
||||
m_client->refreshFromServer();
|
||||
m_client->refreshAllStates();
|
||||
}
|
||||
|
||||
void Chat::selectModel(const QString& id) {
|
||||
|
||||
@@ -29,7 +29,12 @@ class Chat : public QObject {
|
||||
Q_PROPERTY(
|
||||
bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY
|
||||
toolsEnabledChanged)
|
||||
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
|
||||
Q_PROPERTY(
|
||||
bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY
|
||||
sandboxEnabledChanged)
|
||||
Q_PROPERTY(bool error READ error NOTIFY errorChanged)
|
||||
Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged)
|
||||
Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged)
|
||||
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
|
||||
Q_PROPERTY(
|
||||
QString streamingChatId READ streamingChatId NOTIFY
|
||||
@@ -45,7 +50,11 @@ class Chat : public QObject {
|
||||
[[nodiscard]] int contextSize() const;
|
||||
[[nodiscard]] bool toolsEnabled() const;
|
||||
void setToolsEnabled(bool value);
|
||||
[[nodiscard]] QString lastError() const { return m_lastError; }
|
||||
[[nodiscard]] bool sandboxEnabled() const;
|
||||
void setSandboxEnabled(bool value);
|
||||
[[nodiscard]] bool error() const { return m_hasError; }
|
||||
[[nodiscard]] QString errorMessage() const { return m_errorMessage; }
|
||||
[[nodiscard]] QString serverError() const;
|
||||
[[nodiscard]] ChatStore* chats() const { return m_store; }
|
||||
[[nodiscard]] QString streamingChatId() const;
|
||||
|
||||
@@ -64,14 +73,17 @@ class Chat : public QObject {
|
||||
void availableModelsChanged();
|
||||
void contextSizeChanged();
|
||||
void toolsEnabledChanged();
|
||||
void sandboxEnabledChanged();
|
||||
void errorOccurred(const QString& message);
|
||||
void lastErrorChanged();
|
||||
void errorChanged();
|
||||
void serverErrorChanged();
|
||||
void streamingChatIdChanged();
|
||||
|
||||
private:
|
||||
ChatStore* m_store = nullptr;
|
||||
LlmClient* m_client = nullptr;
|
||||
QString m_lastError;
|
||||
bool m_hasError = false;
|
||||
QString m_errorMessage;
|
||||
|
||||
static Chat* s_instance;
|
||||
};
|
||||
|
||||
@@ -65,10 +65,19 @@ struct GenerationRow {
|
||||
QVector<SegmentRow> segments;
|
||||
};
|
||||
|
||||
struct AttachmentRow {
|
||||
QString name;
|
||||
QString path;
|
||||
int kind = 1;
|
||||
QString mime;
|
||||
qint64 size = 0;
|
||||
};
|
||||
|
||||
struct MessageRow {
|
||||
bool user = false;
|
||||
qint64 timestamp = 0;
|
||||
QVector<GenerationRow> generations;
|
||||
QVector<AttachmentRow> attachments;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
@@ -160,6 +169,20 @@ void ChatStore::openDb() {
|
||||
" timestamp INTEGER NOT NULL\n"
|
||||
")"));
|
||||
}
|
||||
{
|
||||
QSqlQuery query(db);
|
||||
query.exec(QStringLiteral(
|
||||
"CREATE TABLE IF NOT EXISTS attachments (\n"
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
|
||||
" message_id INTEGER NOT NULL REFERENCES messages "
|
||||
"(id) ON DELETE CASCADE,\n"
|
||||
" name TEXT NOT NULL,\n"
|
||||
" path TEXT NOT NULL,\n"
|
||||
" kind INTEGER NOT NULL DEFAULT 1,\n"
|
||||
" mime TEXT NOT NULL DEFAULT '',\n"
|
||||
" size INTEGER NOT NULL DEFAULT 0\n"
|
||||
")"));
|
||||
}
|
||||
{
|
||||
QSqlQuery query(db);
|
||||
query.exec(QStringLiteral(
|
||||
@@ -171,6 +194,9 @@ void ChatStore::openDb() {
|
||||
query.exec(QStringLiteral(
|
||||
"CREATE INDEX IF NOT EXISTS idx_segments_generation "
|
||||
"ON segments (generation_id)"));
|
||||
query.exec(QStringLiteral(
|
||||
"CREATE INDEX IF NOT EXISTS idx_attachments_message "
|
||||
"ON attachments (message_id)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +259,7 @@ void ChatStore::removeSession(ChatSession* session) {
|
||||
query.bindValue(":id", session->id());
|
||||
query.exec();
|
||||
}
|
||||
QDir(ChatSession::sandboxDirFor(session->id())).removeRecursively();
|
||||
m_sessions.removeOne(session);
|
||||
session->deleteLater();
|
||||
notify(before);
|
||||
@@ -330,6 +357,11 @@ bool ChatStore::saveSession(ChatSession* session) {
|
||||
"timestamp) VALUES (:gid, :type, :text, :name, "
|
||||
":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
|
||||
":timestamp)");
|
||||
QSqlQuery attachmentInsert(handle);
|
||||
ok = ok && attachmentInsert.prepare(
|
||||
"INSERT INTO attachments (message_id, name, path, kind, "
|
||||
"mime, size) VALUES (:mid, :name, :path, :kind, :mime, "
|
||||
":size)");
|
||||
const auto* model = session->messagesModel();
|
||||
for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
|
||||
const auto* message = model->at(row);
|
||||
@@ -390,6 +422,26 @@ bool ChatStore::saveSession(ChatSession* session) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ok)
|
||||
for (const auto* attachment : message->attachments()) {
|
||||
attachmentInsert.bindValue(":mid", messageId);
|
||||
attachmentInsert.bindValue(
|
||||
":name", sqlText(attachment->name()));
|
||||
attachmentInsert.bindValue(
|
||||
":path", sqlText(attachment->path()));
|
||||
attachmentInsert.bindValue(
|
||||
":kind", static_cast<int>(attachment->kind()));
|
||||
attachmentInsert.bindValue(
|
||||
":mime", sqlText(attachment->mimeType()));
|
||||
attachmentInsert.bindValue(":size", attachment->size());
|
||||
if (!attachmentInsert.exec()) {
|
||||
ok = false;
|
||||
qWarning() << "ChatStore: saveSession" << id
|
||||
<< "attachment insert failed:"
|
||||
<< attachmentInsert.lastError().text();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ok || !handle.commit()) {
|
||||
@@ -497,6 +549,34 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
|
||||
<< messageId << ":"
|
||||
<< generationQuery.lastError().text();
|
||||
}
|
||||
QSqlQuery attachmentQuery(db);
|
||||
attachmentQuery.prepare(
|
||||
"SELECT name, path, kind, mime, size FROM "
|
||||
"attachments WHERE message_id = :mid "
|
||||
"ORDER BY rowid");
|
||||
attachmentQuery.bindValue(":mid", messageId);
|
||||
if (attachmentQuery.exec()) {
|
||||
while (attachmentQuery.next()) {
|
||||
AttachmentRow attachment;
|
||||
attachment.name =
|
||||
attachmentQuery.value(0).toString();
|
||||
attachment.path =
|
||||
attachmentQuery.value(1).toString();
|
||||
attachment.kind =
|
||||
attachmentQuery.value(2).toInt();
|
||||
attachment.mime =
|
||||
attachmentQuery.value(3).toString();
|
||||
attachment.size =
|
||||
attachmentQuery.value(4).toLongLong();
|
||||
message.attachments.append(attachment);
|
||||
}
|
||||
} else {
|
||||
qWarning()
|
||||
<< "ChatStore: failed to load attachments "
|
||||
"for message"
|
||||
<< messageId << ":"
|
||||
<< attachmentQuery.lastError().text();
|
||||
}
|
||||
rows.append(message);
|
||||
}
|
||||
}
|
||||
@@ -523,6 +603,22 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
|
||||
row.user ? ChatMessage::Role::User
|
||||
: ChatMessage::Role::Assistant,
|
||||
row.timestamp);
|
||||
if (!row.attachments.isEmpty()) {
|
||||
QList<Attachment*> attachments;
|
||||
for (const AttachmentRow& attachmentRow :
|
||||
row.attachments) {
|
||||
attachments.append(new Attachment(
|
||||
attachmentRow.name,
|
||||
attachmentRow.path,
|
||||
attachmentRow.kind == 0
|
||||
? Attachment::Kind::Image
|
||||
: Attachment::Kind::File,
|
||||
attachmentRow.mime,
|
||||
attachmentRow.size,
|
||||
message));
|
||||
}
|
||||
message->setAttachments(attachments);
|
||||
}
|
||||
int activeIndex = 0;
|
||||
for (int i = 0; i < row.generations.size(); ++i) {
|
||||
const GenerationRow& generationRow =
|
||||
|
||||
@@ -198,7 +198,7 @@ const char* CodeHighlighter::roleName(uint8_t role) {
|
||||
QString CodeHighlighter::resolveId(const QString& language) {
|
||||
const QString tag = language.trimmed().toLower();
|
||||
const QString alias = aliases().value(tag);
|
||||
return alias.isEmpty() ? tag : alias; // unknown tags = grammar id
|
||||
return alias.isEmpty() ? tag : alias;
|
||||
}
|
||||
|
||||
QString CodeHighlighter::cacheKey(const QString& id, const QString& code) {
|
||||
@@ -223,8 +223,8 @@ void CodeHighlighter::storeSpans(
|
||||
const QString& language,
|
||||
const QVariantList& spans) const {
|
||||
if (spans.isEmpty() || code.isEmpty()) return;
|
||||
static constexpr int kMaxEntries = 32;
|
||||
static constexpr int kMaxBytes = 1024 * 1024;
|
||||
static constexpr int kMaxEntries = 64;
|
||||
static constexpr int kMaxBytes = 2 * 1024 * 1024;
|
||||
const QString key = cacheKey(resolveId(language), code);
|
||||
const int bytes = static_cast<int>(code.toUtf8().size());
|
||||
QMutexLocker locker(&m_cacheMutex);
|
||||
@@ -369,7 +369,7 @@ QVariantList CodeHighlighter::doHighlight(
|
||||
if (c < 0x80)
|
||||
cu[b + 1] += 1;
|
||||
else if (c < 0xC0)
|
||||
; // continuation byte
|
||||
;
|
||||
else if (c < 0xF0)
|
||||
cu[b + 1] += 1;
|
||||
else
|
||||
|
||||
@@ -176,6 +176,21 @@ void ChatGeneration::appendReasoning(const QString& piece) {
|
||||
openReasoningSegment()->appendText(piece);
|
||||
}
|
||||
|
||||
void ChatGeneration::setError(const QString& value) {
|
||||
const bool hasError = !value.isEmpty();
|
||||
if (m_hasError == hasError && m_errorMessage == value) return;
|
||||
m_hasError = hasError;
|
||||
m_errorMessage = value;
|
||||
Q_EMIT errorChanged();
|
||||
}
|
||||
|
||||
void ChatGeneration::dismissError() {
|
||||
if (!m_hasError && m_errorMessage.isEmpty()) return;
|
||||
m_hasError = false;
|
||||
m_errorMessage.clear();
|
||||
Q_EMIT errorChanged();
|
||||
}
|
||||
|
||||
void ChatGeneration::setStreaming(bool value) {
|
||||
if (m_streaming == value) return;
|
||||
m_streaming = value;
|
||||
|
||||
@@ -21,6 +21,8 @@ class ChatGeneration : public QObject {
|
||||
Q_PROPERTY(
|
||||
QString content READ content WRITE setContent NOTIFY contentChanged)
|
||||
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged)
|
||||
Q_PROPERTY(bool error READ error NOTIFY errorChanged)
|
||||
Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged)
|
||||
Q_PROPERTY(
|
||||
qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
|
||||
elapsedMsChanged)
|
||||
@@ -48,6 +50,8 @@ class ChatGeneration : public QObject {
|
||||
|
||||
[[nodiscard]] QString content() const;
|
||||
[[nodiscard]] QString reasoning() const;
|
||||
[[nodiscard]] bool error() const { return m_hasError; }
|
||||
[[nodiscard]] QString errorMessage() const { return m_errorMessage; }
|
||||
[[nodiscard]] bool reasoningActive() const;
|
||||
[[nodiscard]] qint64 reasoningElapsedMs() const;
|
||||
[[nodiscard]] qint64 contentElapsedMs() const;
|
||||
@@ -65,11 +69,13 @@ class ChatGeneration : public QObject {
|
||||
|
||||
Q_INVOKABLE void approveTools();
|
||||
Q_INVOKABLE void denyTools();
|
||||
Q_INVOKABLE void dismissError();
|
||||
|
||||
void setContent(const QString& value);
|
||||
void appendContent(const QString& piece);
|
||||
void appendReasoning(const QString& piece);
|
||||
void setStreaming(bool value);
|
||||
void setError(const QString& value);
|
||||
|
||||
[[nodiscard]] LlmSegment* openContentSegment();
|
||||
[[nodiscard]] LlmSegment* openReasoningSegment();
|
||||
@@ -81,6 +87,7 @@ class ChatGeneration : public QObject {
|
||||
Q_SIGNALS:
|
||||
void contentChanged();
|
||||
void reasoningChanged();
|
||||
void errorChanged();
|
||||
void reasoningActiveChanged();
|
||||
void elapsedMsChanged();
|
||||
void streamingChanged();
|
||||
@@ -98,6 +105,8 @@ class ChatGeneration : public QObject {
|
||||
bool m_streaming = false;
|
||||
bool m_approvalPending = false;
|
||||
qint64 m_timestamp;
|
||||
bool m_hasError = false;
|
||||
QString m_errorMessage;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "llmclient.hpp"
|
||||
|
||||
#include "attachment.hpp"
|
||||
#include "generation.hpp"
|
||||
#include "message.hpp"
|
||||
#include "messagemodel.hpp"
|
||||
@@ -7,6 +8,7 @@
|
||||
#include "session.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
@@ -132,26 +134,30 @@ QString LlmClient::completionsPath(
|
||||
return base + subpath;
|
||||
}
|
||||
|
||||
QString LlmClient::jsonErrorMessage(const QByteArray& body) {
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(body);
|
||||
if (!doc.isObject()) return QString();
|
||||
const QJsonValue errorValue = doc.object().value("error");
|
||||
if (errorValue.isObject())
|
||||
return errorValue.toObject().value("message").toString();
|
||||
if (errorValue.isString()) return errorValue.toString();
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString LlmClient::serverErrorMessage(
|
||||
const QByteArray& body, const QString& fallback) {
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(body);
|
||||
if (doc.isObject()) {
|
||||
const QJsonObject obj = doc.object();
|
||||
if (obj.contains("error")) {
|
||||
const QJsonValue errorValue = obj["error"];
|
||||
if (errorValue.isObject()) {
|
||||
const QString message =
|
||||
errorValue.toObject()["message"].toString();
|
||||
if (!message.isEmpty()) return message;
|
||||
} else if (!errorValue.toString().isEmpty()) {
|
||||
return errorValue.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
const QString message = jsonErrorMessage(body);
|
||||
if (!message.isEmpty()) return message;
|
||||
return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed")
|
||||
: fallback;
|
||||
}
|
||||
|
||||
void LlmClient::setServerError(const QString& value) {
|
||||
if (m_serverError == value) return;
|
||||
m_serverError = value;
|
||||
Q_EMIT serverErrorChanged();
|
||||
}
|
||||
|
||||
void LlmClient::setBusy(bool value) {
|
||||
if (m_busy == value) return;
|
||||
m_busy = value;
|
||||
@@ -175,14 +181,17 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) {
|
||||
m_refreshTimer.setParent(this);
|
||||
m_refreshTimer.setInterval(kRefreshIntervalMs);
|
||||
connect(
|
||||
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer);
|
||||
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshAllStates);
|
||||
m_refreshTimer.start();
|
||||
}
|
||||
|
||||
LlmClient::~LlmClient() {
|
||||
if (m_reply) m_reply->abort();
|
||||
if (m_reply) {
|
||||
QNetworkReply* reply = m_reply;
|
||||
m_reply = nullptr;
|
||||
reply->abort();
|
||||
}
|
||||
m_tools->cancelAll();
|
||||
endStream();
|
||||
}
|
||||
|
||||
void LlmClient::setEndpoint(const QString& value) {
|
||||
@@ -190,7 +199,7 @@ void LlmClient::setEndpoint(const QString& value) {
|
||||
m_endpoint = value;
|
||||
Q_EMIT endpointChanged();
|
||||
m_refreshTimer.start();
|
||||
refreshFromServer();
|
||||
refreshAllStates();
|
||||
}
|
||||
|
||||
void LlmClient::setModel(const QString& value) {
|
||||
@@ -204,9 +213,16 @@ void LlmClient::setTemperature(double value) {
|
||||
m_temperature = value;
|
||||
}
|
||||
|
||||
void LlmClient::setSandboxEnabled(bool value) {
|
||||
m_sandboxEnabled = value;
|
||||
}
|
||||
|
||||
QString LlmClient::activeWorkspace() const {
|
||||
return m_active ? m_active->workspacePath() : QString();
|
||||
}
|
||||
|
||||
void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
|
||||
if (m_busy || !session || !target) return;
|
||||
refreshModels();
|
||||
m_contextRetryUsed = false;
|
||||
m_active = session;
|
||||
m_streaming = target;
|
||||
@@ -242,6 +258,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
|
||||
|
||||
probeContextSize([this, session, target]() {
|
||||
if (m_active != session || m_streaming != target) return;
|
||||
refreshModels();
|
||||
sendRound();
|
||||
});
|
||||
}
|
||||
@@ -253,6 +270,7 @@ void LlmClient::sendRound() {
|
||||
m_callBuilders.clear();
|
||||
m_callResults.clear();
|
||||
m_roundDone = false;
|
||||
m_streamDone = false;
|
||||
m_toolPhase = false;
|
||||
m_pendingCalls = 0;
|
||||
m_buffer.clear();
|
||||
@@ -308,6 +326,8 @@ void LlmClient::sendRound() {
|
||||
}
|
||||
}
|
||||
|
||||
appendSandboxPreamble(messages);
|
||||
|
||||
QJsonObject body;
|
||||
QJsonObject streamOptions;
|
||||
streamOptions[QStringLiteral("include_usage")] = true;
|
||||
@@ -323,15 +343,24 @@ void LlmClient::sendRound() {
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
request.setRawHeader("Accept", "text/event-stream");
|
||||
|
||||
m_reply = m_manager.post(request, QJsonDocument(body).toJson());
|
||||
QNetworkReply* reply =
|
||||
m_manager.post(request, QJsonDocument(body).toJson());
|
||||
if (!reply) {
|
||||
fail(QStringLiteral("Failed to start request to LLM server"));
|
||||
return;
|
||||
}
|
||||
m_reply = reply;
|
||||
|
||||
connect(m_reply, &QNetworkReply::readyRead, this, [this]() {
|
||||
if (m_reply) m_buffer.append(m_reply->readAll());
|
||||
connect(reply, &QNetworkReply::readyRead, this, [this, reply]() {
|
||||
if (m_reply != reply) return;
|
||||
m_buffer.append(reply->readAll());
|
||||
drainBuffer();
|
||||
});
|
||||
connect(m_reply, &QNetworkReply::finished, this, [this]() {
|
||||
QNetworkReply* reply = m_reply;
|
||||
if (!reply) return;
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||
if (m_reply != reply) {
|
||||
reply->deleteLater();
|
||||
return;
|
||||
}
|
||||
m_reply = nullptr;
|
||||
|
||||
const QNetworkReply::NetworkError error = reply->error();
|
||||
@@ -343,25 +372,37 @@ void LlmClient::sendRound() {
|
||||
drainBuffer();
|
||||
if (!m_streaming) return;
|
||||
|
||||
if (error == QNetworkReply::NoError)
|
||||
roundFinished();
|
||||
else if (error == QNetworkReply::OperationCanceledError)
|
||||
finishTurn();
|
||||
else {
|
||||
const QString message =
|
||||
serverErrorMessage(responseBody, errorString);
|
||||
const int reported = contextSizeFromErrorMessage(message);
|
||||
if (reported > 0 && reported != m_contextSize &&
|
||||
!m_contextRetryUsed) {
|
||||
qWarning() << "LlmClient: prompt overflow, server reports"
|
||||
<< reported << "tokens; retrying round";
|
||||
setContextSize(reported);
|
||||
m_contextRetryUsed = true;
|
||||
sendRound();
|
||||
if (error == QNetworkReply::NoError) {
|
||||
if (m_streamDone) {
|
||||
roundFinished();
|
||||
return;
|
||||
}
|
||||
fail(message);
|
||||
const QByteArray tail = m_buffer;
|
||||
m_buffer.clear();
|
||||
const QString serverMessage = jsonErrorMessage(tail.simplified());
|
||||
fail(
|
||||
serverMessage.isEmpty()
|
||||
? QStringLiteral(
|
||||
"LLM server closed the stream before "
|
||||
"completing the response")
|
||||
: serverMessage);
|
||||
return;
|
||||
}
|
||||
if (error == QNetworkReply::OperationCanceledError) {
|
||||
finishTurn();
|
||||
return;
|
||||
}
|
||||
const QString message = serverErrorMessage(responseBody, errorString);
|
||||
const int reported = contextSizeFromErrorMessage(message);
|
||||
if (reported > 0 && reported != m_contextSize && !m_contextRetryUsed) {
|
||||
qWarning() << "LlmClient: prompt overflow, server reports"
|
||||
<< reported << "tokens; retrying round";
|
||||
setContextSize(reported);
|
||||
m_contextRetryUsed = true;
|
||||
sendRound();
|
||||
return;
|
||||
}
|
||||
fail(message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -373,12 +414,51 @@ QJsonArray LlmClient::buildContextMessages(
|
||||
const auto* message = model->at(row);
|
||||
const auto* generation = message->activeGeneration();
|
||||
if (!generation) continue;
|
||||
const bool hasAnyContent = !generation->content().isEmpty() ||
|
||||
!generation->reasoning().isEmpty() ||
|
||||
generation->toolCallCount() > 0;
|
||||
if (message->role() != ChatMessage::Role::User && !hasAnyContent)
|
||||
continue;
|
||||
|
||||
ContextUnit unit;
|
||||
if (message->role() == ChatMessage::Role::User) {
|
||||
QJsonObject user;
|
||||
user[QStringLiteral("role")] = QStringLiteral("user");
|
||||
user[QStringLiteral("content")] = generation->content();
|
||||
const QString text = generation->content();
|
||||
const QList<Attachment*> attachments = message->attachments();
|
||||
if (attachments.isEmpty()) {
|
||||
user[QStringLiteral("content")] = text;
|
||||
} else {
|
||||
QJsonArray parts;
|
||||
if (!text.isEmpty()) {
|
||||
QJsonObject textPart;
|
||||
textPart[QStringLiteral("type")] = QStringLiteral("text");
|
||||
textPart[QStringLiteral("text")] = text;
|
||||
parts.append(textPart);
|
||||
}
|
||||
for (const auto* attachment : attachments) {
|
||||
if (attachment->kind() != Attachment::Kind::Image) continue;
|
||||
QFile file(attachment->path());
|
||||
if (!file.open(QIODevice::ReadOnly)) continue;
|
||||
const QByteArray base64 = file.readAll().toBase64();
|
||||
file.close();
|
||||
if (base64.isEmpty()) continue;
|
||||
QJsonObject imageUrl;
|
||||
imageUrl[QStringLiteral("url")] =
|
||||
QStringLiteral("data:%1;base64,%2")
|
||||
.arg(attachment->mimeType())
|
||||
.arg(QString::fromLatin1(base64));
|
||||
QJsonObject imagePart;
|
||||
imagePart[QStringLiteral("type")] =
|
||||
QLatin1String("image_url");
|
||||
imagePart[QStringLiteral("image_url")] = imageUrl;
|
||||
parts.append(imagePart);
|
||||
}
|
||||
if (parts.isEmpty())
|
||||
user[QStringLiteral("content")] = text;
|
||||
else
|
||||
user[QStringLiteral("content")] = parts;
|
||||
}
|
||||
unit.messages.append(user);
|
||||
} else {
|
||||
QList<const LlmSegment*> toolSegments;
|
||||
@@ -455,6 +535,48 @@ QJsonArray LlmClient::buildContextMessages(
|
||||
return messages;
|
||||
}
|
||||
|
||||
void LlmClient::appendSandboxPreamble(QJsonArray& messages) {
|
||||
if (!m_sandboxEnabled) return;
|
||||
ChatSession* active = m_active;
|
||||
if (!active) return;
|
||||
const QString workspace = active->workspacePath();
|
||||
if (workspace.isEmpty()) return;
|
||||
const auto* model = active->messagesModel();
|
||||
QStringList files;
|
||||
QSet<QString> seenPaths;
|
||||
for (int row = 0; row < model->rowCount(); ++row) {
|
||||
const auto* message = model->at(row);
|
||||
if (message->role() != ChatMessage::Role::User) continue;
|
||||
for (const auto* attachment : message->attachments()) {
|
||||
if (attachment->kind() != Attachment::Kind::File) continue;
|
||||
const QString relativePath =
|
||||
attachment->path().mid(workspace.size() + 1);
|
||||
if (seenPaths.contains(relativePath)) continue;
|
||||
seenPaths.insert(relativePath);
|
||||
files.append(QStringLiteral("- %1 (available as %2)")
|
||||
.arg(attachment->name(), relativePath));
|
||||
}
|
||||
}
|
||||
if (files.isEmpty()) return;
|
||||
QJsonObject preamble;
|
||||
preamble[QStringLiteral("role")] = QStringLiteral("system");
|
||||
preamble[QStringLiteral("content")] =
|
||||
QStringLiteral(
|
||||
"The user attached the following files to this "
|
||||
"conversation. They are available in your sandbox "
|
||||
"directory at %1:\n%2\nruncommand executes shell "
|
||||
"commands inside a bubblewrap sandbox: the rest of the "
|
||||
"filesystem is read-only, there is no network access, "
|
||||
"and only the sandbox directory is writable. Use it to "
|
||||
"inspect, transform, or extract attached files (for "
|
||||
"example to extract archives or search across many "
|
||||
"files). Files you create stay inside the sandbox "
|
||||
"directory. Use the webfetch tool for anything that "
|
||||
"needs the network.")
|
||||
.arg(workspace, files.join(QStringLiteral("\n")));
|
||||
messages.prepend(preamble);
|
||||
}
|
||||
|
||||
void LlmClient::applyToolCallDelta(const QJsonObject& call) {
|
||||
ChatGeneration* streaming = m_streaming;
|
||||
if (!streaming) return;
|
||||
@@ -688,10 +810,12 @@ void LlmClient::stop() {
|
||||
finishTurn();
|
||||
return;
|
||||
}
|
||||
if (m_reply)
|
||||
m_reply->abort();
|
||||
else
|
||||
endStream();
|
||||
if (m_reply) {
|
||||
QNetworkReply* reply = m_reply;
|
||||
m_reply = nullptr;
|
||||
reply->abort();
|
||||
}
|
||||
endStream();
|
||||
}
|
||||
|
||||
void LlmClient::endStream() {
|
||||
@@ -700,6 +824,11 @@ void LlmClient::endStream() {
|
||||
ChatSession* session = m_active;
|
||||
m_streaming = nullptr;
|
||||
m_active = nullptr;
|
||||
if (m_reply) {
|
||||
QNetworkReply* reply = m_reply;
|
||||
m_reply = nullptr;
|
||||
reply->abort();
|
||||
}
|
||||
generation->setStreaming(false);
|
||||
m_approvalPending = false;
|
||||
generation->setApprovalPending(false);
|
||||
@@ -708,7 +837,7 @@ void LlmClient::endStream() {
|
||||
segment->status() == LlmSegment::Status::Pending)
|
||||
segment->finishTool(QStringLiteral("Cancelled"), false);
|
||||
if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
|
||||
generation->toolCallCount() == 0) {
|
||||
generation->toolCallCount() == 0 && !generation->error()) {
|
||||
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
|
||||
if (message->generationCount() <= 1) {
|
||||
if (session) session->removeMessage(message);
|
||||
@@ -730,7 +859,7 @@ void LlmClient::finishTurn() {
|
||||
m_pendingClear.clear();
|
||||
}
|
||||
if (session) session->persist();
|
||||
refreshFromServer();
|
||||
refreshAllStates();
|
||||
}
|
||||
|
||||
void LlmClient::clearOnFinish(ChatSession* session) {
|
||||
@@ -747,6 +876,7 @@ void LlmClient::sessionRemoved(ChatSession* session) {
|
||||
|
||||
void LlmClient::fail(const QString& message) {
|
||||
qWarning() << "LlmClient:" << message;
|
||||
if (ChatGeneration* streaming = m_streaming) streaming->setError(message);
|
||||
finishTurn();
|
||||
Q_EMIT errorOccurred(message);
|
||||
}
|
||||
@@ -766,6 +896,7 @@ void LlmClient::handleLine(const QByteArray& line) {
|
||||
|
||||
const QByteArray data = line.mid(5).trimmed();
|
||||
if (data == "[DONE]") {
|
||||
m_streamDone = true;
|
||||
roundFinished();
|
||||
return;
|
||||
}
|
||||
@@ -776,8 +907,12 @@ void LlmClient::handleLine(const QByteArray& line) {
|
||||
updateTokenUsage(obj);
|
||||
|
||||
if (obj.contains("error")) {
|
||||
const QJsonObject error = obj["error"].toObject();
|
||||
const QString message = error["message"].toString();
|
||||
const QJsonValue errorValue = obj["error"];
|
||||
QString message;
|
||||
if (errorValue.isObject())
|
||||
message = errorValue.toObject()["message"].toString();
|
||||
else if (errorValue.isString())
|
||||
message = errorValue.toString();
|
||||
fail(
|
||||
message.isEmpty() ? QStringLiteral("LLM server returned an error")
|
||||
: message);
|
||||
@@ -805,25 +940,36 @@ void LlmClient::handleLine(const QByteArray& line) {
|
||||
}
|
||||
}
|
||||
|
||||
void LlmClient::refreshFromServer() {
|
||||
if (m_endpoint.trimmed().isEmpty()) return;
|
||||
probeContextSize();
|
||||
refreshModels();
|
||||
void LlmClient::refreshAllStates() {
|
||||
if (m_endpoint.trimmed().isEmpty()) {
|
||||
setServerError(QStringLiteral("LLM endpoint is not configured"));
|
||||
return;
|
||||
}
|
||||
probeContextSize([this]() { refreshModels(); });
|
||||
}
|
||||
|
||||
void LlmClient::refreshModels() {
|
||||
const QUrl url =
|
||||
QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
|
||||
if (!url.isValid() || url.host().isEmpty()) return;
|
||||
if (!url.isValid() || url.host().isEmpty()) {
|
||||
setServerError(QStringLiteral("LLM endpoint is not configured"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto* reply = m_manager.get(QNetworkRequest(url));
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||
const QNetworkReply::NetworkError error = reply->error();
|
||||
const QString errorString = reply->errorString();
|
||||
const QByteArray data = reply->readAll();
|
||||
reply->deleteLater();
|
||||
|
||||
if (error != QNetworkReply::NoError) {
|
||||
qWarning() << "LlmClient: failed to fetch models:" << error;
|
||||
const QString serverMessage = jsonErrorMessage(data);
|
||||
setServerError(
|
||||
serverMessage.isEmpty()
|
||||
? QStringLiteral("Cannot reach LLM server: %1")
|
||||
.arg(errorString)
|
||||
: serverMessage);
|
||||
return;
|
||||
}
|
||||
const QJsonArray dataArr =
|
||||
@@ -838,7 +984,12 @@ void LlmClient::refreshModels() {
|
||||
models.append(id);
|
||||
}
|
||||
}
|
||||
if (models.isEmpty()) return;
|
||||
if (models.isEmpty()) {
|
||||
setServerError(QStringLiteral(
|
||||
"LLM server is running but has no models loaded"));
|
||||
return;
|
||||
}
|
||||
setServerError(QString());
|
||||
|
||||
m_availableModels = models;
|
||||
Q_EMIT availableModelsChanged();
|
||||
@@ -866,6 +1017,8 @@ void LlmClient::probeContextSize(std::function<void()> done) {
|
||||
base.chop(1);
|
||||
const QUrl url = QUrl::fromUserInput(base + "/props");
|
||||
if (!url.isValid() || url.host().isEmpty()) {
|
||||
setServerError(
|
||||
QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint));
|
||||
if (done) done();
|
||||
return;
|
||||
}
|
||||
@@ -877,11 +1030,13 @@ void LlmClient::probeContextSize(std::function<void()> done) {
|
||||
*settled = true;
|
||||
|
||||
const QNetworkReply::NetworkError error = reply->error();
|
||||
const QString errorString = reply->errorString();
|
||||
const QByteArray data = reply->readAll();
|
||||
reply->deleteLater();
|
||||
|
||||
int size = 0;
|
||||
if (error == QNetworkReply::NoError) {
|
||||
setServerError(QString());
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(data);
|
||||
if (doc.isArray()) {
|
||||
for (const auto& value : doc.array()) {
|
||||
@@ -899,6 +1054,13 @@ void LlmClient::probeContextSize(std::function<void()> done) {
|
||||
.toObject()["n_ctx"]
|
||||
.toInt(0);
|
||||
}
|
||||
} else {
|
||||
const QString serverMessage = jsonErrorMessage(data);
|
||||
setServerError(
|
||||
serverMessage.isEmpty()
|
||||
? QStringLiteral("Cannot reach LLM server: %1")
|
||||
.arg(errorString)
|
||||
: serverMessage);
|
||||
}
|
||||
if (size > 0)
|
||||
setContextSize(size);
|
||||
|
||||
@@ -39,16 +39,20 @@ class LlmClient : public QObject {
|
||||
}
|
||||
[[nodiscard]] int contextSize() const { return m_contextSize; }
|
||||
[[nodiscard]] ToolRegistry* tools() const { return m_tools; }
|
||||
[[nodiscard]] QString serverError() const { return m_serverError; }
|
||||
void setEndpoint(const QString& value);
|
||||
void setModel(const QString& value);
|
||||
void setTemperature(double value);
|
||||
void setContextSize(int size);
|
||||
void probeContextSize(std::function<void()> done = {});
|
||||
Q_INVOKABLE void refreshFromServer();
|
||||
void refreshAllStates();
|
||||
|
||||
[[nodiscard]] bool busy() const { return m_busy; }
|
||||
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
|
||||
void setToolsEnabled(bool value) { m_tools->setEnabled(value); }
|
||||
[[nodiscard]] bool sandboxEnabled() const { return m_sandboxEnabled; }
|
||||
void setSandboxEnabled(bool value);
|
||||
[[nodiscard]] QString activeWorkspace() const;
|
||||
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
|
||||
[[nodiscard]] ChatSession* streamingSession() const { return m_active; }
|
||||
|
||||
@@ -74,6 +78,7 @@ class LlmClient : public QObject {
|
||||
void toolsEnabledChanged();
|
||||
void streamingChatIdChanged();
|
||||
void errorOccurred(const QString& message);
|
||||
void serverErrorChanged();
|
||||
void titleSuggested(ZShell::llm::ChatSession* session, const QString& title);
|
||||
void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon);
|
||||
|
||||
@@ -89,6 +94,7 @@ class LlmClient : public QObject {
|
||||
void sendRound();
|
||||
QJsonArray buildContextMessages(
|
||||
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
|
||||
void appendSandboxPreamble(QJsonArray& messages);
|
||||
void applyToolCallDelta(const QJsonObject& call);
|
||||
void roundFinished();
|
||||
void executeAllCalls();
|
||||
@@ -102,6 +108,7 @@ class LlmClient : public QObject {
|
||||
void updateTokenUsage(const QJsonObject& data);
|
||||
void setBusy(bool value);
|
||||
void setStreamingChatId(const QString& id);
|
||||
void setServerError(const QString& value);
|
||||
void shortRequest(
|
||||
const QString& tag,
|
||||
const QString& systemPrompt,
|
||||
@@ -109,6 +116,7 @@ class LlmClient : public QObject {
|
||||
std::function<void(QString result)> onResult);
|
||||
static QString completionsPath(
|
||||
const QString& endpoint, const QString& subpath);
|
||||
static QString jsonErrorMessage(const QByteArray& body);
|
||||
static QString serverErrorMessage(
|
||||
const QByteArray& body, const QString& fallback);
|
||||
|
||||
@@ -125,6 +133,7 @@ class LlmClient : public QObject {
|
||||
QString m_endpoint;
|
||||
QString m_model;
|
||||
QStringList m_availableModels;
|
||||
QString m_serverError;
|
||||
double m_temperature = 0.7;
|
||||
int m_contextSize = 0;
|
||||
|
||||
@@ -139,7 +148,9 @@ class LlmClient : public QObject {
|
||||
int m_round = 0;
|
||||
qsizetype m_contentMark = 0;
|
||||
qsizetype m_reasoningMark = 0;
|
||||
bool m_sandboxEnabled = true;
|
||||
bool m_roundDone = false;
|
||||
bool m_streamDone = false;
|
||||
bool m_toolPhase = false;
|
||||
bool m_approvalPending = false;
|
||||
bool m_contextRetryUsed = false;
|
||||
|
||||
@@ -11,12 +11,7 @@ class LlmMarkdown : public QObject {
|
||||
QML_UNCREATABLE("Blocks are produced by LlmSegment")
|
||||
|
||||
public:
|
||||
enum class Type : int {
|
||||
Text = 0, // Paragraph, list, quote, table; "text" is markdown source
|
||||
Heading, // "level" + "text" (markdown source of the content)
|
||||
Code, // "language" + "code"
|
||||
Math // "latex" (display math, without the $$ delimiters)
|
||||
};
|
||||
enum class Type : int { Text = 0, Heading, Code, Math };
|
||||
Q_ENUM(Type)
|
||||
|
||||
explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {}
|
||||
|
||||
+182
-42
@@ -10,9 +10,14 @@
|
||||
#include <QFile>
|
||||
#include <QFontDatabase>
|
||||
#include <QHash>
|
||||
#include <QMutex>
|
||||
#include <QMutexLocker>
|
||||
#include <QPainter>
|
||||
#include <QPointer>
|
||||
#include <QThreadPool>
|
||||
|
||||
#include <QtQuick/QQuickImageProvider>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
namespace {
|
||||
@@ -71,26 +76,27 @@ const LatinModern& loadLatinModern() {
|
||||
return fonts;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
LlmMathText::LlmMathText(QObject* parent)
|
||||
: QObject(parent)
|
||||
, m_renderer(
|
||||
std::make_shared<JKQTMathText>(nullptr, /* useFontsForGUI */ true)) {
|
||||
void configureLatinModern(JKQTMathText& renderer) {
|
||||
const LatinModern& fonts = loadLatinModern();
|
||||
if (fonts.roman)
|
||||
m_renderer->setFontRomanAndMath(
|
||||
renderer.setFontRomanAndMath(
|
||||
QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
if (fonts.math) {
|
||||
m_renderer->setFontMathRoman(
|
||||
renderer.setFontMathRoman(
|
||||
QStringLiteral("Latin Modern Math"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
m_renderer->setFallbackFontSymbols(
|
||||
renderer.setFallbackFontSymbols(
|
||||
QStringLiteral("Latin Modern Math"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
LlmMathText::LlmMathText(QObject* parent) : QObject(parent) {
|
||||
loadLatinModern();
|
||||
}
|
||||
|
||||
void LlmMathText::setLatex(const QString& value) {
|
||||
if (m_latex == value) return;
|
||||
m_latex = value;
|
||||
@@ -120,23 +126,88 @@ namespace {
|
||||
struct MathRender {
|
||||
bool ok = false;
|
||||
QImage image;
|
||||
QUrl url;
|
||||
QString id;
|
||||
qreal width = 0;
|
||||
qreal height = 0;
|
||||
};
|
||||
|
||||
QHash<QString, MathRender>& mathCache() {
|
||||
static QHash<QString, MathRender> cache;
|
||||
return cache;
|
||||
struct MathStore {
|
||||
QMutex mutex;
|
||||
QHash<QString, MathRender> entries;
|
||||
QStringList order;
|
||||
QHash<QString, QString> idIndex;
|
||||
QHash<QString, int> idRefs;
|
||||
qint64 bytes = 0;
|
||||
quint64 nextId = 1;
|
||||
};
|
||||
|
||||
MathStore& mathStore() {
|
||||
static MathStore store;
|
||||
return store;
|
||||
}
|
||||
|
||||
qint64 imageBytes(const QImage& image) {
|
||||
return image.isNull() ? 0 : static_cast<qint64>(image.sizeInBytes());
|
||||
}
|
||||
|
||||
void retainMathId(const QString& id) {
|
||||
if (id.isEmpty()) return;
|
||||
auto& store = mathStore();
|
||||
QMutexLocker locker(&store.mutex);
|
||||
++store.idRefs[id];
|
||||
}
|
||||
|
||||
void releaseMathId(const QString& id) {
|
||||
if (id.isEmpty()) return;
|
||||
auto& store = mathStore();
|
||||
QMutexLocker locker(&store.mutex);
|
||||
auto it = store.idRefs.find(id);
|
||||
if (it == store.idRefs.end()) return;
|
||||
if (--it.value() <= 0) store.idRefs.erase(it);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr qint64 kMaxCacheBytes = 64 * 1024 * 1024;
|
||||
|
||||
class MathImageProvider : public QQuickImageProvider {
|
||||
public:
|
||||
MathImageProvider() : QQuickImageProvider(QQuickImageProvider::Image) {}
|
||||
|
||||
QImage requestImage(
|
||||
const QString& id, QSize* size, const QSize& requestedSize) override {
|
||||
Q_UNUSED(requestedSize)
|
||||
auto& store = mathStore();
|
||||
QMutexLocker locker(&store.mutex);
|
||||
const auto keyIt = store.idIndex.constFind(id);
|
||||
if (keyIt == store.idIndex.constEnd()) return {};
|
||||
const QString key = keyIt.value();
|
||||
const auto entryIt = store.entries.constFind(key);
|
||||
if (entryIt == store.entries.constEnd()) return {};
|
||||
if (size) *size = entryIt->image.size();
|
||||
const qsizetype pos = store.order.indexOf(key);
|
||||
if (pos >= 0) store.order.move(pos, store.order.size() - 1);
|
||||
return entryIt->image;
|
||||
}
|
||||
};
|
||||
|
||||
void ensureImageProvider(QObject* owner) {
|
||||
if (auto* engine = qmlEngine(owner))
|
||||
if (!engine->imageProvider(QStringLiteral("llmmath")))
|
||||
engine->addImageProvider(
|
||||
QStringLiteral("llmmath"), new MathImageProvider());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void LlmMathText::reRender() {
|
||||
ensureImageProvider(this);
|
||||
++m_requestId;
|
||||
if (m_latex.trimmed().isEmpty()) {
|
||||
m_image = QImage();
|
||||
m_imageUrl = QUrl();
|
||||
releaseMathId(m_imageId);
|
||||
m_imageId.clear();
|
||||
m_width = 0;
|
||||
m_height = 0;
|
||||
m_ok = false;
|
||||
@@ -147,12 +218,30 @@ void LlmMathText::reRender() {
|
||||
const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() +
|
||||
QLatin1Char(0x1f) + QString::number(m_fontPointSize) +
|
||||
QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
|
||||
if (auto it = mathCache().find(key); it != mathCache().end()) {
|
||||
m_image = it->image;
|
||||
m_imageUrl = it->url;
|
||||
m_width = it->width;
|
||||
m_height = it->height;
|
||||
m_ok = it->ok;
|
||||
bool cacheHit = false;
|
||||
QString hitId;
|
||||
qreal hitW = 0;
|
||||
qreal hitH = 0;
|
||||
bool hitOk = false;
|
||||
{
|
||||
auto& store = mathStore();
|
||||
QMutexLocker locker(&store.mutex);
|
||||
const auto it = store.entries.constFind(key);
|
||||
if (it != store.entries.constEnd()) {
|
||||
cacheHit = true;
|
||||
hitId = it->id;
|
||||
hitW = it->width;
|
||||
hitH = it->height;
|
||||
hitOk = it->ok;
|
||||
}
|
||||
}
|
||||
if (cacheHit) {
|
||||
releaseMathId(m_imageId);
|
||||
retainMathId(hitId);
|
||||
m_imageId = hitId;
|
||||
m_width = hitW;
|
||||
m_height = hitH;
|
||||
m_ok = hitOk;
|
||||
Q_EMIT changed();
|
||||
return;
|
||||
}
|
||||
@@ -164,33 +253,46 @@ void LlmMathText::reRender() {
|
||||
const QColor color = m_color;
|
||||
const double pointSize = m_fontPointSize;
|
||||
const qreal dpr = m_devicePixelRatio;
|
||||
auto renderer = m_renderer;
|
||||
QThreadPool::globalInstance()->start(
|
||||
[this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() {
|
||||
[this, id = m_requestId, key, latex, color, pointSize, dpr]() {
|
||||
JKQTMathText renderer(nullptr, true);
|
||||
configureLatinModern(renderer);
|
||||
MathRender render;
|
||||
renderer->setFontPointSize(pointSize);
|
||||
renderer->setFontColor(color);
|
||||
if (renderer->parse(
|
||||
renderer.setFontPointSize(pointSize);
|
||||
renderer.setFontColor(color);
|
||||
if (renderer.parse(
|
||||
latex,
|
||||
JKQTMathText::LatexParser,
|
||||
JKQTMathText::DefaultParseOptions)) {
|
||||
const QImage image = renderer->drawIntoImage(
|
||||
/* drawBoxes */ false,
|
||||
{
|
||||
QImage scratch(1, 1, QImage::Format_ARGB32);
|
||||
QPainter painter(&scratch);
|
||||
const QSizeF size = renderer.getSize(painter);
|
||||
render.width = size.width() + 2 * kRenderMargin;
|
||||
render.height = size.height() + 2 * kRenderMargin;
|
||||
}
|
||||
QPointer<LlmMathText> sizeGuard(this);
|
||||
const qreal sizeW = render.width;
|
||||
const qreal sizeH = render.height;
|
||||
QMetaObject::invokeMethod(
|
||||
QCoreApplication::instance(),
|
||||
[sizeGuard, id, sizeW, sizeH]() {
|
||||
LlmMathText* self = sizeGuard;
|
||||
if (!self || id != self->m_requestId) return;
|
||||
self->m_width = sizeW;
|
||||
self->m_height = sizeH;
|
||||
Q_EMIT self->changed();
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
|
||||
const QImage image = renderer.drawIntoImage(
|
||||
false,
|
||||
QColor(Qt::transparent),
|
||||
kRenderMargin,
|
||||
dpr,
|
||||
kResolutionDpi);
|
||||
if (!image.isNull()) {
|
||||
QByteArray png;
|
||||
{
|
||||
QBuffer buffer(&png);
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
image.save(&buffer, "PNG");
|
||||
}
|
||||
render.image = image;
|
||||
render.url = QUrl(
|
||||
QStringLiteral("data:image/png;base64,") +
|
||||
QString::fromLatin1(png.toBase64()));
|
||||
render.width = image.width() / dpr;
|
||||
render.height = image.height() / dpr;
|
||||
render.ok = true;
|
||||
@@ -208,12 +310,46 @@ void LlmMathText::reRender() {
|
||||
return;
|
||||
}
|
||||
if (render.ok) {
|
||||
auto& cache = mathCache();
|
||||
if (cache.size() >= kCacheLimit) cache.clear();
|
||||
cache.insert(key, render);
|
||||
auto& store = mathStore();
|
||||
QMutexLocker locker(&store.mutex);
|
||||
const bool isNew = !store.entries.contains(key);
|
||||
if (isNew)
|
||||
render.id = QString::number(store.nextId++);
|
||||
else
|
||||
render.id = store.entries[key].id;
|
||||
store.idIndex.insert(render.id, key);
|
||||
if (isNew) {
|
||||
const qint64 newBytes = imageBytes(render.image);
|
||||
while (store.order.size() >= kCacheLimit ||
|
||||
store.bytes + newBytes > kMaxCacheBytes) {
|
||||
bool evicted = false;
|
||||
for (int i = 0; i < store.order.size(); ++i) {
|
||||
const QString oldKey = store.order.at(i);
|
||||
const auto oldIt =
|
||||
store.entries.constFind(oldKey);
|
||||
if (oldIt == store.entries.constEnd()) {
|
||||
store.order.removeAt(i);
|
||||
continue;
|
||||
}
|
||||
if (store.idRefs.value(oldIt->id, 0) > 0)
|
||||
continue;
|
||||
store.order.removeAt(i);
|
||||
store.bytes -= imageBytes(oldIt->image);
|
||||
store.idIndex.remove(oldIt->id);
|
||||
store.entries.remove(oldKey);
|
||||
evicted = true;
|
||||
break;
|
||||
}
|
||||
if (!evicted) break;
|
||||
}
|
||||
store.entries.insert(key, render);
|
||||
store.order.append(key);
|
||||
store.bytes += imageBytes(render.image);
|
||||
}
|
||||
}
|
||||
self->m_image = render.image;
|
||||
self->m_imageUrl = render.url;
|
||||
releaseMathId(self->m_imageId);
|
||||
retainMathId(render.id);
|
||||
self->m_imageId = render.id;
|
||||
self->m_width = render.width;
|
||||
self->m_height = render.height;
|
||||
self->m_ok = render.ok;
|
||||
@@ -223,4 +359,8 @@ void LlmMathText::reRender() {
|
||||
});
|
||||
}
|
||||
|
||||
LlmMathText::~LlmMathText() {
|
||||
releaseMathId(m_imageId);
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
#include <QImage>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QtQml>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <jkqtmathtext/jkqtmathtext.h>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
class LlmMathText : public QObject {
|
||||
@@ -25,14 +19,14 @@ class LlmMathText : public QObject {
|
||||
Q_PROPERTY(
|
||||
qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio
|
||||
NOTIFY changed)
|
||||
Q_PROPERTY(QImage image READ image NOTIFY changed)
|
||||
Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed)
|
||||
Q_PROPERTY(QString imageId READ imageId NOTIFY changed)
|
||||
Q_PROPERTY(qreal width READ width NOTIFY changed)
|
||||
Q_PROPERTY(qreal height READ height NOTIFY changed)
|
||||
Q_PROPERTY(bool ok READ ok NOTIFY changed)
|
||||
|
||||
public:
|
||||
explicit LlmMathText(QObject* parent = nullptr);
|
||||
~LlmMathText() override;
|
||||
|
||||
[[nodiscard]] QString latex() const { return m_latex; }
|
||||
void setLatex(const QString& value);
|
||||
@@ -42,8 +36,7 @@ class LlmMathText : public QObject {
|
||||
void setFontPointSize(double value);
|
||||
[[nodiscard]] qreal devicePixelRatio() const { return m_devicePixelRatio; }
|
||||
void setDevicePixelRatio(qreal value);
|
||||
[[nodiscard]] QImage image() const { return m_image; }
|
||||
[[nodiscard]] QUrl imageUrl() const { return m_imageUrl; }
|
||||
[[nodiscard]] QString imageId() const { return m_imageId; }
|
||||
[[nodiscard]] qreal width() const { return m_width; }
|
||||
[[nodiscard]] qreal height() const { return m_height; }
|
||||
[[nodiscard]] bool ok() const { return m_ok; }
|
||||
@@ -54,13 +47,11 @@ class LlmMathText : public QObject {
|
||||
private:
|
||||
void reRender();
|
||||
|
||||
std::shared_ptr<JKQTMathText> m_renderer;
|
||||
QString m_latex;
|
||||
QColor m_color;
|
||||
double m_fontPointSize = 12.0;
|
||||
qreal m_devicePixelRatio = 1.0;
|
||||
QImage m_image;
|
||||
QUrl m_imageUrl;
|
||||
QString m_imageId;
|
||||
qreal m_width = 0;
|
||||
qreal m_height = 0;
|
||||
bool m_ok = false;
|
||||
|
||||
@@ -57,6 +57,20 @@ void ChatMessage::setActiveGeneration(int index) {
|
||||
setActiveInternal(index);
|
||||
}
|
||||
|
||||
void ChatMessage::setAttachments(QList<Attachment*> attachments) {
|
||||
QList<Attachment*> stale;
|
||||
for (Attachment* attachment : m_attachments)
|
||||
if (!attachments.contains(attachment)) stale.append(attachment);
|
||||
m_attachments.clear();
|
||||
for (Attachment* attachment : attachments) {
|
||||
attachment->setParent(this);
|
||||
m_attachments.append(attachment);
|
||||
}
|
||||
Q_EMIT attachmentsChanged();
|
||||
for (Attachment* attachment : stale)
|
||||
attachment->deleteLater();
|
||||
}
|
||||
|
||||
void ChatMessage::edit(const QString& newContent) {
|
||||
if (auto* generation = activeGeneration())
|
||||
generation->setContent(newContent);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "attachment.hpp"
|
||||
#include "generation.hpp"
|
||||
|
||||
#include <QList>
|
||||
@@ -27,6 +28,9 @@ class ChatMessage : public QObject {
|
||||
Q_PROPERTY(
|
||||
int activeGenerationIndex READ activeGenerationIndex NOTIFY
|
||||
activeGenerationChanged)
|
||||
Q_PROPERTY(
|
||||
QList<ZShell::llm::Attachment*> attachments READ attachments NOTIFY
|
||||
attachmentsChanged)
|
||||
|
||||
public:
|
||||
enum class Role : int { User = 0, Assistant };
|
||||
@@ -46,12 +50,16 @@ class ChatMessage : public QObject {
|
||||
return generation(m_active);
|
||||
}
|
||||
[[nodiscard]] int activeGenerationIndex() const { return m_active; }
|
||||
[[nodiscard]] QList<Attachment*> attachments() const {
|
||||
return m_attachments;
|
||||
}
|
||||
[[nodiscard]] ChatGeneration* generation(int index) const {
|
||||
if (index < 0 || index >= m_generations.size()) return nullptr;
|
||||
return m_generations.at(index);
|
||||
}
|
||||
|
||||
Q_INVOKABLE void setActiveGeneration(int index);
|
||||
void setAttachments(QList<Attachment*> attachments);
|
||||
Q_INVOKABLE void edit(const QString& newContent);
|
||||
Q_INVOKABLE void retry();
|
||||
Q_INVOKABLE void generate();
|
||||
@@ -63,6 +71,7 @@ class ChatMessage : public QObject {
|
||||
Q_SIGNALS:
|
||||
void generationsChanged();
|
||||
void activeGenerationChanged();
|
||||
void attachmentsChanged();
|
||||
|
||||
private:
|
||||
void setActiveInternal(int index);
|
||||
@@ -70,6 +79,7 @@ class ChatMessage : public QObject {
|
||||
Role m_role;
|
||||
qint64 m_timestamp;
|
||||
QList<ChatGeneration*> m_generations;
|
||||
QList<Attachment*> m_attachments;
|
||||
int m_active = -1;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace ZShell::llm {
|
||||
|
||||
namespace {
|
||||
constexpr int kMarkdownRefreshMs = 150;
|
||||
} // namespace
|
||||
}
|
||||
|
||||
LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent)
|
||||
: QObject(parent), m_type(type), m_timestamp(timestamp) {
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
#include "session.hpp"
|
||||
|
||||
#include "attachment.hpp"
|
||||
#include "chatstore.hpp"
|
||||
#include "llmclient.hpp"
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QDebug>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QImage>
|
||||
#include <QMimeDatabase>
|
||||
#include <QMimeData>
|
||||
#include <QMimeType>
|
||||
#include <QStandardPaths>
|
||||
#include <QUrl>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr qint64 kMaxImageBytes = 20 * 1024 * 1024;
|
||||
constexpr qint64 kMaxFileBytes = 100 * 1024 * 1024;
|
||||
|
||||
QString titleFrom(const QString& content) {
|
||||
const QString flat = content.simplified();
|
||||
if (flat.isEmpty()) return QString();
|
||||
@@ -21,7 +36,7 @@ QString titleFrom(const QString& content) {
|
||||
} // namespace
|
||||
|
||||
ChatSession::ChatSession(const QString& id, QObject* parent)
|
||||
: QObject(parent), m_id(id) {
|
||||
: QObject(parent), m_id(id), m_workspace(sandboxDirFor(id)) {
|
||||
m_model = new ChatMessageModel(this, this);
|
||||
connect(
|
||||
m_model,
|
||||
@@ -157,26 +172,179 @@ void ChatSession::startGeneration(ChatMessage* target) {
|
||||
persist();
|
||||
}
|
||||
|
||||
QString ChatSession::sandboxDirFor(const QString& sessionId) {
|
||||
const QString state =
|
||||
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation);
|
||||
return state + QStringLiteral("/zshell/sandboxes/") + sessionId;
|
||||
}
|
||||
|
||||
void ChatSession::ensureWorkspace() {
|
||||
QDir().mkpath(m_workspace);
|
||||
}
|
||||
|
||||
void ChatSession::setAttachmentError(const QString& value) {
|
||||
if (m_attachmentError == value) return;
|
||||
m_attachmentError = value;
|
||||
Q_EMIT attachmentErrorChanged();
|
||||
}
|
||||
|
||||
QVariantList ChatSession::addAttachments(const QStringList& paths) {
|
||||
ensureWorkspace();
|
||||
setAttachmentError(QString());
|
||||
QVariantList result;
|
||||
for (const QString& rawPath : paths) {
|
||||
const QFileInfo info(rawPath);
|
||||
if (!info.exists() || !info.isFile()) {
|
||||
qWarning() << "ChatSession: addAttachments skipping" << rawPath
|
||||
<< ": file not found or not a file";
|
||||
setAttachmentError(
|
||||
QStringLiteral("File not found: %1").arg(info.fileName()));
|
||||
continue;
|
||||
}
|
||||
const QString mimeType = QMimeDatabase().mimeTypeForFile(info).name();
|
||||
const bool isImage =
|
||||
mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive);
|
||||
const qint64 cap = isImage ? kMaxImageBytes : kMaxFileBytes;
|
||||
if (info.size() > cap) {
|
||||
qWarning() << "ChatSession: addAttachments skipping" << rawPath
|
||||
<< ": too large (" << info.size() << "bytes, cap" << cap
|
||||
<< ")";
|
||||
setAttachmentError(
|
||||
QStringLiteral("File too large: %1").arg(info.fileName()));
|
||||
continue;
|
||||
}
|
||||
QString destination = m_workspace + QLatin1Char('/') + info.fileName();
|
||||
int suffix = 1;
|
||||
while (QFile::exists(destination)) {
|
||||
const QString extension = info.suffix().isEmpty()
|
||||
? QString()
|
||||
: QLatin1Char('.') + info.suffix();
|
||||
destination = m_workspace + QLatin1Char('/') +
|
||||
info.completeBaseName() +
|
||||
QStringLiteral("_%1%2").arg(suffix).arg(extension);
|
||||
++suffix;
|
||||
}
|
||||
if (!QFile::copy(info.absoluteFilePath(), destination)) {
|
||||
qWarning() << "ChatSession: addAttachments skipping" << rawPath
|
||||
<< ": copy to" << destination << "failed";
|
||||
setAttachmentError(
|
||||
QStringLiteral("Could not copy file: %1").arg(info.fileName()));
|
||||
continue;
|
||||
}
|
||||
auto* attachment = new Attachment(
|
||||
info.fileName(),
|
||||
destination,
|
||||
isImage ? Attachment::Kind::Image : Attachment::Kind::File,
|
||||
mimeType,
|
||||
info.size(),
|
||||
this);
|
||||
result.append(QVariant::fromValue(attachment));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool ChatSession::clipboardHasAttachment() {
|
||||
QClipboard* clipboard = QGuiApplication::clipboard();
|
||||
if (!clipboard) return false;
|
||||
const QMimeData* data = clipboard->mimeData();
|
||||
if (!data) return false;
|
||||
if (data->hasImage()) return true;
|
||||
for (const QUrl& url : data->urls())
|
||||
if (url.isLocalFile()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
QVariantList ChatSession::pasteAttachments() {
|
||||
QClipboard* clipboard = QGuiApplication::clipboard();
|
||||
const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr;
|
||||
if (!data) {
|
||||
qWarning() << "ChatSession: pasteAttachments: no clipboard data";
|
||||
return {};
|
||||
}
|
||||
|
||||
qInfo() << "ChatSession: pasteAttachments: formats =" << data->formats()
|
||||
<< "hasImage =" << data->hasImage()
|
||||
<< "hasUrls =" << data->hasUrls() << "hasText =" << data->hasText();
|
||||
|
||||
QStringList paths;
|
||||
QString tempImagePath;
|
||||
if (data->hasImage()) {
|
||||
const QImage image = clipboard->image();
|
||||
qInfo() << "ChatSession: pasteAttachments: image"
|
||||
<< (image.isNull() ? "null"
|
||||
: QString::number(image.width()) + "x" +
|
||||
QString::number(image.height()));
|
||||
if (image.isNull()) {
|
||||
setAttachmentError(
|
||||
QStringLiteral("Clipboard image could not be decoded"));
|
||||
return {};
|
||||
}
|
||||
tempImagePath =
|
||||
QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png")
|
||||
.arg(QDateTime::currentMSecsSinceEpoch());
|
||||
if (!image.save(tempImagePath)) {
|
||||
qWarning() << "ChatSession: pasteAttachments: saving image to"
|
||||
<< tempImagePath << "failed";
|
||||
setAttachmentError(
|
||||
QStringLiteral("Could not save the pasted image"));
|
||||
return {};
|
||||
}
|
||||
paths.append(tempImagePath);
|
||||
} else if (data->hasUrls()) {
|
||||
for (const QUrl& url : data->urls()) {
|
||||
qInfo() << "ChatSession: pasteAttachments: url =" << url.toString()
|
||||
<< "local =" << url.isLocalFile();
|
||||
if (url.isLocalFile()) paths.append(url.toLocalFile());
|
||||
}
|
||||
}
|
||||
if (paths.isEmpty()) {
|
||||
if (data->hasImage() || data->hasUrls())
|
||||
setAttachmentError(QStringLiteral(
|
||||
"Nothing usable on the clipboard (expected an image "
|
||||
"or copied files)"));
|
||||
return {};
|
||||
}
|
||||
const QVariantList attachments = addAttachments(paths);
|
||||
if (!tempImagePath.isEmpty()) QFile::remove(tempImagePath);
|
||||
qInfo() << "ChatSession: pasteAttachments: result =" << attachments.size()
|
||||
<< "attachment(s)";
|
||||
return attachments;
|
||||
}
|
||||
|
||||
void ChatSession::sendMessage(const QString& text) {
|
||||
sendMessage(text, QVariantList());
|
||||
}
|
||||
|
||||
void ChatSession::sendMessage(
|
||||
const QString& text, const QVariantList& attachments) {
|
||||
QList<Attachment*> attachmentList;
|
||||
for (const QVariant& variant : attachments)
|
||||
if (auto* attachment =
|
||||
qobject_cast<Attachment*>(variant.value<QObject*>()))
|
||||
attachmentList.append(attachment);
|
||||
const QString trimmed = text.trimmed();
|
||||
if (trimmed.isEmpty()) return;
|
||||
if (trimmed.isEmpty() && attachmentList.isEmpty()) return;
|
||||
if (auto* clientObject = client()) {
|
||||
if (clientObject->busy()) return;
|
||||
}
|
||||
|
||||
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||
appendNewest(ChatMessage::Role::User, trimmed, now);
|
||||
auto* userMessage = appendNewest(ChatMessage::Role::User, trimmed, now);
|
||||
if (!attachmentList.isEmpty()) userMessage->setAttachments(attachmentList);
|
||||
while (m_model->rowCount() > 200)
|
||||
m_model->removeMessage(m_model->at(m_model->rowCount() - 1));
|
||||
if (m_title.isEmpty()) {
|
||||
m_title = titleFrom(trimmed);
|
||||
QString titleSource = trimmed;
|
||||
if (titleSource.isEmpty() && !attachmentList.isEmpty())
|
||||
titleSource = attachmentList.first()->name();
|
||||
m_title = titleFrom(titleSource);
|
||||
Q_EMIT titleChanged();
|
||||
qInfo() << "ChatSession:" << m_id << "new conversation,"
|
||||
<< "fallback title" << m_title
|
||||
<< "- requesting generated title and icon";
|
||||
if (auto* clientObject = client()) {
|
||||
clientObject->requestTitle(this, trimmed);
|
||||
clientObject->requestIcon(this, trimmed);
|
||||
clientObject->requestTitle(this, titleSource);
|
||||
clientObject->requestIcon(this, titleSource);
|
||||
}
|
||||
}
|
||||
auto* assistant =
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <QDateTime>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVariantList>
|
||||
#include <QtQml>
|
||||
|
||||
namespace ZShell::llm {
|
||||
@@ -27,6 +29,10 @@ class ChatSession : public QObject {
|
||||
Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged)
|
||||
Q_PROPERTY(
|
||||
ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT)
|
||||
Q_PROPERTY(QString workspace READ workspacePath CONSTANT)
|
||||
Q_PROPERTY(
|
||||
QString attachmentError READ attachmentError NOTIFY
|
||||
attachmentErrorChanged)
|
||||
|
||||
public:
|
||||
explicit ChatSession(const QString& id, QObject* parent = nullptr);
|
||||
@@ -55,6 +61,13 @@ class ChatSession : public QObject {
|
||||
[[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; }
|
||||
void setLastTokenCount(int value);
|
||||
|
||||
[[nodiscard]] QString workspacePath() const { return m_workspace; }
|
||||
[[nodiscard]] QString attachmentError() const { return m_attachmentError; }
|
||||
void setAttachmentError(const QString& value);
|
||||
|
||||
static QString sandboxDirFor(const QString& sessionId);
|
||||
void ensureWorkspace();
|
||||
|
||||
void setTitle(const QString& value);
|
||||
void setIcon(const QString& value);
|
||||
void setUpdatedAt(qint64 value);
|
||||
@@ -71,7 +84,12 @@ class ChatSession : public QObject {
|
||||
void removeMessage(ChatMessage* message);
|
||||
void clearMessages();
|
||||
|
||||
Q_INVOKABLE QVariantList addAttachments(const QStringList& paths);
|
||||
Q_INVOKABLE bool clipboardHasAttachment();
|
||||
Q_INVOKABLE QVariantList pasteAttachments();
|
||||
Q_INVOKABLE void sendMessage(const QString& text);
|
||||
Q_INVOKABLE void sendMessage(
|
||||
const QString& text, const QVariantList& attachments);
|
||||
Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target);
|
||||
Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message);
|
||||
Q_INVOKABLE void clear();
|
||||
@@ -83,6 +101,7 @@ class ChatSession : public QObject {
|
||||
void pinnedChanged();
|
||||
void messageCountChanged();
|
||||
void loaded();
|
||||
void attachmentErrorChanged();
|
||||
|
||||
private:
|
||||
void onModelRowsChanged();
|
||||
@@ -103,6 +122,8 @@ class ChatSession : public QObject {
|
||||
bool m_loaded = false;
|
||||
bool m_clearPending = false;
|
||||
int m_lastTokenCount = 0;
|
||||
QString m_workspace;
|
||||
QString m_attachmentError;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
#include "shelltool.hpp"
|
||||
|
||||
#include "llmclient.hpp"
|
||||
#include "session.hpp"
|
||||
|
||||
#include <QDir>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QProcess>
|
||||
#include <QTimer>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
namespace {
|
||||
|
||||
QJsonObject makeOutput(const QString& text) {
|
||||
QJsonObject obj;
|
||||
obj[QStringLiteral("output")] = text;
|
||||
return obj;
|
||||
}
|
||||
|
||||
QJsonObject makeError(const QString& message) {
|
||||
QJsonObject obj;
|
||||
obj[QStringLiteral("error")] = message;
|
||||
return obj;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ShellTool::ShellTool(LlmClient* client, QObject* parent)
|
||||
: LlmTool(parent), m_client(client) {}
|
||||
|
||||
ShellTool::~ShellTool() {
|
||||
for (auto* job : m_jobs) {
|
||||
if (job->timer) job->timer->stop();
|
||||
if (job->process) job->process->kill();
|
||||
delete job;
|
||||
}
|
||||
m_jobs.clear();
|
||||
}
|
||||
|
||||
QString ShellTool::name() const {
|
||||
return QStringLiteral("runcommand");
|
||||
}
|
||||
|
||||
QString ShellTool::description() const {
|
||||
return QStringLiteral(
|
||||
"Run a shell command inside a bubblewrap sandbox that contains "
|
||||
"the files the user attached to the conversation. The rest of "
|
||||
"the filesystem is mounted read-only, there is no network "
|
||||
"access, and only the sandbox directory is writable. The "
|
||||
"command is executed with /bin/sh and the sandbox directory as "
|
||||
"its working directory, so relative paths refer to the sandbox. "
|
||||
"Use it to explore, transform, or extract attached files (for "
|
||||
"example: ls, find, grep, unzip, tar, head, wc). The combined "
|
||||
"output is limited; the exit code is always reported. The call "
|
||||
"only completes when every child process has exited, so do not "
|
||||
"start long-lived background processes.");
|
||||
}
|
||||
|
||||
QJsonObject ShellTool::parameters() const {
|
||||
QJsonObject command;
|
||||
command[QStringLiteral("type")] = QStringLiteral("string");
|
||||
command[QStringLiteral("description")] =
|
||||
QStringLiteral("The shell command to execute");
|
||||
|
||||
QJsonObject timeout;
|
||||
timeout[QStringLiteral("type")] = QStringLiteral("integer");
|
||||
timeout[QStringLiteral("minimum")] = 1;
|
||||
timeout[QStringLiteral("maximum")] = MaxTimeoutSeconds;
|
||||
timeout[QStringLiteral("description")] =
|
||||
QStringLiteral("Timeout in seconds. Defaults to %1.")
|
||||
.arg(DefaultTimeoutSeconds);
|
||||
|
||||
QJsonObject properties;
|
||||
properties[QStringLiteral("command")] = command;
|
||||
properties[QStringLiteral("timeout")] = timeout;
|
||||
|
||||
QJsonObject schema;
|
||||
schema[QStringLiteral("type")] = QStringLiteral("object");
|
||||
schema[QStringLiteral("properties")] = properties;
|
||||
QJsonArray required;
|
||||
required.append(QStringLiteral("command"));
|
||||
schema[QStringLiteral("required")] = required;
|
||||
return schema;
|
||||
}
|
||||
|
||||
QStringList ShellTool::sandboxArguments(const QString& workspace) {
|
||||
QStringList args;
|
||||
args << QLatin1String("--unshare-user") << QLatin1String("--uid")
|
||||
<< QString::number(::getuid()) << QLatin1String("--gid")
|
||||
<< QString::number(::getgid()) << QLatin1String("--disable-userns")
|
||||
<< QLatin1String("--unshare-pid") << QLatin1String("--unshare-net")
|
||||
<< QLatin1String("--unshare-ipc") << QLatin1String("--unshare-uts")
|
||||
<< QLatin1String("--hostname") << QLatin1String("zshell-sandbox");
|
||||
args << QLatin1String("--ro-bind") << QLatin1String("/")
|
||||
<< QLatin1String("/") << QLatin1String("--tmpfs")
|
||||
<< QLatin1String("/tmp") << QLatin1String("--tmpfs")
|
||||
<< QLatin1String("/run") << QLatin1String("--bind") << workspace
|
||||
<< workspace << QLatin1String("--dev") << QLatin1String("/dev")
|
||||
<< QLatin1String("--proc") << QLatin1String("/proc");
|
||||
args << QLatin1String("--chdir") << workspace;
|
||||
args << QLatin1String("--clearenv") << QLatin1String("--setenv")
|
||||
<< QLatin1String("PATH")
|
||||
<< QLatin1String(
|
||||
"/usr/local/sbin:/usr/local/bin:/usr/sbin:"
|
||||
"/usr/bin:/sbin:/bin")
|
||||
<< QLatin1String("--setenv") << QLatin1String("HOME") << workspace
|
||||
<< QLatin1String("--setenv") << QLatin1String("TMPDIR")
|
||||
<< QLatin1String("/tmp") << QLatin1String("--setenv")
|
||||
<< QLatin1String("SHELL") << QLatin1String("/bin/sh");
|
||||
const QByteArray user = qgetenv("USER");
|
||||
if (!user.isEmpty())
|
||||
args << QLatin1String("--setenv") << QLatin1String("USER")
|
||||
<< QString::fromLocal8Bit(user) << QLatin1String("--setenv")
|
||||
<< QLatin1String("LOGNAME") << QString::fromLocal8Bit(user);
|
||||
const QByteArray lang = qgetenv("LANG");
|
||||
if (!lang.isEmpty())
|
||||
args << QLatin1String("--setenv") << QLatin1String("LANG")
|
||||
<< QString::fromLocal8Bit(lang);
|
||||
args << QLatin1String("--new-session")
|
||||
<< QLatin1String("--die-with-parent");
|
||||
return args;
|
||||
}
|
||||
|
||||
void ShellTool::execute(
|
||||
const QString& toolCallId,
|
||||
const QJsonObject& args,
|
||||
int outputBudgetChars,
|
||||
std::function<void(const QJsonObject&)> done) {
|
||||
Q_UNUSED(toolCallId);
|
||||
const QString command = args[QStringLiteral("command")].toString();
|
||||
if (command.trimmed().isEmpty()) {
|
||||
done(makeError(QStringLiteral("Missing required argument 'command'")));
|
||||
return;
|
||||
}
|
||||
LlmClient* client = m_client;
|
||||
if (!client || !client->sandboxEnabled()) {
|
||||
done(makeError(QStringLiteral(
|
||||
"The file sandbox is disabled, so no commands can run")));
|
||||
return;
|
||||
}
|
||||
const QString workspace = client->activeWorkspace();
|
||||
if (workspace.isEmpty()) {
|
||||
done(makeError(
|
||||
QStringLiteral("No sandbox available: no chat session is active")));
|
||||
return;
|
||||
}
|
||||
QDir().mkpath(workspace);
|
||||
|
||||
const int timeoutSeconds = qBound(
|
||||
1,
|
||||
args[QStringLiteral("timeout")].toInt(DefaultTimeoutSeconds),
|
||||
MaxTimeoutSeconds);
|
||||
auto* job = new Job;
|
||||
job->timeoutSeconds = timeoutSeconds;
|
||||
job->outputBudgetChars = outputBudgetChars;
|
||||
job->done = std::move(done);
|
||||
m_jobs.append(job);
|
||||
|
||||
QProcess* process = new QProcess(this);
|
||||
process->setWorkingDirectory(workspace);
|
||||
process->setProcessChannelMode(QProcess::MergedChannels);
|
||||
job->process = process;
|
||||
|
||||
QTimer* timer = new QTimer(this);
|
||||
timer->setSingleShot(true);
|
||||
connect(timer, &QTimer::timeout, this, [this, job, process]() {
|
||||
job->timedOut = true;
|
||||
process->kill();
|
||||
});
|
||||
timer->start(timeoutSeconds * 1000);
|
||||
job->timer = timer;
|
||||
|
||||
connect(
|
||||
process,
|
||||
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
||||
this,
|
||||
[this, job](int, QProcess::ExitStatus) { finishJob(job); });
|
||||
connect(
|
||||
process,
|
||||
&QProcess::errorOccurred,
|
||||
this,
|
||||
[this, job, process](QProcess::ProcessError error) {
|
||||
if (job->finished) return;
|
||||
if (error == QProcess::FailedToStart ||
|
||||
process->state() == QProcess::NotRunning) {
|
||||
job->startError = process->errorString();
|
||||
finishJob(job);
|
||||
} else {
|
||||
process->kill();
|
||||
}
|
||||
});
|
||||
|
||||
QStringList commandArgs = sandboxArguments(workspace);
|
||||
commandArgs << QStringLiteral("sh") << QStringLiteral("-c") << command;
|
||||
process->start(QStringLiteral("bwrap"), commandArgs);
|
||||
}
|
||||
|
||||
void ShellTool::cancel() {
|
||||
for (auto* job : m_jobs) {
|
||||
if (job->finished || !job->process) continue;
|
||||
job->cancelled = true;
|
||||
job->process->kill();
|
||||
}
|
||||
}
|
||||
|
||||
void ShellTool::finishJob(Job* job) {
|
||||
if (!job || job->finished) return;
|
||||
job->finished = true;
|
||||
if (job->timer) job->timer->stop();
|
||||
m_jobs.removeAll(job);
|
||||
|
||||
QProcess* process = job->process;
|
||||
const bool ran = process && process->state() != QProcess::NotRunning;
|
||||
const int exitCode = ran ? process->exitCode() : -1;
|
||||
QString output;
|
||||
if (process) output = QString::fromUtf8(process->readAllStandardOutput());
|
||||
|
||||
QString note;
|
||||
if (!job->startError.isEmpty())
|
||||
note =
|
||||
QStringLiteral("Command failed to start: %1\n").arg(job->startError);
|
||||
else if (job->cancelled)
|
||||
note = QStringLiteral("Command was cancelled.\n");
|
||||
else if (job->timedOut)
|
||||
note = QStringLiteral(
|
||||
"Command timed out after %1 seconds and was killed.\n")
|
||||
.arg(job->timeoutSeconds);
|
||||
|
||||
int budget = inlineBudgetChars();
|
||||
if (job->outputBudgetChars > 0)
|
||||
budget = qMin(budget, job->outputBudgetChars);
|
||||
if (output.size() > budget)
|
||||
output = output.left(budget) + QStringLiteral(
|
||||
"\n[output truncated: %1 characters "
|
||||
"total]")
|
||||
.arg(output.size());
|
||||
|
||||
const QString result =
|
||||
note + QStringLiteral("exit code: %1\n").arg(exitCode) +
|
||||
(output.isEmpty() ? QStringLiteral("(no output)") : output);
|
||||
|
||||
QJsonObject resultObject;
|
||||
if (!job->cancelled && !job->timedOut && exitCode == 0)
|
||||
resultObject = makeOutput(result);
|
||||
else
|
||||
resultObject = makeError(result);
|
||||
|
||||
std::function<void(const QJsonObject&)> done = std::move(job->done);
|
||||
if (process) process->deleteLater();
|
||||
delete job;
|
||||
if (done) done(resultObject);
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include "tool.hpp"
|
||||
|
||||
#include <QList>
|
||||
#include <QPointer>
|
||||
#include <QStringList>
|
||||
|
||||
class QProcess;
|
||||
class QTimer;
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
class LlmClient;
|
||||
|
||||
class ShellTool : public LlmTool {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ShellTool(LlmClient* client, QObject* parent = nullptr);
|
||||
~ShellTool() override;
|
||||
|
||||
QString name() const override;
|
||||
QString description() const override;
|
||||
QJsonObject parameters() const override;
|
||||
void execute(
|
||||
const QString& toolCallId,
|
||||
const QJsonObject& args,
|
||||
int outputBudgetChars,
|
||||
std::function<void(const QJsonObject& result)> done) override;
|
||||
void cancel() override;
|
||||
|
||||
static constexpr int DefaultTimeoutSeconds = 60;
|
||||
static constexpr int MaxTimeoutSeconds = 300;
|
||||
|
||||
private:
|
||||
struct Job {
|
||||
QPointer<QProcess> process;
|
||||
QPointer<QTimer> timer;
|
||||
int timeoutSeconds = DefaultTimeoutSeconds;
|
||||
int outputBudgetChars = 0;
|
||||
std::function<void(const QJsonObject&)> done;
|
||||
QString startError;
|
||||
bool timedOut = false;
|
||||
bool cancelled = false;
|
||||
bool finished = false;
|
||||
};
|
||||
|
||||
static QStringList sandboxArguments(const QString& workspace);
|
||||
|
||||
void finishJob(Job* job);
|
||||
|
||||
QPointer<LlmClient> m_client;
|
||||
QList<Job*> m_jobs;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -0,0 +1,718 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mock llama-server for exercising zshell's LLM error handling.
|
||||
|
||||
Implements the OpenAI-compatible subset of llama-server that the C++
|
||||
backend talks to, and lets you trigger specific failure modes from the
|
||||
QML chat UI by typing a keyword as your message.
|
||||
|
||||
Wire format is taken from ggml-org/llama.cpp (tools/server):
|
||||
- SSE events are "data: <json>\n\n" (note the space after "data:").
|
||||
- A clean stream ends with "data: [DONE]\n\n".
|
||||
- Errors are {"error": {"code", "message", "type"}}.
|
||||
- A stream that dies mid-way sends the error event, then closes
|
||||
WITHOUT [DONE].
|
||||
- A request that errors before the first token is a plain HTTP 4xx/5xx
|
||||
with the error JSON body (not a stream).
|
||||
- /props is a JSON object; n_ctx lives under
|
||||
default_generation_settings.n_ctx.
|
||||
|
||||
Run:
|
||||
python3 scripts/mock-llama-server.py [--port 8080]
|
||||
|
||||
Then point zshell's LLM endpoint at http://localhost:8080.
|
||||
|
||||
Trigger words (type as the chat message, case-insensitive):
|
||||
normal -> healthy stream, ends with [DONE] (also the default)
|
||||
instant error -> 500 before any token ("server_error")
|
||||
context overflow -> 400 "exceeds the available context size (2048 ...)";
|
||||
backend retries once at 2048, then fails
|
||||
error during -> streams a few tokens, then an SSE error event,
|
||||
closes without [DONE]
|
||||
no done -> streams a few tokens, closes without [DONE] and
|
||||
without an error (server killed mid-response)
|
||||
200 error -> HTTP 200 with a plain JSON error body (not SSE)
|
||||
tool call -> streams reasoning, then a tool_calls round calling
|
||||
readfile on /etc/os-release (finish_reason
|
||||
"tool_calls"); after you approve (or deny) the
|
||||
permission dialog, the follow-up round that
|
||||
carries the tool result gets a normal summary
|
||||
answer. Exercises the tool permission dialog,
|
||||
tool execution and the multi-round transcript.
|
||||
multiple tool -> two tool calls in one round (readfile + webfetch
|
||||
calls of this mock's own / endpoint); approve both at
|
||||
once
|
||||
unknown tool -> a tool call for a tool the client does not have;
|
||||
approving it yields an "unknown tool" error
|
||||
result
|
||||
|
||||
Control endpoint (for the non-chat routes / global state), via curl:
|
||||
curl -XPOST localhost:8080/_mock -d '{"loading": true}'
|
||||
/props, /v1/models, /health all answer 503 "Loading model"
|
||||
curl -XPOST localhost:8080/_mock -d '{"no_models": true}'
|
||||
/v1/models returns an empty list
|
||||
curl -XPOST localhost:8080/_mock -d '{"chat_mode": "error_during"}'
|
||||
force a chat behaviour regardless of the message text
|
||||
(values: normal|instant_error|context_overflow|error_during|
|
||||
no_done|200_error|tool_call|tool_call_multi|
|
||||
tool_call_unknown, or null to go back to keywords)
|
||||
curl -XPOST localhost:8080/_mock -d '{"reset": true}'
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
MODEL_ID = "mock-model"
|
||||
CTX_SIZE = 8192
|
||||
|
||||
# Set in main(); used by the tool-call behaviours so webfetch can point
|
||||
# at this server itself.
|
||||
_host = "127.0.0.1"
|
||||
_port = 8080
|
||||
|
||||
# Mutable, shared across handler threads.
|
||||
_state_lock = threading.Lock()
|
||||
_state = {
|
||||
"loading": False, # 503 "Loading model" on /props, /v1/models, /health
|
||||
"no_models": False, # /v1/models returns an empty list
|
||||
"chat_mode": None, # force a chat behaviour, overriding keywords
|
||||
}
|
||||
|
||||
|
||||
def _now():
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _chunk(cid, delta, finish_reason=None):
|
||||
return {
|
||||
"id": cid,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": _now(),
|
||||
"model": MODEL_ID,
|
||||
"system_fingerprint": "mock",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _usage_chunk(cid, prompt_tokens, completion_tokens):
|
||||
return {
|
||||
"id": cid,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": _now(),
|
||||
"model": MODEL_ID,
|
||||
"choices": [],
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": prompt_tokens + completion_tokens,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _error(code, message, etype):
|
||||
return {"error": {"code": code, "message": message, "type": etype}}
|
||||
|
||||
|
||||
def _sse(obj):
|
||||
# Exact llama.cpp format_oai_sse shape: "data: " + compact json + "\n\n".
|
||||
return ("data: " + json.dumps(obj, separators=(",", ":")) + "\n\n").encode()
|
||||
|
||||
|
||||
def _sse_done():
|
||||
return b"data: [DONE]\n\n"
|
||||
|
||||
|
||||
# Chat behaviours. Each returns ("json", status, body_bytes) for a
|
||||
# non-streaming answer, or ("sse", 200, None) and then writes to the
|
||||
# provided stream writer.
|
||||
def _behaviour_for(text):
|
||||
t = text.lower()
|
||||
if "instant error" in t:
|
||||
return "instant_error"
|
||||
if "context overflow" in t:
|
||||
return "context_overflow"
|
||||
if "error during" in t:
|
||||
return "error_during"
|
||||
if "no done" in t or "truncate" in t:
|
||||
return "no_done"
|
||||
if "200 error" in t:
|
||||
return "200_error"
|
||||
# Order matters: "multiple tool calls" contains "tool call".
|
||||
if "two tool calls" in t or "two tool call" in t:
|
||||
return "tool_call_duo"
|
||||
if "some tool calls" in t:
|
||||
return "tool_call_some"
|
||||
if "many tool calls" in t:
|
||||
return "tool_call_many"
|
||||
if "unknown tool" in t:
|
||||
return "tool_call_unknown"
|
||||
if "tool call" in t:
|
||||
return "tool_call"
|
||||
return "normal"
|
||||
|
||||
|
||||
_TOOL_MODES = ("tool_call", "tool_call_duo", "tool_call_some", "tool_call_many", "tool_call_unknown")
|
||||
|
||||
|
||||
def _tool_call_delta(index, call_id=None, name=None, arguments=None):
|
||||
call = {"index": index}
|
||||
if call_id is not None:
|
||||
call["id"] = call_id
|
||||
if name is not None or arguments is not None:
|
||||
function = {}
|
||||
if name is not None:
|
||||
function["name"] = name
|
||||
if arguments is not None:
|
||||
function["arguments"] = arguments
|
||||
call["function"] = function
|
||||
return {"tool_calls": [call]}
|
||||
|
||||
|
||||
def _stream_tool_calls(w, cid, reasoning, calls):
|
||||
# One tool round: optional reasoning, then the calls (llama.cpp
|
||||
# streams the id/name with the first fragment of each call and the
|
||||
# arguments as successive fragments), then finish_reason
|
||||
# "tool_calls". The client concatenates the argument fragments and
|
||||
# shows the permission dialog on stream end.
|
||||
w(_sse(_chunk(cid, {"role": "assistant"})))
|
||||
if reasoning:
|
||||
for piece in reasoning:
|
||||
w(_sse(_chunk(cid, {"reasoning_content": piece})))
|
||||
time.sleep(0.05)
|
||||
for i, (call_id, name, arguments) in enumerate(calls):
|
||||
w(_sse(_chunk(cid, _tool_call_delta(i, call_id=call_id, name=name))))
|
||||
time.sleep(0.05)
|
||||
for fragment in arguments:
|
||||
w(
|
||||
_sse(
|
||||
_chunk(cid, _tool_call_delta(i, arguments=fragment))
|
||||
)
|
||||
)
|
||||
time.sleep(0.05)
|
||||
w(_sse(_chunk(cid, {}, finish_reason="tool_calls")))
|
||||
w(_sse(_usage_chunk(cid, 24, len(reasoning or []) + len(calls) * 2)))
|
||||
w(_sse_done())
|
||||
|
||||
|
||||
def _tool_result_snippets(messages, limit=160):
|
||||
snippets = []
|
||||
for msg in messages or []:
|
||||
if msg.get("role") != "tool":
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
part.get("text", "")
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") == "text"
|
||||
)
|
||||
flat = " ".join(str(content or "").split())
|
||||
snippets.append(flat[:limit] + ("..." if len(flat) > limit else ""))
|
||||
return snippets
|
||||
|
||||
|
||||
def _stream_tool_round_done(w, cid, messages):
|
||||
# Follow-up round: the client sent the tool results back; answer
|
||||
# with a plain content summary so the turn completes.
|
||||
w(_sse(_chunk(cid, {"role": "assistant"})))
|
||||
parts = ["mock:", "tool", "round", "complete."]
|
||||
for i, snippet in enumerate(_tool_result_snippets(messages), 1):
|
||||
parts.append(f"result {i}:")
|
||||
parts.append(snippet or "(empty)")
|
||||
for word in parts:
|
||||
w(_sse(_chunk(cid, {"content": word + " "})))
|
||||
time.sleep(0.02)
|
||||
w(_sse(_chunk(cid, {}, finish_reason="stop")))
|
||||
w(_sse(_usage_chunk(cid, 40, len(parts) + 1)))
|
||||
w(_sse_done())
|
||||
|
||||
|
||||
def _stream_normal(w, cid, words):
|
||||
w(_sse(_chunk(cid, {"role": "assistant"})))
|
||||
for word in words:
|
||||
w(_sse(_chunk(cid, {"content": word + " "})))
|
||||
time.sleep(0.05)
|
||||
w(_sse(_chunk(cid, {}, finish_reason="stop")))
|
||||
w(_sse(_usage_chunk(cid, 12, len(words) + 1)))
|
||||
w(_sse_done())
|
||||
|
||||
|
||||
def _stream_error_during(w, cid, words):
|
||||
w(_sse(_chunk(cid, {"role": "assistant"})))
|
||||
for word in words:
|
||||
w(_sse(_chunk(cid, {"content": word + " "})))
|
||||
time.sleep(0.05)
|
||||
# Mid-stream failure: error event, then the connection closes without
|
||||
# [DONE] (llama.cpp terminates the stream on a task error).
|
||||
w(_sse(_error(500, "Mock: generation failed mid-stream", "server_error")))
|
||||
|
||||
|
||||
def _stream_no_done(w, cid, words):
|
||||
w(_sse(_chunk(cid, {"role": "assistant"})))
|
||||
for word in words:
|
||||
w(_sse(_chunk(cid, {"content": word + " "})))
|
||||
time.sleep(0.05)
|
||||
# Clean FIN, no [DONE], no error: server died mid-response.
|
||||
|
||||
|
||||
def _last_user_text(messages):
|
||||
for msg in reversed(messages or []):
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# multimodal shape: pull out the text parts
|
||||
parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
parts.append(part.get("text", ""))
|
||||
content = " ".join(parts)
|
||||
return content or ""
|
||||
return ""
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "MockLlama/1.0"
|
||||
|
||||
# --- low-level response helpers -----------------------------------
|
||||
def _send_json(self, status, obj):
|
||||
body = json.dumps(obj, separators=(",", ":")).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_loading_error(self):
|
||||
# llama.cpp middleware: server not ready -> 503 on every route.
|
||||
self._send_json(
|
||||
503, _error(503, "Loading model", "unavailable_error")
|
||||
)
|
||||
|
||||
def _not_ready(self):
|
||||
with _state_lock:
|
||||
return _state["loading"]
|
||||
|
||||
def _stream(self, status, writer):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
try:
|
||||
writer(self.wfile.write)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
# --- routing -------------------------------------------------------
|
||||
def do_GET(self):
|
||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||
if path in ("/health", "/v1/health"):
|
||||
if self._not_ready():
|
||||
return self._send_loading_error()
|
||||
return self._send_json(200, {"status": "ok"})
|
||||
if path in ("/props", "/v1/props"):
|
||||
if self._not_ready():
|
||||
return self._send_loading_error()
|
||||
return self._send_json(
|
||||
200,
|
||||
{
|
||||
"total_slots": 1,
|
||||
"model_alias": MODEL_ID,
|
||||
"model_path": "/mock/model.gguf",
|
||||
"default_generation_settings": {
|
||||
"params": {"n_predict": 2048},
|
||||
"n_ctx": CTX_SIZE,
|
||||
},
|
||||
"is_sleeping": False,
|
||||
},
|
||||
)
|
||||
if path in ("/models", "/v1/models"):
|
||||
if self._not_ready():
|
||||
return self._send_loading_error()
|
||||
with _state_lock:
|
||||
no_models = _state["no_models"]
|
||||
data = [] if no_models else [
|
||||
{
|
||||
"id": MODEL_ID,
|
||||
"object": "model",
|
||||
"created": _now(),
|
||||
"owned_by": "mock",
|
||||
}
|
||||
]
|
||||
return self._send_json(200, {"object": "list", "data": data})
|
||||
if path == "/":
|
||||
return self._send_json(200, {"status": "ok", "mock": True})
|
||||
return self._send_json(404, _error(404, "not found", "not_found_error"))
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
try:
|
||||
body = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
body = {}
|
||||
|
||||
if path == "/_mock":
|
||||
return self._mock_control(body)
|
||||
|
||||
if path in ("/chat/completions", "/v1/chat/completions"):
|
||||
if self._not_ready():
|
||||
return self._send_loading_error()
|
||||
return self._chat(body)
|
||||
|
||||
return self._send_json(404, _error(404, "not found", "not_found_error"))
|
||||
|
||||
# --- /_mock control ------------------------------------------------
|
||||
def _mock_control(self, body):
|
||||
with _state_lock:
|
||||
if body.get("reset"):
|
||||
_state["loading"] = False
|
||||
_state["no_models"] = False
|
||||
_state["chat_mode"] = None
|
||||
if "loading" in body:
|
||||
_state["loading"] = bool(body["loading"])
|
||||
if "no_models" in body:
|
||||
_state["no_models"] = bool(body["no_models"])
|
||||
if "chat_mode" in body:
|
||||
_state["chat_mode"] = body["chat_mode"]
|
||||
snapshot = dict(_state)
|
||||
self.log_message("control -> %s", json.dumps(snapshot))
|
||||
return self._send_json(200, {"ok": True, "state": snapshot})
|
||||
|
||||
# --- /chat/completions --------------------------------------------
|
||||
def _chat(self, body):
|
||||
text = _last_user_text(body.get("messages"))
|
||||
with _state_lock:
|
||||
forced = _state["chat_mode"]
|
||||
mode = forced or _behaviour_for(text)
|
||||
stream = bool(body.get("stream", True))
|
||||
cid = "chatcmpl-mock"
|
||||
words = ["mock", "says", "hello", "from", "the", "mock", "server"]
|
||||
self.log_message("chat mode=%s stream=%s text=%r", mode, stream, text)
|
||||
|
||||
if mode == "instant_error":
|
||||
# Error before the first token: plain HTTP error, not a stream.
|
||||
return self._send_json(
|
||||
500, _error(500, "Mock: instant server error", "server_error")
|
||||
)
|
||||
|
||||
if mode == "context_overflow":
|
||||
return self._send_json(
|
||||
400,
|
||||
_error(
|
||||
400,
|
||||
"request (100000 tokens) exceeds the available context "
|
||||
"size (2048 tokens), try increasing it",
|
||||
"exceed_context_size_error",
|
||||
),
|
||||
)
|
||||
|
||||
if mode == "200_error":
|
||||
# 200 with a plain JSON error body (not SSE) -- the case the
|
||||
# data: filter would otherwise swallow.
|
||||
return self._send_json(
|
||||
200, _error(400, "Mock: 200-with-json-error", "server_error")
|
||||
)
|
||||
|
||||
if mode in _TOOL_MODES:
|
||||
# Continuation round: the previous round's tool results came
|
||||
# back through the client (role: "tool" messages).
|
||||
if any(m.get("role") == "tool" for m in body.get("messages") or []):
|
||||
return self._stream(
|
||||
200,
|
||||
lambda w: _stream_tool_round_done(
|
||||
w, cid, body.get("messages")
|
||||
),
|
||||
)
|
||||
if mode == "tool_call":
|
||||
calls = [
|
||||
(
|
||||
"call_mock_1",
|
||||
"readfile",
|
||||
['{"path": "/etc/', "os-release\"}"],
|
||||
)
|
||||
]
|
||||
reasoning = [
|
||||
"The user wants a tool call. ",
|
||||
"I will read /etc/os-release.",
|
||||
]
|
||||
elif mode == "tool_call_duo":
|
||||
calls = [
|
||||
(
|
||||
"call_mock_1",
|
||||
"readfile",
|
||||
['{"path": "/etc/', "hostname\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_2",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
]
|
||||
reasoning = ["Two independent tool calls, ",
|
||||
"batched together."]
|
||||
elif mode == "tool_call_some":
|
||||
calls = [
|
||||
(
|
||||
"call_mock_1",
|
||||
"readfile",
|
||||
['{"path": "/etc/', "hostname\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_2",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
(
|
||||
"call_mock_3",
|
||||
"readfile",
|
||||
['{"path": "/etc/', "os-release\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_4",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
(
|
||||
"call_mock_5",
|
||||
"readfile",
|
||||
['{"path": "/proc/', "uptime\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_6",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
]
|
||||
reasoning = ["Two independent tool calls, ",
|
||||
"batched together."]
|
||||
elif mode == "tool_call_many":
|
||||
calls = [
|
||||
(
|
||||
"call_mock_1",
|
||||
"readfile",
|
||||
['{"path": "/etc/', "hostname\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_2",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
(
|
||||
"call_mock_3",
|
||||
"readfile",
|
||||
['{"path": "/etc/', "os-release\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_4",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
(
|
||||
"call_mock_5",
|
||||
"readfile",
|
||||
['{"path": "/proc/', "uptime\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_6",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
(
|
||||
"call_mock_1",
|
||||
"readfile",
|
||||
['{"path": "/etc/', "hostname\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_2",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
(
|
||||
"call_mock_3",
|
||||
"readfile",
|
||||
['{"path": "/etc/', "os-release\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_4",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
(
|
||||
"call_mock_5",
|
||||
"readfile",
|
||||
['{"path": "/proc/', "uptime\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_6",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
(
|
||||
"call_mock_1",
|
||||
"readfile",
|
||||
['{"path": "/etc/', "hostname\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_2",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
(
|
||||
"call_mock_3",
|
||||
"readfile",
|
||||
['{"path": "/etc/', "os-release\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_4",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
(
|
||||
"call_mock_5",
|
||||
"readfile",
|
||||
['{"path": "/proc/', "uptime\"}"],
|
||||
),
|
||||
(
|
||||
"call_mock_6",
|
||||
"webfetch",
|
||||
[
|
||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
||||
'"format": "text"}',
|
||||
],
|
||||
),
|
||||
]
|
||||
reasoning = ["Two independent tool calls, ",
|
||||
"batched together."]
|
||||
else: # tool_call_unknown
|
||||
calls = [
|
||||
(
|
||||
"call_mock_3",
|
||||
"nonexistent_tool",
|
||||
['{"target": "test"}'],
|
||||
)
|
||||
]
|
||||
reasoning = ["Calling a tool the client ", "does not have."]
|
||||
self.log_message(
|
||||
"tool mode=%s calls=%s", mode, [c[1] for c in calls]
|
||||
)
|
||||
return self._stream(
|
||||
200, lambda w: _stream_tool_calls(w, cid, reasoning, calls)
|
||||
)
|
||||
|
||||
if not stream:
|
||||
# Non-streaming success: single chat.completion object.
|
||||
content = " ".join(words)
|
||||
return self._send_json(
|
||||
200,
|
||||
{
|
||||
"id": cid,
|
||||
"object": "chat.completion",
|
||||
"created": _now(),
|
||||
"model": MODEL_ID,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": content},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 12,
|
||||
"completion_tokens": len(words),
|
||||
"total_tokens": 12 + len(words),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if mode == "error_during":
|
||||
return self._stream(200, lambda w: _stream_error_during(w, cid, words))
|
||||
if mode == "no_done":
|
||||
return self._stream(200, lambda w: _stream_no_done(w, cid, words))
|
||||
return self._stream(200, lambda w: _stream_normal(w, cid, words))
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Compact single-line logging to stdout.
|
||||
print("%s - %s" % (self.log_date_time_string(), format % args), flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Mock llama-server for exercising zshell LLM error handling"
|
||||
)
|
||||
ap.add_argument("--port", type=int, default=8080)
|
||||
ap.add_argument("--host", default="127.0.0.1")
|
||||
args = ap.parse_args()
|
||||
|
||||
global _host, _port
|
||||
_host = args.host
|
||||
_port = args.port
|
||||
|
||||
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||
server.daemon_threads = True
|
||||
print(
|
||||
f"mock llama-server on http://{args.host}:{args.port} "
|
||||
f"(model={MODEL_ID}, ctx={CTX_SIZE})\n"
|
||||
"type trigger words in the chat UI, or curl /_mock to change state",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DURATION="${1:-${QS_SMOKE_DURATION:-10}}"
|
||||
QML_IMPORT_PATH="${QML_IMPORT_PATH:-$ROOT/build/qml}"
|
||||
|
||||
if [ ! -d "$QML_IMPORT_PATH/ZShell" ]; then
|
||||
echo "error: no ZShell plugin at $QML_IMPORT_PATH/ZShell"
|
||||
echo "build the project first: cmake --build build"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
OUT="$(mktemp /tmp/zshell-smoke.XXXXXX)"
|
||||
trap 'rm -f "$OUT"' EXIT
|
||||
|
||||
echo "booting shell for ${DURATION}s (a second bar will be visible)"
|
||||
env QT_QPA_PLATFORM=wayland QML_IMPORT_PATH="$QML_IMPORT_PATH" \
|
||||
qs --no-color -p "$ROOT/shell.qml" >"$OUT" 2>&1 &
|
||||
CLI_PID=$!
|
||||
|
||||
IID=""
|
||||
for _ in $(seq 1 100); do
|
||||
IID="$(sed -nE 's|.*by-id/([a-z0-9]+)/log\.qslog.*|\1|p' "$OUT" | head -1)"
|
||||
[ -n "$IID" ] && break
|
||||
kill -0 "$CLI_PID" 2>/dev/null || break
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if [ -z "$IID" ]; then
|
||||
echo "FAIL: shell did not start"
|
||||
cat "$OUT"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
sleep "$DURATION"
|
||||
|
||||
qs kill -i "$IID" >/dev/null 2>&1
|
||||
if qs list --all 2>/dev/null | grep -q "Instance $IID"; then
|
||||
PID="$(qs list --all 2>/dev/null | awk -v id="$IID" '
|
||||
/^Instance /{cur = $2}
|
||||
cur == id && /Process ID:/{print $3}')"
|
||||
[ -n "$PID" ] && kill "$PID" 2>/dev/null
|
||||
fi
|
||||
wait "$CLI_PID" 2>/dev/null
|
||||
|
||||
ERRORS="$(grep -aE '^[[:space:]]*ERROR' "$OUT" || true)"
|
||||
if [ -n "$ERRORS" ]; then
|
||||
echo
|
||||
echo "FAIL: QML errors detected"
|
||||
echo "$ERRORS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WARNINGS="$(grep -aE '^[[:space:]]*WARN' "$OUT" | sed -E 's/^[[:space:]]*WARN[[:space:]]*//' | sort | uniq -c | sort -rn || true)"
|
||||
echo
|
||||
if [ -n "$WARNINGS" ]; then
|
||||
echo "PASS: no errors (${DURATION}s boot). Non-fatal warnings:"
|
||||
echo "$WARNINGS"
|
||||
else
|
||||
echo "PASS: no errors or warnings (${DURATION}s boot)."
|
||||
fi
|
||||
exit 0
|
||||
Reference in New Issue
Block a user