new chatlistview component, file attachments, performance & primitive test

This commit is contained in:
2026-09-30 15:01:28 +02:00
parent 51ed270774
commit d8a3d5732e
36 changed files with 4368 additions and 310 deletions
+5 -2
View File
@@ -9,6 +9,7 @@ ScrollBar {
id: root id: root
required property Flickable flickable required property Flickable flickable
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
@@ -20,9 +21,11 @@ ScrollBar {
readonly property real nonAnimY: root.axisContentPos / root.axisContentSize readonly property real nonAnimY: root.axisContentPos / root.axisContentSize
readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight) 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) 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 readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0
property bool shouldBeActive
property bool inverted
enabled: !Visibilities.getForActive().isDrawing enabled: !Visibilities.getForActive().isDrawing
implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2
implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 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 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; root.position = newPos;
} }
+4 -1
View File
@@ -7,6 +7,7 @@ CustomListView {
id: root id: root
property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1
property bool fadeEnabled: true
property real fadeAmount: 0.1 property real fadeAmount: 0.1
property real fadeThreshold: 0.0 property real fadeThreshold: 0.0
readonly property bool horizontal: orientation === ListView.Horizontal readonly property bool horizontal: orientation === ListView.Horizontal
@@ -17,7 +18,9 @@ CustomListView {
} }
function fadeShouldBeActive(isStart: bool): bool { 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))) { if (contentSize() + marginStart() + marginEnd() < viewportSize() && rebound.running && ((isStart ? overshootStart() > 0 : overshootStart() < 0))) {
return false; return false;
} }
@@ -1,4 +1,7 @@
pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell
import QtQuick.Layouts import QtQuick.Layouts
import ZShell.Components import ZShell.Components
import ZShell.Config import ZShell.Config
@@ -11,7 +14,6 @@ Item {
id: root id: root
property ChatSession chatData property ChatSession chatData
property bool following: true
// qmlformat off // qmlformat off
readonly property var keys: [ readonly property var keys: [
Qt.Key_A, Qt.Key_A,
@@ -87,12 +89,33 @@ Item {
signal requestClose 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 { function send(text: string): void {
if (text.trim() === "") if (text.trim() === "" && root.pendingAttachments.length === 0)
return; return;
following = true; root.scrollToLatest();
chatData.sendMessage(text); chatData.sendMessage(text, root.pendingAttachments);
input.text = ""; input.text = "";
root.pendingAttachments = [];
}
function scrollToLatest(): void {
if (!listLoader.item || root.atNewestEnd)
return;
scrollAnim.stop();
scrollAnim.start();
} }
function focusInput(): void { 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 { RowLayout {
id: header id: header
@@ -151,41 +186,112 @@ Item {
anchors.topMargin: Tokens.spacing.medium anchors.topMargin: Tokens.spacing.medium
active: root.chatData && root.chatData.loaded active: root.chatData && root.chatData.loaded
sourceComponent: VerticalFadeListView { sourceComponent: Flickable {
id: list id: flick
property bool userScrolledUp: false anchors.fill: parent
property real lastCHeight: 0.0 clip: true
contentWidth: width
contentHeight: list.layoutHeight
function scrollToBottom(): void { transform: Rotation {
scrollAnim.start(); origin.y: flick.height / 2
origin.x: flick.width / 2
angle: 180
} }
cacheBuffer: Math.max(height * 20, 0) WheelInverter {
clip: true target: flick
fadeAmount: 0.05 }
fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel Anim {
spacing: 0 id: restoreAnim
rotation: 180
add: Transition { target: flick
Anim { type: Anim.DefaultEffects
from: -100 property: "contentY"
property: "y" }
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 { CustomScrollBar.vertical: CustomScrollBar {
id: scrollBar id: scrollBar
flickable: list flickable: flick
parent: list.parent parent: flick.parent
anchors.top: list.top anchors.top: flick.top
anchors.right: list.right anchors.right: flick.right
anchors.bottom: list.bottom anchors.bottom: flick.bottom
inverted: true
transform: Rotation { transform: Rotation {
origin.y: list.height / 2 origin.y: flick.height / 2
origin.x: scrollBar.width / 2 origin.x: scrollBar.width / 2
angle: 180 angle: 180
@@ -196,57 +302,15 @@ Item {
} }
} }
} }
delegate: MessageDelegate {
rotation: 180
rootParent: list
}
displaced: Transition { TransformWatcher {
Anim { id: tWatcher
property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
}
Component.onCompleted: { a: flick.contentItem
positionViewAtBeginning(); b: list
}
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
} }
} }
// Trigger the load even before we're ready to show the list.
Component.onCompleted: if (root.chatData) Component.onCompleted: if (root.chatData)
root.chatData.messagesModel root.chatData.messagesModel
} }
@@ -265,7 +329,7 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
implicitHeight: scrollToBottom.implicitHeight implicitHeight: scrollToBottom.implicitHeight
implicitWidth: scrollToBottom.implicitWidth 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 visible: scale > 0
Behavior on scale { Behavior on scale {
@@ -289,7 +353,180 @@ Item {
type: IconButton.Tonal type: IconButton.Tonal
onClicked: { 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.icon: "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall sendIcon.padding: Tokens.padding.extraSmall
Component.onCompleted: root.focusInput() Component.onCompleted: {
root.focusInput();
}
Keys.onPressed: e => { Keys.onPressed: e => {
if (e.key == Qt.Key_Return) { if (e.key == Qt.Key_Return) {
if (!(e.modifiers & Qt.ShiftModifier)) { if (!(e.modifiers & Qt.ShiftModifier)) {
@@ -318,6 +557,14 @@ Item {
e.accepted = true; 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) onSendPressed: root.send(text)
} }
@@ -16,10 +16,15 @@ CustomRect {
property bool copied: false property bool copied: false
property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
property color codeHeaderColor: Colors.palette.m3outline 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 property int highlightToken: 0
function refresh() { function refresh() {
requestedCode = root.code;
requestedLanguage = root.language;
const token = ++root.highlightToken; const token = ++root.highlightToken;
CodeHighlighter.highlight(root.code, root.language, root, token); CodeHighlighter.highlight(root.code, root.language, root, token);
} }
@@ -27,7 +32,11 @@ CustomRect {
function onHighlightSpans(token, spans) { function onHighlightSpans(token, spans) {
if (token !== root.highlightToken) if (token !== root.highlightToken)
return; 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) { function roleColor(kind) {
@@ -71,6 +80,21 @@ CustomRect {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
} }
// 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 { function highlightedHtml(code, spans): string {
let out; let out;
if (!spans.length) { if (!spans.length) {
@@ -101,10 +125,26 @@ CustomRect {
radius: Tokens.rounding.medium radius: Tokens.rounding.medium
onLanguageChanged: { onLanguageChanged: {
codeSpans = []; html = "";
refresh(); 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 { RowLayout {
id: headerRow id: headerRow
@@ -229,21 +269,6 @@ CustomRect {
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2 implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
color: CodeColors.active.bg 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 { Flickable {
id: codeFlick id: codeFlick
@@ -263,12 +288,11 @@ CustomRect {
color: CodeColors.active.normal color: CodeColors.active.normal
layer.enabled: true layer.enabled: true
leftInset: Tokens.padding.small
clip: false clip: false
font.family: Config.appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
textFormat: Text.RichText textFormat: root.colored ? Text.RichText : Text.PlainText
text: code.text text: root.colored ? root.html : root.plainForDisplay(root.code)
readOnly: true 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 id: equationImage
anchors.centerIn: parent anchors.centerIn: parent
source: math.imageUrl source: math.imageId.length ? "image://llmmath/" + math.imageId : ""
asynchronous: true
visible: math.ok visible: math.ok
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
smooth: true smooth: true
@@ -3,45 +3,50 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import Quickshell
import ZShell.Components
import ZShell.Config import ZShell.Config
import ZShell.Llm import ZShell.Llm
import qs.Modules.Notifications.Sidebar.Chat
import qs.Components import qs.Components
import qs.Services import qs.Services
MouseArea { MouseArea {
id: root id: root
property ChatGeneration current: modelData.activeGeneration
required property int index required property int index
readonly property bool isUser: modelData.role === ChatMessage.Role.User
required property Item rootParent required property Item rootParent
required property ChatMessage modelData required property ChatMessage modelData
property bool reasoningExpanded: false required property ChatListView chatList
readonly property var blocks: blockify(current.segments)
property real savedOffset: -1
property bool stickActive: false
function handleReasoningToggle(expanded: bool): void { readonly property bool isUser: modelData.role === ChatMessage.Role.User
const view = root.ListView.view; readonly property var blocks: blockify(current.segments, current.error)
if (!view)
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; return;
if (expanded) { if (expanded) {
root.savedOffset = view.contentY - root.y; root.savedOffset = root.rootParent.contentY - root.y;
root.reasoningExpanded = true; root.anchored = true;
chatList.anchorItem(block, ChatListView.TopEdge);
settleTimer.start();
} else { } else {
root.reasoningExpanded = false;
root.stickActive = false;
settleTimer.stop(); settleTimer.stop();
root.anchored = false;
chatList.clearAnchor();
if (!isNaN(root.savedOffset)) { if (!isNaN(root.savedOffset)) {
restoreAnim.to = root.y + root.savedOffset; chatList.requestRestore(root.y + root.savedOffset);
restoreAnim.restart();
root.savedOffset = NaN; root.savedOffset = NaN;
} }
} }
} }
function blockify(segs) { function blockify(segs, errored) {
const blocks = []; const blocks = [];
for (let i = 0; i < segs.length; i++) { for (let i = 0; i < segs.length; i++) {
const seg = segs[i]; const seg = segs[i];
@@ -65,35 +70,32 @@ MouseArea {
} }
} }
if (errored)
blocks.push({
kind: "error",
id: segs.length
});
return blocks; return blocks;
} }
implicitWidth: ListView.view.width
hoverEnabled: true hoverEnabled: true
preventStealing: false 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 { Timer {
id: settleTimer id: settleTimer
interval: 16
interval: Tokens.anim.durations.expressiveDefaultEffects
repeat: false
onTriggered: { onTriggered: {
root.stickActive = false; if (root.anchored) {
} root.anchored = false;
} chatList.clearAnchor();
}
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;
} }
} }
@@ -147,20 +149,144 @@ MouseArea {
} }
} }
Anim { GridView {
id: restoreAnim id: attachments
property: "contentY" readonly property int columns: ChatState.isWindow ? 5 : 3
target: root.ListView.view
type: Anim.DefaultEffects 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 { Column {
id: layout id: layout
anchors.top: parent.top anchors.top: attachments.bottom
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
Repeater { Repeater {
@@ -173,6 +299,17 @@ MouseArea {
delegate: DelegateChooser { delegate: DelegateChooser {
role: "kind" role: "kind"
DelegateChoice {
roleValue: "error"
delegate: ErrorBubble {
width: root.width
current: root.current
onDismiss: root.current.dismissError()
}
}
DelegateChoice { DelegateChoice {
roleValue: "process" roleValue: "process"
@@ -182,7 +319,7 @@ MouseArea {
current: root.current current: root.current
rootParent: root.rootParent 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 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)) readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s))
clip: true
implicitHeight: { implicitHeight: {
const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin; const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin;
const collapsed = layout.implicitHeight; const collapsed = layout.implicitHeight;
@@ -36,6 +35,8 @@ Item {
return collapsed; return collapsed;
} }
onCurrentChanged: expanded = false
Behavior on implicitHeight { Behavior on implicitHeight {
Anim { Anim {
type: Anim.DefaultEffects type: Anim.DefaultEffects
@@ -147,19 +148,30 @@ Item {
property bool open: false property bool open: false
property Item rootParent: root.rootParent 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 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 implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
z: open || dialogTransition.running ? 2 : 0
Layout.fillWidth: true Layout.fillWidth: true
function reparentWrapper(): void { function reparentWrapper(): void {
const newParent = open ? toolsBg.rootParent : toolsBg; 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.parent = newParent;
toolsWrapper.x = pos.x; toolsWrapper.x = c.x - toolsWrapper.width / 2;
toolsWrapper.y = pos.y; toolsWrapper.y = c.y - toolsWrapper.height / 2;
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height); }
onOpenChanged: {
if (open)
toolsBg.rootParent.interactive = false;
else
toolsBg.rootParent.interactive = true;
} }
BlobGroup { BlobGroup {
@@ -189,6 +201,8 @@ Item {
width: toolsBg.width width: toolsBg.width
height: toolBox.implicitHeight height: toolBox.implicitHeight
rotation: parent === toolsBg.rootParent ? 180 : 0
z: 1
states: State { states: State {
name: "open" name: "open"
@@ -200,11 +214,11 @@ Item {
toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topRightRadius: Tokens.rounding.largeIncreased toolsBackground.topRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased
// toolsContent.opacity: 1 toolsContent.opacity: 1
toolsWrapper.height: toolsBg.openHeight toolsWrapper.height: toolsBg.openHeight
toolsWrapper.width: toolsBg.openWidth toolsWrapper.width: toolsBg.openWidth
toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2 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 elevation.opacity: 1
toolBox.opacity: 0 toolBox.opacity: 0
} }
@@ -274,7 +288,6 @@ Item {
StateLayer { StateLayer {
onClicked: { onClicked: {
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
toolsBg.open = true; toolsBg.open = true;
} }
} }
@@ -287,7 +300,10 @@ Item {
anchors.margins: Tokens.padding.medium anchors.margins: Tokens.padding.medium
Repeater { Repeater {
model: root.current.pendingToolCalls ?? [] id: repeater
model: root.current.pendingToolCalls?.slice(0, 4) ?? []
delegate: RowLayout { delegate: RowLayout {
id: toolText id: toolText
@@ -309,10 +325,10 @@ Item {
return String(seg.arguments); return String(seg.arguments);
} }
CustomText { MaterialIcon {
color: Colors.palette.m3onPrimaryContainer color: Colors.palette.m3onPrimaryContainer
text: String(toolText.index + 1) text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet"
font.pointSize: Tokens.font.size.large fill: 1
} }
CustomText { 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 { ButtonRow {
id: confirmationRow
Layout.alignment: Qt.AlignRight Layout.alignment: Qt.AlignRight
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
@@ -366,7 +504,7 @@ Item {
} }
} }
CustomRect { CustomClippingRect {
id: expandedRect id: expandedRect
anchors.left: parent.left anchors.left: parent.left
+1 -1
View File
@@ -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) find_package(PkgConfig REQUIRED)
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
+6 -4
View File
@@ -1,11 +1,13 @@
qml_module(ZShell-components qml_module(ZShell-components
URI ZShell.Components URI ZShell.Components
SOURCES SOURCES
lazylistview.hpp lazylistview.cpp lazylistview.hpp lazylistview.cpp
chatlistview.hpp chatlistview.cpp
wavyline.hpp wavyline.cpp wavyline.hpp wavyline.cpp
buttonrow.hpp buttonrow.cpp buttonrow.hpp buttonrow.cpp
carouselview.hpp carouselview.cpp carouselview.hpp carouselview.cpp
wheelinverter.hpp wheelinverter.hpp
LIBRARIES LIBRARIES
Qt::Quick Qt::Quick
Qt::QuickPrivate
) )
File diff suppressed because it is too large Load Diff
+299
View File
@@ -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
+2 -1
View File
@@ -24,6 +24,7 @@ class Llm : public ConfigObject {
CFG_PROPERTY(QString, model, "") CFG_PROPERTY(QString, model, "")
CFG_PROPERTY(double, temperature, 0.7) CFG_PROPERTY(double, temperature, 0.7)
CFG_PROPERTY(bool, tools, true) CFG_PROPERTY(bool, tools, true)
CFG_PROPERTY(bool, sandbox, true)
CFG_PROPERTY(bool, enabled, true) CFG_PROPERTY(bool, enabled, true)
CONFIG_SUBOBJECT(LlmAppearance, appearance) CONFIG_SUBOBJECT(LlmAppearance, appearance)
@@ -32,4 +33,4 @@ class Llm : public ConfigObject {
: ConfigObject(parent), m_appearance(new LlmAppearance(this)) {} : ConfigObject(parent), m_appearance(new LlmAppearance(this)) {}
}; };
} // namespace ZShell::config }
+2
View File
@@ -285,6 +285,7 @@ file(WRITE "${LM_FONTS_HPP}" "${_lm_header}")
qml_module(ZShell-llm qml_module(ZShell-llm
URI ZShell.Llm URI ZShell.Llm
SOURCES SOURCES
attachment.hpp attachment.cpp
chat.hpp chat.cpp chat.hpp chat.cpp
chatstore.hpp chatstore.cpp chatstore.hpp chatstore.cpp
codehighlighter.hpp codehighlighter.cpp codehighlighter.hpp codehighlighter.cpp
@@ -298,6 +299,7 @@ qml_module(ZShell-llm
messagemodel.hpp messagemodel.cpp messagemodel.hpp messagemodel.cpp
segment.hpp segment.cpp segment.hpp segment.cpp
session.hpp session.cpp session.hpp session.cpp
shelltool.hpp shelltool.cpp
tool.hpp tool.cpp tool.hpp tool.cpp
webfetchtool.hpp webfetchtool.cpp webfetchtool.hpp webfetchtool.cpp
LIBRARIES LIBRARIES
+19
View File
@@ -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
+48
View File
@@ -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
View File
@@ -4,6 +4,7 @@
#include "filetool.hpp" #include "filetool.hpp"
#include "llm.hpp" #include "llm.hpp"
#include "llmclient.hpp" #include "llmclient.hpp"
#include "shelltool.hpp"
#include "webfetchtool.hpp" #include "webfetchtool.hpp"
#include <QDebug> #include <QDebug>
@@ -19,12 +20,14 @@ Chat::Chat(QObject* parent)
m_store->setLlmClient(m_client); m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
m_client->tools()->registerTool(new FileReadTool(m_client->tools())); 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(); const auto* llm = config::Config::instance()->llm();
m_client->setEndpoint(llm->endpoint()); m_client->setEndpoint(llm->endpoint());
m_client->setModel(llm->model()); m_client->setModel(llm->model());
m_client->setTemperature(llm->temperature()); m_client->setTemperature(llm->temperature());
m_client->setToolsEnabled(llm->tools()); m_client->setToolsEnabled(llm->tools());
m_client->setSandboxEnabled(llm->sandbox());
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
m_client->setEndpoint(llm->endpoint()); m_client->setEndpoint(llm->endpoint());
@@ -38,11 +41,16 @@ Chat::Chat(QObject* parent)
connect(llm, &config::Llm::toolsChanged, this, [this, llm]() { connect(llm, &config::Llm::toolsChanged, this, [this, llm]() {
m_client->setToolsEnabled(llm->tools()); 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]() { connect(m_client, &LlmClient::busyChanged, this, [this]() {
if (m_client->busy() && !m_lastError.isEmpty()) { if (m_client->busy() && (m_hasError || !m_errorMessage.isEmpty())) {
m_lastError.clear(); m_hasError = false;
Q_EMIT lastErrorChanged(); m_errorMessage.clear();
Q_EMIT errorChanged();
} }
Q_EMIT busyChanged(); Q_EMIT busyChanged();
}); });
@@ -73,10 +81,16 @@ Chat::Chat(QObject* parent)
&LlmClient::errorOccurred, &LlmClient::errorOccurred,
this, this,
[this](const QString& message) { [this](const QString& message) {
m_lastError = message; m_hasError = !message.isEmpty();
Q_EMIT lastErrorChanged(); m_errorMessage = message;
Q_EMIT errorChanged();
Q_EMIT errorOccurred(message); Q_EMIT errorOccurred(message);
}); });
connect(
m_client,
&LlmClient::serverErrorChanged,
this,
&Chat::serverErrorChanged);
connect( connect(
m_store, m_store,
&ChatStore::sessionRemoved, &ChatStore::sessionRemoved,
@@ -134,11 +148,24 @@ void Chat::setToolsEnabled(bool value) {
config->llm()->set_tools(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 { QString Chat::streamingChatId() const {
return m_client->streamingChatId(); return m_client->streamingChatId();
} }
Chat* Chat::s_instance = nullptr; Chat* Chat::s_instance = nullptr;
Chat* Chat::create(QQmlEngine*, QJSEngine*) { Chat* Chat::create(QQmlEngine*, QJSEngine*) {
@@ -151,9 +178,10 @@ void Chat::stop() {
} }
void Chat::dismissError() { void Chat::dismissError() {
if (m_lastError.isEmpty()) return; if (!m_hasError && m_errorMessage.isEmpty()) return;
m_lastError.clear(); m_hasError = false;
Q_EMIT lastErrorChanged(); m_errorMessage.clear();
Q_EMIT errorChanged();
} }
void Chat::refreshModels() { void Chat::refreshModels() {
@@ -161,7 +189,7 @@ void Chat::refreshModels() {
} }
void Chat::refreshFromServer() { void Chat::refreshFromServer() {
m_client->refreshFromServer(); m_client->refreshAllStates();
} }
void Chat::selectModel(const QString& id) { void Chat::selectModel(const QString& id) {
+16 -4
View File
@@ -29,7 +29,12 @@ class Chat : public QObject {
Q_PROPERTY( Q_PROPERTY(
bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY
toolsEnabledChanged) 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(ZShell::llm::ChatStore* chats READ chats CONSTANT)
Q_PROPERTY( Q_PROPERTY(
QString streamingChatId READ streamingChatId NOTIFY QString streamingChatId READ streamingChatId NOTIFY
@@ -45,7 +50,11 @@ class Chat : public QObject {
[[nodiscard]] int contextSize() const; [[nodiscard]] int contextSize() const;
[[nodiscard]] bool toolsEnabled() const; [[nodiscard]] bool toolsEnabled() const;
void setToolsEnabled(bool value); 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]] ChatStore* chats() const { return m_store; }
[[nodiscard]] QString streamingChatId() const; [[nodiscard]] QString streamingChatId() const;
@@ -64,14 +73,17 @@ class Chat : public QObject {
void availableModelsChanged(); void availableModelsChanged();
void contextSizeChanged(); void contextSizeChanged();
void toolsEnabledChanged(); void toolsEnabledChanged();
void sandboxEnabledChanged();
void errorOccurred(const QString& message); void errorOccurred(const QString& message);
void lastErrorChanged(); void errorChanged();
void serverErrorChanged();
void streamingChatIdChanged(); void streamingChatIdChanged();
private: private:
ChatStore* m_store = nullptr; ChatStore* m_store = nullptr;
LlmClient* m_client = nullptr; LlmClient* m_client = nullptr;
QString m_lastError; bool m_hasError = false;
QString m_errorMessage;
static Chat* s_instance; static Chat* s_instance;
}; };
+96
View File
@@ -65,10 +65,19 @@ struct GenerationRow {
QVector<SegmentRow> segments; QVector<SegmentRow> segments;
}; };
struct AttachmentRow {
QString name;
QString path;
int kind = 1;
QString mime;
qint64 size = 0;
};
struct MessageRow { struct MessageRow {
bool user = false; bool user = false;
qint64 timestamp = 0; qint64 timestamp = 0;
QVector<GenerationRow> generations; QVector<GenerationRow> generations;
QVector<AttachmentRow> attachments;
}; };
} // namespace } // namespace
@@ -160,6 +169,20 @@ void ChatStore::openDb() {
" timestamp INTEGER NOT NULL\n" " 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); QSqlQuery query(db);
query.exec(QStringLiteral( query.exec(QStringLiteral(
@@ -171,6 +194,9 @@ void ChatStore::openDb() {
query.exec(QStringLiteral( query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_segments_generation " "CREATE INDEX IF NOT EXISTS idx_segments_generation "
"ON segments (generation_id)")); "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.bindValue(":id", session->id());
query.exec(); query.exec();
} }
QDir(ChatSession::sandboxDirFor(session->id())).removeRecursively();
m_sessions.removeOne(session); m_sessions.removeOne(session);
session->deleteLater(); session->deleteLater();
notify(before); notify(before);
@@ -330,6 +357,11 @@ bool ChatStore::saveSession(ChatSession* session) {
"timestamp) VALUES (:gid, :type, :text, :name, " "timestamp) VALUES (:gid, :type, :text, :name, "
":tool_call_id, :arguments, :result, :status, :elapsed_ms, " ":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
":timestamp)"); ":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(); const auto* model = session->messagesModel();
for (int row = model->rowCount() - 1; ok && row >= 0; --row) { for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
const auto* message = model->at(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()) { if (!ok || !handle.commit()) {
@@ -497,6 +549,34 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
<< messageId << ":" << messageId << ":"
<< generationQuery.lastError().text(); << 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); rows.append(message);
} }
} }
@@ -523,6 +603,22 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
row.user ? ChatMessage::Role::User row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant, : ChatMessage::Role::Assistant,
row.timestamp); 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; int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) { for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow = const GenerationRow& generationRow =
+4 -4
View File
@@ -198,7 +198,7 @@ const char* CodeHighlighter::roleName(uint8_t role) {
QString CodeHighlighter::resolveId(const QString& language) { QString CodeHighlighter::resolveId(const QString& language) {
const QString tag = language.trimmed().toLower(); const QString tag = language.trimmed().toLower();
const QString alias = aliases().value(tag); 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) { QString CodeHighlighter::cacheKey(const QString& id, const QString& code) {
@@ -223,8 +223,8 @@ void CodeHighlighter::storeSpans(
const QString& language, const QString& language,
const QVariantList& spans) const { const QVariantList& spans) const {
if (spans.isEmpty() || code.isEmpty()) return; if (spans.isEmpty() || code.isEmpty()) return;
static constexpr int kMaxEntries = 32; static constexpr int kMaxEntries = 64;
static constexpr int kMaxBytes = 1024 * 1024; static constexpr int kMaxBytes = 2 * 1024 * 1024;
const QString key = cacheKey(resolveId(language), code); const QString key = cacheKey(resolveId(language), code);
const int bytes = static_cast<int>(code.toUtf8().size()); const int bytes = static_cast<int>(code.toUtf8().size());
QMutexLocker locker(&m_cacheMutex); QMutexLocker locker(&m_cacheMutex);
@@ -369,7 +369,7 @@ QVariantList CodeHighlighter::doHighlight(
if (c < 0x80) if (c < 0x80)
cu[b + 1] += 1; cu[b + 1] += 1;
else if (c < 0xC0) else if (c < 0xC0)
; // continuation byte ;
else if (c < 0xF0) else if (c < 0xF0)
cu[b + 1] += 1; cu[b + 1] += 1;
else else
+15
View File
@@ -176,6 +176,21 @@ void ChatGeneration::appendReasoning(const QString& piece) {
openReasoningSegment()->appendText(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) { void ChatGeneration::setStreaming(bool value) {
if (m_streaming == value) return; if (m_streaming == value) return;
m_streaming = value; m_streaming = value;
+9
View File
@@ -21,6 +21,8 @@ class ChatGeneration : public QObject {
Q_PROPERTY( Q_PROPERTY(
QString content READ content WRITE setContent NOTIFY contentChanged) QString content READ content WRITE setContent NOTIFY contentChanged)
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) 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( Q_PROPERTY(
qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
elapsedMsChanged) elapsedMsChanged)
@@ -48,6 +50,8 @@ class ChatGeneration : public QObject {
[[nodiscard]] QString content() const; [[nodiscard]] QString content() const;
[[nodiscard]] QString reasoning() const; [[nodiscard]] QString reasoning() const;
[[nodiscard]] bool error() const { return m_hasError; }
[[nodiscard]] QString errorMessage() const { return m_errorMessage; }
[[nodiscard]] bool reasoningActive() const; [[nodiscard]] bool reasoningActive() const;
[[nodiscard]] qint64 reasoningElapsedMs() const; [[nodiscard]] qint64 reasoningElapsedMs() const;
[[nodiscard]] qint64 contentElapsedMs() const; [[nodiscard]] qint64 contentElapsedMs() const;
@@ -65,11 +69,13 @@ class ChatGeneration : public QObject {
Q_INVOKABLE void approveTools(); Q_INVOKABLE void approveTools();
Q_INVOKABLE void denyTools(); Q_INVOKABLE void denyTools();
Q_INVOKABLE void dismissError();
void setContent(const QString& value); void setContent(const QString& value);
void appendContent(const QString& piece); void appendContent(const QString& piece);
void appendReasoning(const QString& piece); void appendReasoning(const QString& piece);
void setStreaming(bool value); void setStreaming(bool value);
void setError(const QString& value);
[[nodiscard]] LlmSegment* openContentSegment(); [[nodiscard]] LlmSegment* openContentSegment();
[[nodiscard]] LlmSegment* openReasoningSegment(); [[nodiscard]] LlmSegment* openReasoningSegment();
@@ -81,6 +87,7 @@ class ChatGeneration : public QObject {
Q_SIGNALS: Q_SIGNALS:
void contentChanged(); void contentChanged();
void reasoningChanged(); void reasoningChanged();
void errorChanged();
void reasoningActiveChanged(); void reasoningActiveChanged();
void elapsedMsChanged(); void elapsedMsChanged();
void streamingChanged(); void streamingChanged();
@@ -98,6 +105,8 @@ class ChatGeneration : public QObject {
bool m_streaming = false; bool m_streaming = false;
bool m_approvalPending = false; bool m_approvalPending = false;
qint64 m_timestamp; qint64 m_timestamp;
bool m_hasError = false;
QString m_errorMessage;
}; };
} // namespace ZShell::llm } // namespace ZShell::llm
+219 -57
View File
@@ -1,5 +1,6 @@
#include "llmclient.hpp" #include "llmclient.hpp"
#include "attachment.hpp"
#include "generation.hpp" #include "generation.hpp"
#include "message.hpp" #include "message.hpp"
#include "messagemodel.hpp" #include "messagemodel.hpp"
@@ -7,6 +8,7 @@
#include "session.hpp" #include "session.hpp"
#include <QDebug> #include <QDebug>
#include <QFile>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
@@ -132,26 +134,30 @@ QString LlmClient::completionsPath(
return base + subpath; 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( QString LlmClient::serverErrorMessage(
const QByteArray& body, const QString& fallback) { const QByteArray& body, const QString& fallback) {
const QJsonDocument doc = QJsonDocument::fromJson(body); const QString message = jsonErrorMessage(body);
if (doc.isObject()) { if (!message.isEmpty()) return message;
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();
}
}
}
return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed") return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed")
: fallback; : fallback;
} }
void LlmClient::setServerError(const QString& value) {
if (m_serverError == value) return;
m_serverError = value;
Q_EMIT serverErrorChanged();
}
void LlmClient::setBusy(bool value) { void LlmClient::setBusy(bool value) {
if (m_busy == value) return; if (m_busy == value) return;
m_busy = value; m_busy = value;
@@ -175,14 +181,17 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) {
m_refreshTimer.setParent(this); m_refreshTimer.setParent(this);
m_refreshTimer.setInterval(kRefreshIntervalMs); m_refreshTimer.setInterval(kRefreshIntervalMs);
connect( connect(
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer); &m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshAllStates);
m_refreshTimer.start(); m_refreshTimer.start();
} }
LlmClient::~LlmClient() { LlmClient::~LlmClient() {
if (m_reply) m_reply->abort(); if (m_reply) {
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
}
m_tools->cancelAll(); m_tools->cancelAll();
endStream();
} }
void LlmClient::setEndpoint(const QString& value) { void LlmClient::setEndpoint(const QString& value) {
@@ -190,7 +199,7 @@ void LlmClient::setEndpoint(const QString& value) {
m_endpoint = value; m_endpoint = value;
Q_EMIT endpointChanged(); Q_EMIT endpointChanged();
m_refreshTimer.start(); m_refreshTimer.start();
refreshFromServer(); refreshAllStates();
} }
void LlmClient::setModel(const QString& value) { void LlmClient::setModel(const QString& value) {
@@ -204,9 +213,16 @@ void LlmClient::setTemperature(double value) {
m_temperature = 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) { void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
if (m_busy || !session || !target) return; if (m_busy || !session || !target) return;
refreshModels();
m_contextRetryUsed = false; m_contextRetryUsed = false;
m_active = session; m_active = session;
m_streaming = target; m_streaming = target;
@@ -242,6 +258,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
probeContextSize([this, session, target]() { probeContextSize([this, session, target]() {
if (m_active != session || m_streaming != target) return; if (m_active != session || m_streaming != target) return;
refreshModels();
sendRound(); sendRound();
}); });
} }
@@ -253,6 +270,7 @@ void LlmClient::sendRound() {
m_callBuilders.clear(); m_callBuilders.clear();
m_callResults.clear(); m_callResults.clear();
m_roundDone = false; m_roundDone = false;
m_streamDone = false;
m_toolPhase = false; m_toolPhase = false;
m_pendingCalls = 0; m_pendingCalls = 0;
m_buffer.clear(); m_buffer.clear();
@@ -308,6 +326,8 @@ void LlmClient::sendRound() {
} }
} }
appendSandboxPreamble(messages);
QJsonObject body; QJsonObject body;
QJsonObject streamOptions; QJsonObject streamOptions;
streamOptions[QStringLiteral("include_usage")] = true; streamOptions[QStringLiteral("include_usage")] = true;
@@ -323,15 +343,24 @@ void LlmClient::sendRound() {
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Accept", "text/event-stream"); 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]() { connect(reply, &QNetworkReply::readyRead, this, [this, reply]() {
if (m_reply) m_buffer.append(m_reply->readAll()); if (m_reply != reply) return;
m_buffer.append(reply->readAll());
drainBuffer(); drainBuffer();
}); });
connect(m_reply, &QNetworkReply::finished, this, [this]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
QNetworkReply* reply = m_reply; if (m_reply != reply) {
if (!reply) return; reply->deleteLater();
return;
}
m_reply = nullptr; m_reply = nullptr;
const QNetworkReply::NetworkError error = reply->error(); const QNetworkReply::NetworkError error = reply->error();
@@ -343,25 +372,37 @@ void LlmClient::sendRound() {
drainBuffer(); drainBuffer();
if (!m_streaming) return; if (!m_streaming) return;
if (error == QNetworkReply::NoError) if (error == QNetworkReply::NoError) {
roundFinished(); if (m_streamDone) {
else if (error == QNetworkReply::OperationCanceledError) roundFinished();
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();
return; 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* message = model->at(row);
const auto* generation = message->activeGeneration(); const auto* generation = message->activeGeneration();
if (!generation) continue; 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; ContextUnit unit;
if (message->role() == ChatMessage::Role::User) { if (message->role() == ChatMessage::Role::User) {
QJsonObject user; QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user"); user[QStringLiteral("role")] = QStringLiteral("user");
user[QStringLiteral("content")] = generation->content(); 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); unit.messages.append(user);
} else { } else {
QList<const LlmSegment*> toolSegments; QList<const LlmSegment*> toolSegments;
@@ -455,6 +535,48 @@ QJsonArray LlmClient::buildContextMessages(
return messages; 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) { void LlmClient::applyToolCallDelta(const QJsonObject& call) {
ChatGeneration* streaming = m_streaming; ChatGeneration* streaming = m_streaming;
if (!streaming) return; if (!streaming) return;
@@ -688,10 +810,12 @@ void LlmClient::stop() {
finishTurn(); finishTurn();
return; return;
} }
if (m_reply) if (m_reply) {
m_reply->abort(); QNetworkReply* reply = m_reply;
else m_reply = nullptr;
endStream(); reply->abort();
}
endStream();
} }
void LlmClient::endStream() { void LlmClient::endStream() {
@@ -700,6 +824,11 @@ void LlmClient::endStream() {
ChatSession* session = m_active; ChatSession* session = m_active;
m_streaming = nullptr; m_streaming = nullptr;
m_active = nullptr; m_active = nullptr;
if (m_reply) {
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
}
generation->setStreaming(false); generation->setStreaming(false);
m_approvalPending = false; m_approvalPending = false;
generation->setApprovalPending(false); generation->setApprovalPending(false);
@@ -708,7 +837,7 @@ void LlmClient::endStream() {
segment->status() == LlmSegment::Status::Pending) segment->status() == LlmSegment::Status::Pending)
segment->finishTool(QStringLiteral("Cancelled"), false); segment->finishTool(QStringLiteral("Cancelled"), false);
if (generation->content().isEmpty() && generation->reasoning().isEmpty() && if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
generation->toolCallCount() == 0) { generation->toolCallCount() == 0 && !generation->error()) {
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) { if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
if (message->generationCount() <= 1) { if (message->generationCount() <= 1) {
if (session) session->removeMessage(message); if (session) session->removeMessage(message);
@@ -730,7 +859,7 @@ void LlmClient::finishTurn() {
m_pendingClear.clear(); m_pendingClear.clear();
} }
if (session) session->persist(); if (session) session->persist();
refreshFromServer(); refreshAllStates();
} }
void LlmClient::clearOnFinish(ChatSession* session) { void LlmClient::clearOnFinish(ChatSession* session) {
@@ -747,6 +876,7 @@ void LlmClient::sessionRemoved(ChatSession* session) {
void LlmClient::fail(const QString& message) { void LlmClient::fail(const QString& message) {
qWarning() << "LlmClient:" << message; qWarning() << "LlmClient:" << message;
if (ChatGeneration* streaming = m_streaming) streaming->setError(message);
finishTurn(); finishTurn();
Q_EMIT errorOccurred(message); Q_EMIT errorOccurred(message);
} }
@@ -766,6 +896,7 @@ void LlmClient::handleLine(const QByteArray& line) {
const QByteArray data = line.mid(5).trimmed(); const QByteArray data = line.mid(5).trimmed();
if (data == "[DONE]") { if (data == "[DONE]") {
m_streamDone = true;
roundFinished(); roundFinished();
return; return;
} }
@@ -776,8 +907,12 @@ void LlmClient::handleLine(const QByteArray& line) {
updateTokenUsage(obj); updateTokenUsage(obj);
if (obj.contains("error")) { if (obj.contains("error")) {
const QJsonObject error = obj["error"].toObject(); const QJsonValue errorValue = obj["error"];
const QString message = error["message"].toString(); QString message;
if (errorValue.isObject())
message = errorValue.toObject()["message"].toString();
else if (errorValue.isString())
message = errorValue.toString();
fail( fail(
message.isEmpty() ? QStringLiteral("LLM server returned an error") message.isEmpty() ? QStringLiteral("LLM server returned an error")
: message); : message);
@@ -805,25 +940,36 @@ void LlmClient::handleLine(const QByteArray& line) {
} }
} }
void LlmClient::refreshFromServer() { void LlmClient::refreshAllStates() {
if (m_endpoint.trimmed().isEmpty()) return; if (m_endpoint.trimmed().isEmpty()) {
probeContextSize(); setServerError(QStringLiteral("LLM endpoint is not configured"));
refreshModels(); return;
}
probeContextSize([this]() { refreshModels(); });
} }
void LlmClient::refreshModels() { void LlmClient::refreshModels() {
const QUrl url = const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
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)); auto* reply = m_manager.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QNetworkReply::NetworkError error = reply->error(); const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
const QByteArray data = reply->readAll(); const QByteArray data = reply->readAll();
reply->deleteLater(); reply->deleteLater();
if (error != QNetworkReply::NoError) { 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; return;
} }
const QJsonArray dataArr = const QJsonArray dataArr =
@@ -838,7 +984,12 @@ void LlmClient::refreshModels() {
models.append(id); 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; m_availableModels = models;
Q_EMIT availableModelsChanged(); Q_EMIT availableModelsChanged();
@@ -866,6 +1017,8 @@ void LlmClient::probeContextSize(std::function<void()> done) {
base.chop(1); base.chop(1);
const QUrl url = QUrl::fromUserInput(base + "/props"); const QUrl url = QUrl::fromUserInput(base + "/props");
if (!url.isValid() || url.host().isEmpty()) { if (!url.isValid() || url.host().isEmpty()) {
setServerError(
QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint));
if (done) done(); if (done) done();
return; return;
} }
@@ -877,11 +1030,13 @@ void LlmClient::probeContextSize(std::function<void()> done) {
*settled = true; *settled = true;
const QNetworkReply::NetworkError error = reply->error(); const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
const QByteArray data = reply->readAll(); const QByteArray data = reply->readAll();
reply->deleteLater(); reply->deleteLater();
int size = 0; int size = 0;
if (error == QNetworkReply::NoError) { if (error == QNetworkReply::NoError) {
setServerError(QString());
const QJsonDocument doc = QJsonDocument::fromJson(data); const QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isArray()) { if (doc.isArray()) {
for (const auto& value : doc.array()) { for (const auto& value : doc.array()) {
@@ -899,6 +1054,13 @@ void LlmClient::probeContextSize(std::function<void()> done) {
.toObject()["n_ctx"] .toObject()["n_ctx"]
.toInt(0); .toInt(0);
} }
} else {
const QString serverMessage = jsonErrorMessage(data);
setServerError(
serverMessage.isEmpty()
? QStringLiteral("Cannot reach LLM server: %1")
.arg(errorString)
: serverMessage);
} }
if (size > 0) if (size > 0)
setContextSize(size); setContextSize(size);
+12 -1
View File
@@ -39,16 +39,20 @@ class LlmClient : public QObject {
} }
[[nodiscard]] int contextSize() const { return m_contextSize; } [[nodiscard]] int contextSize() const { return m_contextSize; }
[[nodiscard]] ToolRegistry* tools() const { return m_tools; } [[nodiscard]] ToolRegistry* tools() const { return m_tools; }
[[nodiscard]] QString serverError() const { return m_serverError; }
void setEndpoint(const QString& value); void setEndpoint(const QString& value);
void setModel(const QString& value); void setModel(const QString& value);
void setTemperature(double value); void setTemperature(double value);
void setContextSize(int size); void setContextSize(int size);
void probeContextSize(std::function<void()> done = {}); void probeContextSize(std::function<void()> done = {});
Q_INVOKABLE void refreshFromServer(); void refreshAllStates();
[[nodiscard]] bool busy() const { return m_busy; } [[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
void setToolsEnabled(bool value) { m_tools->setEnabled(value); } 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]] QString streamingChatId() const { return m_streamingChatId; }
[[nodiscard]] ChatSession* streamingSession() const { return m_active; } [[nodiscard]] ChatSession* streamingSession() const { return m_active; }
@@ -74,6 +78,7 @@ class LlmClient : public QObject {
void toolsEnabledChanged(); void toolsEnabledChanged();
void streamingChatIdChanged(); void streamingChatIdChanged();
void errorOccurred(const QString& message); void errorOccurred(const QString& message);
void serverErrorChanged();
void titleSuggested(ZShell::llm::ChatSession* session, const QString& title); void titleSuggested(ZShell::llm::ChatSession* session, const QString& title);
void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon); void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon);
@@ -89,6 +94,7 @@ class LlmClient : public QObject {
void sendRound(); void sendRound();
QJsonArray buildContextMessages( QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const; ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
void appendSandboxPreamble(QJsonArray& messages);
void applyToolCallDelta(const QJsonObject& call); void applyToolCallDelta(const QJsonObject& call);
void roundFinished(); void roundFinished();
void executeAllCalls(); void executeAllCalls();
@@ -102,6 +108,7 @@ class LlmClient : public QObject {
void updateTokenUsage(const QJsonObject& data); void updateTokenUsage(const QJsonObject& data);
void setBusy(bool value); void setBusy(bool value);
void setStreamingChatId(const QString& id); void setStreamingChatId(const QString& id);
void setServerError(const QString& value);
void shortRequest( void shortRequest(
const QString& tag, const QString& tag,
const QString& systemPrompt, const QString& systemPrompt,
@@ -109,6 +116,7 @@ class LlmClient : public QObject {
std::function<void(QString result)> onResult); std::function<void(QString result)> onResult);
static QString completionsPath( static QString completionsPath(
const QString& endpoint, const QString& subpath); const QString& endpoint, const QString& subpath);
static QString jsonErrorMessage(const QByteArray& body);
static QString serverErrorMessage( static QString serverErrorMessage(
const QByteArray& body, const QString& fallback); const QByteArray& body, const QString& fallback);
@@ -125,6 +133,7 @@ class LlmClient : public QObject {
QString m_endpoint; QString m_endpoint;
QString m_model; QString m_model;
QStringList m_availableModels; QStringList m_availableModels;
QString m_serverError;
double m_temperature = 0.7; double m_temperature = 0.7;
int m_contextSize = 0; int m_contextSize = 0;
@@ -139,7 +148,9 @@ class LlmClient : public QObject {
int m_round = 0; int m_round = 0;
qsizetype m_contentMark = 0; qsizetype m_contentMark = 0;
qsizetype m_reasoningMark = 0; qsizetype m_reasoningMark = 0;
bool m_sandboxEnabled = true;
bool m_roundDone = false; bool m_roundDone = false;
bool m_streamDone = false;
bool m_toolPhase = false; bool m_toolPhase = false;
bool m_approvalPending = false; bool m_approvalPending = false;
bool m_contextRetryUsed = false; bool m_contextRetryUsed = false;
+1 -6
View File
@@ -11,12 +11,7 @@ class LlmMarkdown : public QObject {
QML_UNCREATABLE("Blocks are produced by LlmSegment") QML_UNCREATABLE("Blocks are produced by LlmSegment")
public: public:
enum class Type : int { enum class Type : int { Text = 0, Heading, Code, Math };
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)
};
Q_ENUM(Type) Q_ENUM(Type)
explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {} explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {}
+182 -42
View File
@@ -10,9 +10,14 @@
#include <QFile> #include <QFile>
#include <QFontDatabase> #include <QFontDatabase>
#include <QHash> #include <QHash>
#include <QMutex>
#include <QMutexLocker>
#include <QPainter>
#include <QPointer> #include <QPointer>
#include <QThreadPool> #include <QThreadPool>
#include <QtQuick/QQuickImageProvider>
namespace ZShell::llm { namespace ZShell::llm {
namespace { namespace {
@@ -71,26 +76,27 @@ const LatinModern& loadLatinModern() {
return fonts; return fonts;
} }
} // namespace void configureLatinModern(JKQTMathText& renderer) {
LlmMathText::LlmMathText(QObject* parent)
: QObject(parent)
, m_renderer(
std::make_shared<JKQTMathText>(nullptr, /* useFontsForGUI */ true)) {
const LatinModern& fonts = loadLatinModern(); const LatinModern& fonts = loadLatinModern();
if (fonts.roman) if (fonts.roman)
m_renderer->setFontRomanAndMath( renderer.setFontRomanAndMath(
QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode); QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode);
if (fonts.math) { if (fonts.math) {
m_renderer->setFontMathRoman( renderer.setFontMathRoman(
QStringLiteral("Latin Modern Math"), QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode); JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFallbackFontSymbols( renderer.setFallbackFontSymbols(
QStringLiteral("Latin Modern Math"), QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode); JKQTMathTextFontEncoding::MTFEUnicode);
} }
} }
} // namespace
LlmMathText::LlmMathText(QObject* parent) : QObject(parent) {
loadLatinModern();
}
void LlmMathText::setLatex(const QString& value) { void LlmMathText::setLatex(const QString& value) {
if (m_latex == value) return; if (m_latex == value) return;
m_latex = value; m_latex = value;
@@ -120,23 +126,88 @@ namespace {
struct MathRender { struct MathRender {
bool ok = false; bool ok = false;
QImage image; QImage image;
QUrl url; QString id;
qreal width = 0; qreal width = 0;
qreal height = 0; qreal height = 0;
}; };
QHash<QString, MathRender>& mathCache() { struct MathStore {
static QHash<QString, MathRender> cache; QMutex mutex;
return cache; 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 } // namespace
void LlmMathText::reRender() { void LlmMathText::reRender() {
ensureImageProvider(this);
++m_requestId; ++m_requestId;
if (m_latex.trimmed().isEmpty()) { if (m_latex.trimmed().isEmpty()) {
m_image = QImage(); releaseMathId(m_imageId);
m_imageUrl = QUrl(); m_imageId.clear();
m_width = 0; m_width = 0;
m_height = 0; m_height = 0;
m_ok = false; m_ok = false;
@@ -147,12 +218,30 @@ void LlmMathText::reRender() {
const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() + const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() +
QLatin1Char(0x1f) + QString::number(m_fontPointSize) + QLatin1Char(0x1f) + QString::number(m_fontPointSize) +
QLatin1Char(0x1f) + QString::number(m_devicePixelRatio); QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
if (auto it = mathCache().find(key); it != mathCache().end()) { bool cacheHit = false;
m_image = it->image; QString hitId;
m_imageUrl = it->url; qreal hitW = 0;
m_width = it->width; qreal hitH = 0;
m_height = it->height; bool hitOk = false;
m_ok = it->ok; {
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(); Q_EMIT changed();
return; return;
} }
@@ -164,33 +253,46 @@ void LlmMathText::reRender() {
const QColor color = m_color; const QColor color = m_color;
const double pointSize = m_fontPointSize; const double pointSize = m_fontPointSize;
const qreal dpr = m_devicePixelRatio; const qreal dpr = m_devicePixelRatio;
auto renderer = m_renderer;
QThreadPool::globalInstance()->start( 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; MathRender render;
renderer->setFontPointSize(pointSize); renderer.setFontPointSize(pointSize);
renderer->setFontColor(color); renderer.setFontColor(color);
if (renderer->parse( if (renderer.parse(
latex, latex,
JKQTMathText::LatexParser, JKQTMathText::LatexParser,
JKQTMathText::DefaultParseOptions)) { 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), QColor(Qt::transparent),
kRenderMargin, kRenderMargin,
dpr, dpr,
kResolutionDpi); kResolutionDpi);
if (!image.isNull()) { if (!image.isNull()) {
QByteArray png;
{
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
}
render.image = image; render.image = image;
render.url = QUrl(
QStringLiteral("data:image/png;base64,") +
QString::fromLatin1(png.toBase64()));
render.width = image.width() / dpr; render.width = image.width() / dpr;
render.height = image.height() / dpr; render.height = image.height() / dpr;
render.ok = true; render.ok = true;
@@ -208,12 +310,46 @@ void LlmMathText::reRender() {
return; return;
} }
if (render.ok) { if (render.ok) {
auto& cache = mathCache(); auto& store = mathStore();
if (cache.size() >= kCacheLimit) cache.clear(); QMutexLocker locker(&store.mutex);
cache.insert(key, render); 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; releaseMathId(self->m_imageId);
self->m_imageUrl = render.url; retainMathId(render.id);
self->m_imageId = render.id;
self->m_width = render.width; self->m_width = render.width;
self->m_height = render.height; self->m_height = render.height;
self->m_ok = render.ok; self->m_ok = render.ok;
@@ -223,4 +359,8 @@ void LlmMathText::reRender() {
}); });
} }
LlmMathText::~LlmMathText() {
releaseMathId(m_imageId);
}
} // namespace ZShell::llm } // namespace ZShell::llm
+4 -13
View File
@@ -1,16 +1,10 @@
#pragma once #pragma once
#include <QColor> #include <QColor>
#include <QImage>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QUrl>
#include <QtQml> #include <QtQml>
#include <memory>
#include <jkqtmathtext/jkqtmathtext.h>
namespace ZShell::llm { namespace ZShell::llm {
class LlmMathText : public QObject { class LlmMathText : public QObject {
@@ -25,14 +19,14 @@ class LlmMathText : public QObject {
Q_PROPERTY( Q_PROPERTY(
qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio
NOTIFY changed) NOTIFY changed)
Q_PROPERTY(QImage image READ image NOTIFY changed) Q_PROPERTY(QString imageId READ imageId NOTIFY changed)
Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed)
Q_PROPERTY(qreal width READ width NOTIFY changed) Q_PROPERTY(qreal width READ width NOTIFY changed)
Q_PROPERTY(qreal height READ height NOTIFY changed) Q_PROPERTY(qreal height READ height NOTIFY changed)
Q_PROPERTY(bool ok READ ok NOTIFY changed) Q_PROPERTY(bool ok READ ok NOTIFY changed)
public: public:
explicit LlmMathText(QObject* parent = nullptr); explicit LlmMathText(QObject* parent = nullptr);
~LlmMathText() override;
[[nodiscard]] QString latex() const { return m_latex; } [[nodiscard]] QString latex() const { return m_latex; }
void setLatex(const QString& value); void setLatex(const QString& value);
@@ -42,8 +36,7 @@ class LlmMathText : public QObject {
void setFontPointSize(double value); void setFontPointSize(double value);
[[nodiscard]] qreal devicePixelRatio() const { return m_devicePixelRatio; } [[nodiscard]] qreal devicePixelRatio() const { return m_devicePixelRatio; }
void setDevicePixelRatio(qreal value); void setDevicePixelRatio(qreal value);
[[nodiscard]] QImage image() const { return m_image; } [[nodiscard]] QString imageId() const { return m_imageId; }
[[nodiscard]] QUrl imageUrl() const { return m_imageUrl; }
[[nodiscard]] qreal width() const { return m_width; } [[nodiscard]] qreal width() const { return m_width; }
[[nodiscard]] qreal height() const { return m_height; } [[nodiscard]] qreal height() const { return m_height; }
[[nodiscard]] bool ok() const { return m_ok; } [[nodiscard]] bool ok() const { return m_ok; }
@@ -54,13 +47,11 @@ class LlmMathText : public QObject {
private: private:
void reRender(); void reRender();
std::shared_ptr<JKQTMathText> m_renderer;
QString m_latex; QString m_latex;
QColor m_color; QColor m_color;
double m_fontPointSize = 12.0; double m_fontPointSize = 12.0;
qreal m_devicePixelRatio = 1.0; qreal m_devicePixelRatio = 1.0;
QImage m_image; QString m_imageId;
QUrl m_imageUrl;
qreal m_width = 0; qreal m_width = 0;
qreal m_height = 0; qreal m_height = 0;
bool m_ok = false; bool m_ok = false;
+14
View File
@@ -57,6 +57,20 @@ void ChatMessage::setActiveGeneration(int index) {
setActiveInternal(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) { void ChatMessage::edit(const QString& newContent) {
if (auto* generation = activeGeneration()) if (auto* generation = activeGeneration())
generation->setContent(newContent); generation->setContent(newContent);
+10
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "attachment.hpp"
#include "generation.hpp" #include "generation.hpp"
#include <QList> #include <QList>
@@ -27,6 +28,9 @@ class ChatMessage : public QObject {
Q_PROPERTY( Q_PROPERTY(
int activeGenerationIndex READ activeGenerationIndex NOTIFY int activeGenerationIndex READ activeGenerationIndex NOTIFY
activeGenerationChanged) activeGenerationChanged)
Q_PROPERTY(
QList<ZShell::llm::Attachment*> attachments READ attachments NOTIFY
attachmentsChanged)
public: public:
enum class Role : int { User = 0, Assistant }; enum class Role : int { User = 0, Assistant };
@@ -46,12 +50,16 @@ class ChatMessage : public QObject {
return generation(m_active); return generation(m_active);
} }
[[nodiscard]] int activeGenerationIndex() const { return m_active; } [[nodiscard]] int activeGenerationIndex() const { return m_active; }
[[nodiscard]] QList<Attachment*> attachments() const {
return m_attachments;
}
[[nodiscard]] ChatGeneration* generation(int index) const { [[nodiscard]] ChatGeneration* generation(int index) const {
if (index < 0 || index >= m_generations.size()) return nullptr; if (index < 0 || index >= m_generations.size()) return nullptr;
return m_generations.at(index); return m_generations.at(index);
} }
Q_INVOKABLE void setActiveGeneration(int index); Q_INVOKABLE void setActiveGeneration(int index);
void setAttachments(QList<Attachment*> attachments);
Q_INVOKABLE void edit(const QString& newContent); Q_INVOKABLE void edit(const QString& newContent);
Q_INVOKABLE void retry(); Q_INVOKABLE void retry();
Q_INVOKABLE void generate(); Q_INVOKABLE void generate();
@@ -63,6 +71,7 @@ class ChatMessage : public QObject {
Q_SIGNALS: Q_SIGNALS:
void generationsChanged(); void generationsChanged();
void activeGenerationChanged(); void activeGenerationChanged();
void attachmentsChanged();
private: private:
void setActiveInternal(int index); void setActiveInternal(int index);
@@ -70,6 +79,7 @@ class ChatMessage : public QObject {
Role m_role; Role m_role;
qint64 m_timestamp; qint64 m_timestamp;
QList<ChatGeneration*> m_generations; QList<ChatGeneration*> m_generations;
QList<Attachment*> m_attachments;
int m_active = -1; int m_active = -1;
}; };
+1 -1
View File
@@ -11,7 +11,7 @@ namespace ZShell::llm {
namespace { namespace {
constexpr int kMarkdownRefreshMs = 150; constexpr int kMarkdownRefreshMs = 150;
} // namespace }
LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent)
: QObject(parent), m_type(type), m_timestamp(timestamp) { : QObject(parent), m_type(type), m_timestamp(timestamp) {
+174 -6
View File
@@ -1,16 +1,31 @@
#include "session.hpp" #include "session.hpp"
#include "attachment.hpp"
#include "chatstore.hpp" #include "chatstore.hpp"
#include "llmclient.hpp" #include "llmclient.hpp"
#include <QClipboard>
#include <QDebug> #include <QDebug>
#include <QDateTime> #include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImage>
#include <QMimeDatabase>
#include <QMimeData>
#include <QMimeType>
#include <QStandardPaths>
#include <QUrl>
#include <QtGlobal> #include <QtGlobal>
namespace ZShell::llm { namespace ZShell::llm {
namespace { namespace {
constexpr qint64 kMaxImageBytes = 20 * 1024 * 1024;
constexpr qint64 kMaxFileBytes = 100 * 1024 * 1024;
QString titleFrom(const QString& content) { QString titleFrom(const QString& content) {
const QString flat = content.simplified(); const QString flat = content.simplified();
if (flat.isEmpty()) return QString(); if (flat.isEmpty()) return QString();
@@ -21,7 +36,7 @@ QString titleFrom(const QString& content) {
} // namespace } // namespace
ChatSession::ChatSession(const QString& id, QObject* parent) 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); m_model = new ChatMessageModel(this, this);
connect( connect(
m_model, m_model,
@@ -157,26 +172,179 @@ void ChatSession::startGeneration(ChatMessage* target) {
persist(); 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) { 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(); const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) return; if (trimmed.isEmpty() && attachmentList.isEmpty()) return;
if (auto* clientObject = client()) { if (auto* clientObject = client()) {
if (clientObject->busy()) return; if (clientObject->busy()) return;
} }
const qint64 now = QDateTime::currentMSecsSinceEpoch(); 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) while (m_model->rowCount() > 200)
m_model->removeMessage(m_model->at(m_model->rowCount() - 1)); m_model->removeMessage(m_model->at(m_model->rowCount() - 1));
if (m_title.isEmpty()) { 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(); Q_EMIT titleChanged();
qInfo() << "ChatSession:" << m_id << "new conversation," qInfo() << "ChatSession:" << m_id << "new conversation,"
<< "fallback title" << m_title << "fallback title" << m_title
<< "- requesting generated title and icon"; << "- requesting generated title and icon";
if (auto* clientObject = client()) { if (auto* clientObject = client()) {
clientObject->requestTitle(this, trimmed); clientObject->requestTitle(this, titleSource);
clientObject->requestIcon(this, trimmed); clientObject->requestIcon(this, titleSource);
} }
} }
auto* assistant = auto* assistant =
+21
View File
@@ -6,6 +6,8 @@
#include <QDateTime> #include <QDateTime>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QStringList>
#include <QVariantList>
#include <QtQml> #include <QtQml>
namespace ZShell::llm { namespace ZShell::llm {
@@ -27,6 +29,10 @@ class ChatSession : public QObject {
Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged)
Q_PROPERTY( Q_PROPERTY(
ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT) ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT)
Q_PROPERTY(QString workspace READ workspacePath CONSTANT)
Q_PROPERTY(
QString attachmentError READ attachmentError NOTIFY
attachmentErrorChanged)
public: public:
explicit ChatSession(const QString& id, QObject* parent = nullptr); explicit ChatSession(const QString& id, QObject* parent = nullptr);
@@ -55,6 +61,13 @@ class ChatSession : public QObject {
[[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; }
void setLastTokenCount(int value); 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 setTitle(const QString& value);
void setIcon(const QString& value); void setIcon(const QString& value);
void setUpdatedAt(qint64 value); void setUpdatedAt(qint64 value);
@@ -71,7 +84,12 @@ class ChatSession : public QObject {
void removeMessage(ChatMessage* message); void removeMessage(ChatMessage* message);
void clearMessages(); 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);
Q_INVOKABLE void sendMessage(
const QString& text, const QVariantList& attachments);
Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target); Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target);
Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message); Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message);
Q_INVOKABLE void clear(); Q_INVOKABLE void clear();
@@ -83,6 +101,7 @@ class ChatSession : public QObject {
void pinnedChanged(); void pinnedChanged();
void messageCountChanged(); void messageCountChanged();
void loaded(); void loaded();
void attachmentErrorChanged();
private: private:
void onModelRowsChanged(); void onModelRowsChanged();
@@ -103,6 +122,8 @@ class ChatSession : public QObject {
bool m_loaded = false; bool m_loaded = false;
bool m_clearPending = false; bool m_clearPending = false;
int m_lastTokenCount = 0; int m_lastTokenCount = 0;
QString m_workspace;
QString m_attachmentError;
}; };
} // namespace ZShell::llm } // namespace ZShell::llm
+258
View File
@@ -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
+57
View File
@@ -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
+718
View File
@@ -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()
+64
View File
@@ -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