new chatlistview component, file attachments, performance & primitive test
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Failing after 11s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Failing after 31s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m37s
Rust / fmt (pull_request) Successful in 27s
Rust / clippy (pull_request) Successful in 58s

This commit is contained in:
2026-09-17 15:20:41 +02:00
parent a3178ad9cd
commit 99949ff255
36 changed files with 4368 additions and 310 deletions
+5 -2
View File
@@ -9,6 +9,7 @@ ScrollBar {
id: root
required property Flickable flickable
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
@@ -20,9 +21,11 @@ ScrollBar {
readonly property real nonAnimY: root.axisContentPos / root.axisContentSize
readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight)
readonly property bool reversed: isHorizontal ? (flickable instanceof ListView && flickable.layoutDirection === Qt.RightToLeft) : (flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop)
property bool shouldBeActive
readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0
property bool shouldBeActive
property bool inverted
enabled: !Visibilities.getForActive().isDrawing
implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2
implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0
@@ -119,7 +122,7 @@ ScrollBar {
}
var delta = event.angleDelta.y > 0 ? -0.1 : 0.1;
var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta));
var newPos = Math.max(0, Math.min(1 - root.size, root.position + (root.inverted ? -delta : delta)));
root.position = newPos;
}
+4 -1
View File
@@ -7,6 +7,7 @@ CustomListView {
id: root
property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1
property bool fadeEnabled: true
property real fadeAmount: 0.1
property real fadeThreshold: 0.0
readonly property bool horizontal: orientation === ListView.Horizontal
@@ -17,7 +18,9 @@ CustomListView {
}
function fadeShouldBeActive(isStart: bool): bool {
// When content is smaller than flickable size, hide fade when rebound starts.
if (!fadeEnabled)
return false;
if (contentSize() + marginStart() + marginEnd() < viewportSize() && rebound.running && ((isStart ? overshootStart() > 0 : overshootStart() < 0))) {
return false;
}
@@ -1,4 +1,7 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
@@ -11,7 +14,6 @@ Item {
id: root
property ChatSession chatData
property bool following: true
// qmlformat off
readonly property var keys: [
Qt.Key_A,
@@ -87,12 +89,33 @@ Item {
signal requestClose
readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4
property var pendingAttachments: []
function attachFiles(): void {
Files.pickFiles().then(files => {
if (!files || files.length === 0)
return;
const added = chatData.addAttachments(files);
root.pendingAttachments = root.pendingAttachments.concat(added);
});
}
function send(text: string): void {
if (text.trim() === "")
if (text.trim() === "" && root.pendingAttachments.length === 0)
return;
following = true;
chatData.sendMessage(text);
root.scrollToLatest();
chatData.sendMessage(text, root.pendingAttachments);
input.text = "";
root.pendingAttachments = [];
}
function scrollToLatest(): void {
if (!listLoader.item || root.atNewestEnd)
return;
scrollAnim.stop();
scrollAnim.start();
}
function focusInput(): void {
@@ -110,6 +133,18 @@ Item {
}
}
Anim {
id: scrollAnim
target: listLoader.item
property: "contentY"
to: 0
type: Anim.DefaultEffects
onFinished: if (target)
target.contentY = 0
}
RowLayout {
id: header
@@ -151,41 +186,112 @@ Item {
anchors.topMargin: Tokens.spacing.medium
active: root.chatData && root.chatData.loaded
sourceComponent: VerticalFadeListView {
id: list
sourceComponent: Flickable {
id: flick
property bool userScrolledUp: false
property real lastCHeight: 0.0
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: list.layoutHeight
function scrollToBottom(): void {
scrollAnim.start();
transform: Rotation {
origin.y: flick.height / 2
origin.x: flick.width / 2
angle: 180
}
cacheBuffer: Math.max(height * 20, 0)
clip: true
fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel
spacing: 0
rotation: 180
add: Transition {
Anim {
from: -100
property: "y"
WheelInverter {
target: flick
}
Anim {
id: restoreAnim
target: flick
type: Anim.DefaultEffects
property: "contentY"
}
ChatListView {
id: list
width: flick.width
height: flick.height
yAnimDuration: Tokens.anim.durations.expressiveDefaultEffects
asynchronous: true
cacheBuffer: 50
estimatedHeight: 100
readyDelay: 5
removeDuration: Tokens.anim.durations.normal
spacing: 0
model: root.chatData.messagesModel
useCustomViewport: true
viewport: {
tWatcher.transform;
return Qt.rect(0, flick.contentY, width, flick.height);
}
onViewportAdjustNeeded: delta => {
restoreAnim.stop();
flick.contentY += delta;
}
onViewportRestoreRequested: target => {
restoreAnim.to = target;
restoreAnim.start();
}
delegate: MessageDelegate {
id: message
rootParent: flick
chatList: list
ChatListView.preferredHeight: implicitHeight
ChatListView.trackViewport: true
ChatListView.visibleHeight: ChatListView.removing ? 0 : implicitHeight
opacity: ChatListView.removing || ChatListView.adding ? 0 : 1
transform: Rotation {
origin.y: message.height / 2
origin.x: message.width / 2
angle: 180
}
Behavior on y {
enabled: message.ChatListView.ready
Anim {
id: yAnim
type: Anim.DefaultEffects
}
}
Behavior on x {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
}
CustomScrollBar.vertical: CustomScrollBar {
id: scrollBar
flickable: list
parent: list.parent
anchors.top: list.top
anchors.right: list.right
anchors.bottom: list.bottom
flickable: flick
parent: flick.parent
anchors.top: flick.top
anchors.right: flick.right
anchors.bottom: flick.bottom
inverted: true
transform: Rotation {
origin.y: list.height / 2
origin.y: flick.height / 2
origin.x: scrollBar.width / 2
angle: 180
@@ -196,57 +302,15 @@ Item {
}
}
}
delegate: MessageDelegate {
rotation: 180
rootParent: list
}
displaced: Transition {
Anim {
property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
}
TransformWatcher {
id: tWatcher
Component.onCompleted: {
positionViewAtBeginning();
}
onAtYBeginningChanged: {
if (atYBeginning)
userScrolledUp = false;
}
onContentHeightChanged: {
if (userScrolledUp && Chat.busy) {
const delta = contentHeight - lastCHeight;
contentY += delta;
}
lastCHeight = contentHeight;
}
onMovingChanged: {
if (moving)
userScrolledUp = !atYBeginning;
}
Anim {
id: scrollAnim
property: "contentY"
target: list
type: Anim.DefaultEffects
to: list.originY
}
WheelInverter {
target: list
a: flick.contentItem
b: list
}
}
// Trigger the load even before we're ready to show the list.
Component.onCompleted: if (root.chatData)
root.chatData.messagesModel
}
@@ -265,7 +329,7 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
implicitHeight: scrollToBottom.implicitHeight
implicitWidth: scrollToBottom.implicitWidth
scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0
scale: root.atNewestEnd || !listLoader.item || listLoader.item.contentHeight <= listLoader.item.height ? 0 : 1
visible: scale > 0
Behavior on scale {
@@ -289,7 +353,180 @@ Item {
type: IconButton.Tonal
onClicked: {
listLoader.item.scrollToBottom();
root.scrollToLatest();
}
}
}
GridView {
id: attachments
readonly property int columns: ChatState.isWindow ? 5 : 3
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.medium
flow: GridView.LeftToRight
verticalLayoutDirection: GridView.BottomToTop
cellWidth: width / columns
cellHeight: cellWidth
implicitHeight: Math.ceil(count / columns) * cellHeight
displaced: Transition {
Anim {
property: "x"
}
Anim {
property: "y"
}
}
add: Transition {
id: addTrans
Anim {
property: "y"
from: addTrans.ViewTransition.destination.y + attachments.cellHeight / 4
}
Anim {
property: "opacity"
from: 0
to: 1
}
}
remove: Transition {
id: removeTrans
Anim {
property: "y"
to: removeTrans.ViewTransition.destination.y + attachments.cellHeight / 4
}
Anim {
property: "opacity"
from: 1
to: 0
}
}
model: ScriptModel {
objectProp: "path"
values: root.pendingAttachments
}
delegate: Item {
id: thumb
required property var modelData
function fileIcon(mime): string {
if (mime.startsWith("archive/") || mime === "application/zip")
return "folder_zip";
if (mime === "application/pdf")
return "picture_as_pdf";
if (mime.startsWith("audio/"))
return "audio_file";
if (mime.startsWith("video/"))
return "video_file";
if (mime.startsWith("text/"))
return "description";
return "insert_drive_file";
}
width: attachments.cellWidth
height: width
opacity: modelData ? 1 : 0
CustomClippingRect {
anchors.fill: parent
anchors.margins: Tokens.spacing.extraSmall
radius: Tokens.rounding.medium
color: Colors.tPalette.m3surfaceContainer
FadeImage {
id: attImage
anchors.fill: parent
fillMode: FadeImage.PreserveAspectCrop
asynchronous: true
source: visible ? thumb.modelData?.path ?? "" : ""
visible: thumb.modelData?.isImage ?? false
}
MaterialIcon {
anchors.centerIn: parent
font.pointSize: Tokens.font.size.extraLarge
text: thumb.fileIcon(thumb.modelData?.mimeType ?? "")
visible: !thumb.modelData?.isImage ?? false
}
Loader {
id: attIndicatorLoader
anchors.centerIn: parent
opacity: 0
active: opacity > 0 && (thumb.modelData?.isImage ?? false)
sourceComponent: CustomRect {
implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
color: Colors.palette.m3primaryContainer
radius: Tokens.rounding.full
LoadingIndicator {
id: attLoadingIndicator
anchors.centerIn: parent
containsIcon: true
implicitSize: thumb.implicitWidth * 0.4
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Timer {
id: attLoadDebounceTimer
interval: 100
onTriggered: {
if (attImage.status !== Image.Ready)
attIndicatorLoader.opacity = 1;
}
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: undefined
IconButton {
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Tokens.padding.medium
icon: "close"
visible: opacity > 0
enabled: thumb.opacity > 0
opacity: hover.containsMouse ? 1 : 0
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
onClicked: root.pendingAttachments = root.pendingAttachments.filter(a => a !== thumb.modelData)
}
}
}
}
}
@@ -310,7 +547,9 @@ Item {
sendIcon.icon: "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall
Component.onCompleted: root.focusInput()
Component.onCompleted: {
root.focusInput();
}
Keys.onPressed: e => {
if (e.key == Qt.Key_Return) {
if (!(e.modifiers & Qt.ShiftModifier)) {
@@ -318,6 +557,14 @@ Item {
e.accepted = true;
}
}
if (e.key == Qt.Key_V && e.modifiers & Qt.ControlModifier) {
if (root.chatData.clipboardHasAttachment()) {
const att = root.chatData.pasteAttachments();
root.pendingAttachments = [...root.pendingAttachments, ...att];
e.accepted = true;
}
}
}
onSendPressed: root.send(text)
}
@@ -16,10 +16,15 @@ CustomRect {
property bool copied: false
property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
property color codeHeaderColor: Colors.palette.m3outline
property var codeSpans: []
property string html: ""
property bool colored: false
property string requestedCode: ""
property string requestedLanguage: ""
property int highlightToken: 0
function refresh() {
requestedCode = root.code;
requestedLanguage = root.language;
const token = ++root.highlightToken;
CodeHighlighter.highlight(root.code, root.language, root, token);
}
@@ -27,7 +32,11 @@ CustomRect {
function onHighlightSpans(token, spans) {
if (token !== root.highlightToken)
return;
codeSpans = spans;
if (root.code !== root.requestedCode ||
root.language !== root.requestedLanguage)
return;
html = spans.length ? root.highlightedHtml(root.code, spans) : "";
colored = root.visible && html.length > 0;
}
function roleColor(kind) {
@@ -71,6 +80,21 @@ CustomRect {
return text.replace(/&/g, "&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 {
let out;
if (!spans.length) {
@@ -101,10 +125,26 @@ CustomRect {
radius: Tokens.rounding.medium
onLanguageChanged: {
codeSpans = [];
refresh();
html = "";
colored = false;
if (root.visible)
refreshTimer.restart();
}
onCodeChanged: {
colored = false;
if (root.visible)
refreshTimer.restart();
}
onVisibleChanged: if (root.visible)
refreshTimer.restart();
Timer {
id: refreshTimer
interval: 200
repeat: false
onTriggered: refresh()
}
onCodeChanged: refresh()
RowLayout {
id: headerRow
@@ -229,21 +269,6 @@ CustomRect {
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
color: CodeColors.active.bg
CustomText {
id: code
anchors.top: parent.top
anchors.bottom: parent.bottom
x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small
anchors.margins: Tokens.padding.small
text: root.highlightedHtml(root.code, root.codeSpans)
textFormat: Text.RichText
color: CodeColors.active.normal
layer.enabled: true
clip: false
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
}
Flickable {
id: codeFlick
@@ -263,12 +288,11 @@ CustomRect {
color: CodeColors.active.normal
layer.enabled: true
leftInset: Tokens.padding.small
clip: false
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
textFormat: Text.RichText
text: code.text
textFormat: root.colored ? Text.RichText : Text.PlainText
text: root.colored ? root.html : root.plainForDisplay(root.code)
readOnly: true
}
}
@@ -0,0 +1,128 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Llm
import ZShell.Config
import qs.Components
import qs.Services
Item {
id: root
required property ChatGeneration current
signal dismiss
implicitHeight: 0
Component.onCompleted: {
root.implicitHeight = Qt.binding(() => errorInner.implicitHeight + errorInner.anchors.margins * 2);
enterAnim.start();
}
Behavior on implicitHeight {
Anim {}
}
ParallelAnimation {
id: enterAnim
Anim {
target: container
property: "y"
from: container.implicitHeight
to: 0
}
Anim {
target: container
property: "opacity"
from: 0
to: 1
}
}
ParallelAnimation {
id: exitAnim
Anim {
target: root
property: "implicitHeight"
from: root.implicitHeight
to: 0
}
Anim {
target: container
property: "y"
to: container.implicitHeight
from: 0
}
Anim {
target: container
property: "opacity"
to: 0
from: 1
}
onFinished: root.dismiss()
}
CustomRect {
id: container
anchors.left: parent.left
anchors.right: parent.right
radius: Tokens.rounding.medium
color: Colors.palette.m3errorContainer
implicitHeight: errorInner.implicitHeight + errorInner.anchors.margins * 2
ColumnLayout {
id: errorInner
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
spacing: Tokens.spacing.small
CustomText {
id: header
Layout.fillWidth: true
font.pointSize: Tokens.font.size.large
elide: CustomText.ElideRight
color: Colors.palette.m3onErrorContainer
text: qsTr("An error occured")
}
CustomText {
id: body
Layout.fillWidth: true
wrapMode: CustomText.WordWrap
color: Colors.palette.m3onErrorContainer
text: root.current.errorMessage || Chat.serverError
}
}
IconButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
isRound: true
icon: "close"
type: IconButton.Text
font.pointSize: Tokens.font.size.large
onClicked: exitAnim.start()
}
}
}
@@ -26,8 +26,7 @@ Item {
id: equationImage
anchors.centerIn: parent
source: math.imageUrl
asynchronous: true
source: math.imageId.length ? "image://llmmath/" + math.imageId : ""
visible: math.ok
fillMode: Image.PreserveAspectFit
smooth: true
@@ -3,45 +3,50 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import ZShell.Components
import ZShell.Config
import ZShell.Llm
import qs.Modules.Notifications.Sidebar.Chat
import qs.Components
import qs.Services
MouseArea {
id: root
property ChatGeneration current: modelData.activeGeneration
required property int index
readonly property bool isUser: modelData.role === ChatMessage.Role.User
required property Item rootParent
required property ChatMessage modelData
property bool reasoningExpanded: false
readonly property var blocks: blockify(current.segments)
property real savedOffset: -1
property bool stickActive: false
required property ChatListView chatList
function handleReasoningToggle(expanded: bool): void {
const view = root.ListView.view;
if (!view)
readonly property bool isUser: modelData.role === ChatMessage.Role.User
readonly property var blocks: blockify(current.segments, current.error)
property ChatGeneration current: modelData.activeGeneration
property bool reasoningExpanded: false
property real savedOffset: NaN
property bool anchored: false
function handleReasoningToggle(expanded: bool, block: Item): void {
if (!root.rootParent)
return;
if (expanded) {
root.savedOffset = view.contentY - root.y;
root.reasoningExpanded = true;
root.savedOffset = root.rootParent.contentY - root.y;
root.anchored = true;
chatList.anchorItem(block, ChatListView.TopEdge);
settleTimer.start();
} else {
root.reasoningExpanded = false;
root.stickActive = false;
settleTimer.stop();
root.anchored = false;
chatList.clearAnchor();
if (!isNaN(root.savedOffset)) {
restoreAnim.to = root.y + root.savedOffset;
restoreAnim.restart();
chatList.requestRestore(root.y + root.savedOffset);
root.savedOffset = NaN;
}
}
}
function blockify(segs) {
function blockify(segs, errored) {
const blocks = [];
for (let i = 0; i < segs.length; i++) {
const seg = segs[i];
@@ -65,35 +70,32 @@ MouseArea {
}
}
if (errored)
blocks.push({
kind: "error",
id: segs.length
});
return blocks;
}
implicitWidth: ListView.view.width
hoverEnabled: true
preventStealing: false
implicitHeight: layout.implicitHeight + Tokens.padding.medium
implicitHeight: layout.implicitHeight + Tokens.padding.medium + (attachments.visible ? attachments.implicitHeight : 0)
onImplicitHeightChanged: if (root.anchored)
settleTimer.restart()
Timer {
id: settleTimer
interval: 16
interval: Tokens.anim.durations.expressiveDefaultEffects
repeat: false
onTriggered: {
root.stickActive = false;
}
}
onImplicitHeightChanged: {
if (root.reasoningExpanded) {
root.stickActive = true;
settleTimer.restart();
const view = root.ListView.view;
if (!view)
return;
const bottomEdge = root.y + root.height + Tokens.padding.large * 2;
const viewportBottom = view.contentY + view.height;
if (bottomEdge > viewportBottom)
view.contentY = bottomEdge - view.height;
if (root.anchored) {
root.anchored = false;
chatList.clearAnchor();
}
}
}
@@ -147,20 +149,144 @@ MouseArea {
}
}
Anim {
id: restoreAnim
GridView {
id: attachments
property: "contentY"
target: root.ListView.view
type: Anim.DefaultEffects
readonly property int columns: ChatState.isWindow ? 5 : 3
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
flow: GridView.LeftToRight
verticalLayoutDirection: GridView.BottomToTop
cellWidth: width / columns
cellHeight: cellWidth
implicitHeight: Math.ceil(count / columns) * cellHeight
displaced: Transition {
Anim {
property: "x"
}
Anim {
property: "y"
}
}
model: ScriptModel {
objectProp: "path"
values: {
const list = [...root.modelData.attachments];
if (list.length > 0 && list.length < attachments.columns) {
while (list.length < attachments.columns)
list.unshift(null);
}
return list;
}
}
delegate: Item {
id: thumb
required property var modelData
function fileIcon(mime): string {
if (mime.startsWith("archive/") || mime === "application/zip")
return "folder_zip";
if (mime === "application/pdf")
return "picture_as_pdf";
if (mime.startsWith("audio/"))
return "audio_file";
if (mime.startsWith("video/"))
return "video_file";
if (mime.startsWith("text/"))
return "description";
return "insert_drive_file";
}
width: attachments.cellWidth
height: width
opacity: modelData ? 1 : 0
CustomClippingRect {
anchors.fill: parent
anchors.margins: Tokens.spacing.extraSmall
radius: Tokens.rounding.medium
color: Colors.tPalette.m3surfaceContainer
FadeImage {
id: attImage
anchors.fill: parent
fillMode: FadeImage.PreserveAspectCrop
asynchronous: true
source: visible ? thumb.modelData?.path ?? "" : ""
visible: thumb.modelData?.isImage ?? false
}
MaterialIcon {
anchors.centerIn: parent
font.pointSize: Tokens.font.size.extraLarge
text: thumb.fileIcon(thumb.modelData?.mimeType ?? "")
visible: !thumb.modelData?.isImage ?? false
}
Loader {
id: attIndicatorLoader
anchors.centerIn: parent
opacity: 0
active: opacity > 0 && (thumb.modelData?.isImage ?? false)
sourceComponent: CustomRect {
implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
color: Colors.palette.m3primaryContainer
radius: Tokens.rounding.full
LoadingIndicator {
id: attLoadingIndicator
anchors.centerIn: parent
containsIcon: true
implicitSize: thumb.implicitWidth * 0.4
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Timer {
id: attLoadDebounceTimer
interval: 100
onTriggered: {
if (attImage.status !== Image.Ready)
attIndicatorLoader.opacity = 1;
}
}
}
}
}
Column {
id: layout
anchors.top: parent.top
anchors.top: attachments.bottom
anchors.left: parent.left
anchors.right: parent.right
spacing: Tokens.spacing.medium
Repeater {
@@ -173,6 +299,17 @@ MouseArea {
delegate: DelegateChooser {
role: "kind"
DelegateChoice {
roleValue: "error"
delegate: ErrorBubble {
width: root.width
current: root.current
onDismiss: root.current.dismissError()
}
}
DelegateChoice {
roleValue: "process"
@@ -182,7 +319,7 @@ MouseArea {
current: root.current
rootParent: root.rootParent
onExpandedChanged: root.handleReasoningToggle(expanded)
onExpandedChanged: root.handleReasoningToggle(expanded, this)
}
}
@@ -22,7 +22,6 @@ Item {
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s))
clip: true
implicitHeight: {
const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin;
const collapsed = layout.implicitHeight;
@@ -36,6 +35,8 @@ Item {
return collapsed;
}
onCurrentChanged: expanded = false
Behavior on implicitHeight {
Anim {
type: Anim.DefaultEffects
@@ -147,19 +148,30 @@ Item {
property bool open: false
property Item rootParent: root.rootParent
property int openHeight: toolsBg.rootParent.height - Tokens.padding.medium * 2
property int maxHeight: rootParent.height - Tokens.padding.medium * 2
property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight)
property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2
property real openHeightProg: openHeight / maxHeight
property int collapsedY: 0
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
z: open || dialogTransition.running ? 2 : 0
Layout.fillWidth: true
function reparentWrapper(): void {
const newParent = open ? toolsBg.rootParent : toolsBg;
const pos = toolsWrapper.mapToItem(newParent, 0, 0);
const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2);
toolsBg.collapsedY = toolsWrapper.y;
toolsWrapper.parent = newParent;
toolsWrapper.x = pos.x;
toolsWrapper.y = pos.y;
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
toolsWrapper.x = c.x - toolsWrapper.width / 2;
toolsWrapper.y = c.y - toolsWrapper.height / 2;
}
onOpenChanged: {
if (open)
toolsBg.rootParent.interactive = false;
else
toolsBg.rootParent.interactive = true;
}
BlobGroup {
@@ -189,6 +201,8 @@ Item {
width: toolsBg.width
height: toolBox.implicitHeight
rotation: parent === toolsBg.rootParent ? 180 : 0
z: 1
states: State {
name: "open"
@@ -200,11 +214,11 @@ Item {
toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased
// toolsContent.opacity: 1
toolsContent.opacity: 1
toolsWrapper.height: toolsBg.openHeight
toolsWrapper.width: toolsBg.openWidth
toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2
toolsWrapper.y: (toolsBg.rootParent.height - toolsBg.openHeight) / 2
toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium
elevation.opacity: 1
toolBox.opacity: 0
}
@@ -274,7 +288,6 @@ Item {
StateLayer {
onClicked: {
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
toolsBg.open = true;
}
}
@@ -287,7 +300,10 @@ Item {
anchors.margins: Tokens.padding.medium
Repeater {
model: root.current.pendingToolCalls ?? []
id: repeater
model: root.current.pendingToolCalls?.slice(0, 4) ?? []
delegate: RowLayout {
id: toolText
@@ -309,10 +325,10 @@ Item {
return String(seg.arguments);
}
CustomText {
MaterialIcon {
color: Colors.palette.m3onPrimaryContainer
text: String(toolText.index + 1)
font.pointSize: Tokens.font.size.large
text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet"
fill: 1
}
CustomText {
@@ -331,12 +347,134 @@ Item {
}
}
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length)
visible: repeater.count < root.current.pendingToolCalls.length
}
}
}
Loader {
id: toolsContent
active: opacity > 0
anchors.fill: parent
asynchronous: false
opacity: 0
sourceComponent: MouseArea {
onWheel: event => event.accepted = true
preventStealing: true
implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2
VerticalFadeListView {
id: innerLayout
anchors.fill: parent
anchors.margins: Tokens.padding.small
cacheBuffer: height * 10
fadeAmount: 0.1
fadeEnabled: interactive
spacing: 0
clip: true
interactive: contentHeight > toolsBg.maxHeight
model: root.current.pendingToolCalls ?? []
delegate: Item {
id: toolCall
required property LlmSegment modelData
property bool expanded: false
implicitWidth: CustomListView.view.width
implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins)
function toolTarget(seg: LlmSegment): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
Behavior on implicitHeight {
Anim {}
}
RowLayout {
id: collapsedRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
IconButton {
id: toolExpandBtn
font.pointSize: Tokens.font.size.large
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
rotation: toolCall.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {}
}
onClicked: toolCall.expanded = !toolCall.expanded
}
CustomText {
id: preview
Layout.fillWidth: true
text: toolCall.modelData.name
}
}
CustomClippingRect {
id: expandedTool
anchors.top: collapsedRow.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
anchors.topMargin: Tokens.spacing.medium
implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2
radius: Tokens.rounding.medium
color: Colors.palette.m3surfaceContainerLow
CustomText {
id: fullToolCall
anchors.fill: parent
anchors.margins: Tokens.padding.medium
text: toolCall.toolTarget(toolCall.modelData)
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
}
}
}
}
}
}
}
}
ButtonRow {
id: confirmationRow
Layout.alignment: Qt.AlignRight
spacing: Tokens.spacing.medium
@@ -366,7 +504,7 @@ Item {
}
}
CustomRect {
CustomClippingRect {
id: expandedRect
anchors.left: parent.left
+1 -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)
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
+6 -4
View File
@@ -1,11 +1,13 @@
qml_module(ZShell-components
URI ZShell.Components
SOURCES
lazylistview.hpp lazylistview.cpp
SOURCES
lazylistview.hpp lazylistview.cpp
chatlistview.hpp chatlistview.cpp
wavyline.hpp wavyline.cpp
buttonrow.hpp buttonrow.cpp
carouselview.hpp carouselview.cpp
wheelinverter.hpp
LIBRARIES
Qt::Quick
LIBRARIES
Qt::Quick
Qt::QuickPrivate
)
File diff suppressed because it is too large Load Diff
+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(double, temperature, 0.7)
CFG_PROPERTY(bool, tools, true)
CFG_PROPERTY(bool, sandbox, true)
CFG_PROPERTY(bool, enabled, true)
CONFIG_SUBOBJECT(LlmAppearance, appearance)
@@ -32,4 +33,4 @@ class Llm : public ConfigObject {
: ConfigObject(parent), m_appearance(new LlmAppearance(this)) {}
};
} // namespace ZShell::config
}
+2
View File
@@ -285,6 +285,7 @@ file(WRITE "${LM_FONTS_HPP}" "${_lm_header}")
qml_module(ZShell-llm
URI ZShell.Llm
SOURCES
attachment.hpp attachment.cpp
chat.hpp chat.cpp
chatstore.hpp chatstore.cpp
codehighlighter.hpp codehighlighter.cpp
@@ -298,6 +299,7 @@ qml_module(ZShell-llm
messagemodel.hpp messagemodel.cpp
segment.hpp segment.cpp
session.hpp session.cpp
shelltool.hpp shelltool.cpp
tool.hpp tool.cpp
webfetchtool.hpp webfetchtool.cpp
LIBRARIES
+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 "llm.hpp"
#include "llmclient.hpp"
#include "shelltool.hpp"
#include "webfetchtool.hpp"
#include <QDebug>
@@ -19,12 +20,14 @@ Chat::Chat(QObject* parent)
m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
m_client->tools()->registerTool(new ShellTool(m_client, m_client->tools()));
const auto* llm = config::Config::instance()->llm();
m_client->setEndpoint(llm->endpoint());
m_client->setModel(llm->model());
m_client->setTemperature(llm->temperature());
m_client->setToolsEnabled(llm->tools());
m_client->setSandboxEnabled(llm->sandbox());
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
m_client->setEndpoint(llm->endpoint());
@@ -38,11 +41,16 @@ Chat::Chat(QObject* parent)
connect(llm, &config::Llm::toolsChanged, this, [this, llm]() {
m_client->setToolsEnabled(llm->tools());
});
connect(llm, &config::Llm::sandboxChanged, this, [this, llm]() {
m_client->setSandboxEnabled(llm->sandbox());
Q_EMIT sandboxEnabledChanged();
});
connect(m_client, &LlmClient::busyChanged, this, [this]() {
if (m_client->busy() && !m_lastError.isEmpty()) {
m_lastError.clear();
Q_EMIT lastErrorChanged();
if (m_client->busy() && (m_hasError || !m_errorMessage.isEmpty())) {
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
Q_EMIT busyChanged();
});
@@ -73,10 +81,16 @@ Chat::Chat(QObject* parent)
&LlmClient::errorOccurred,
this,
[this](const QString& message) {
m_lastError = message;
Q_EMIT lastErrorChanged();
m_hasError = !message.isEmpty();
m_errorMessage = message;
Q_EMIT errorChanged();
Q_EMIT errorOccurred(message);
});
connect(
m_client,
&LlmClient::serverErrorChanged,
this,
&Chat::serverErrorChanged);
connect(
m_store,
&ChatStore::sessionRemoved,
@@ -134,11 +148,24 @@ void Chat::setToolsEnabled(bool value) {
config->llm()->set_tools(value);
}
bool Chat::sandboxEnabled() const {
return m_client->sandboxEnabled();
}
void Chat::setSandboxEnabled(bool value) {
m_client->setSandboxEnabled(value);
if (auto* config = config::Config::instance())
config->llm()->set_sandbox(value);
}
QString Chat::serverError() const {
return m_client->serverError();
}
QString Chat::streamingChatId() const {
return m_client->streamingChatId();
}
Chat* Chat::s_instance = nullptr;
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
@@ -151,9 +178,10 @@ void Chat::stop() {
}
void Chat::dismissError() {
if (m_lastError.isEmpty()) return;
m_lastError.clear();
Q_EMIT lastErrorChanged();
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
void Chat::refreshModels() {
@@ -161,7 +189,7 @@ void Chat::refreshModels() {
}
void Chat::refreshFromServer() {
m_client->refreshFromServer();
m_client->refreshAllStates();
}
void Chat::selectModel(const QString& id) {
+16 -4
View File
@@ -29,7 +29,12 @@ class Chat : public QObject {
Q_PROPERTY(
bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY
toolsEnabledChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(
bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY
sandboxEnabledChanged)
Q_PROPERTY(bool error READ error NOTIFY errorChanged)
Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged)
Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged)
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
Q_PROPERTY(
QString streamingChatId READ streamingChatId NOTIFY
@@ -45,7 +50,11 @@ class Chat : public QObject {
[[nodiscard]] int contextSize() const;
[[nodiscard]] bool toolsEnabled() const;
void setToolsEnabled(bool value);
[[nodiscard]] QString lastError() const { return m_lastError; }
[[nodiscard]] bool sandboxEnabled() const;
void setSandboxEnabled(bool value);
[[nodiscard]] bool error() const { return m_hasError; }
[[nodiscard]] QString errorMessage() const { return m_errorMessage; }
[[nodiscard]] QString serverError() const;
[[nodiscard]] ChatStore* chats() const { return m_store; }
[[nodiscard]] QString streamingChatId() const;
@@ -64,14 +73,17 @@ class Chat : public QObject {
void availableModelsChanged();
void contextSizeChanged();
void toolsEnabledChanged();
void sandboxEnabledChanged();
void errorOccurred(const QString& message);
void lastErrorChanged();
void errorChanged();
void serverErrorChanged();
void streamingChatIdChanged();
private:
ChatStore* m_store = nullptr;
LlmClient* m_client = nullptr;
QString m_lastError;
bool m_hasError = false;
QString m_errorMessage;
static Chat* s_instance;
};
+96
View File
@@ -65,10 +65,19 @@ struct GenerationRow {
QVector<SegmentRow> segments;
};
struct AttachmentRow {
QString name;
QString path;
int kind = 1;
QString mime;
qint64 size = 0;
};
struct MessageRow {
bool user = false;
qint64 timestamp = 0;
QVector<GenerationRow> generations;
QVector<AttachmentRow> attachments;
};
} // namespace
@@ -160,6 +169,20 @@ void ChatStore::openDb() {
" timestamp INTEGER NOT NULL\n"
")"));
}
{
QSqlQuery query(db);
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS attachments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" message_id INTEGER NOT NULL REFERENCES messages "
"(id) ON DELETE CASCADE,\n"
" name TEXT NOT NULL,\n"
" path TEXT NOT NULL,\n"
" kind INTEGER NOT NULL DEFAULT 1,\n"
" mime TEXT NOT NULL DEFAULT '',\n"
" size INTEGER NOT NULL DEFAULT 0\n"
")"));
}
{
QSqlQuery query(db);
query.exec(QStringLiteral(
@@ -171,6 +194,9 @@ void ChatStore::openDb() {
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_segments_generation "
"ON segments (generation_id)"));
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_attachments_message "
"ON attachments (message_id)"));
}
}
@@ -233,6 +259,7 @@ void ChatStore::removeSession(ChatSession* session) {
query.bindValue(":id", session->id());
query.exec();
}
QDir(ChatSession::sandboxDirFor(session->id())).removeRecursively();
m_sessions.removeOne(session);
session->deleteLater();
notify(before);
@@ -330,6 +357,11 @@ bool ChatStore::saveSession(ChatSession* session) {
"timestamp) VALUES (:gid, :type, :text, :name, "
":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
":timestamp)");
QSqlQuery attachmentInsert(handle);
ok = ok && attachmentInsert.prepare(
"INSERT INTO attachments (message_id, name, path, kind, "
"mime, size) VALUES (:mid, :name, :path, :kind, :mime, "
":size)");
const auto* model = session->messagesModel();
for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
const auto* message = model->at(row);
@@ -390,6 +422,26 @@ bool ChatStore::saveSession(ChatSession* session) {
}
}
}
if (ok)
for (const auto* attachment : message->attachments()) {
attachmentInsert.bindValue(":mid", messageId);
attachmentInsert.bindValue(
":name", sqlText(attachment->name()));
attachmentInsert.bindValue(
":path", sqlText(attachment->path()));
attachmentInsert.bindValue(
":kind", static_cast<int>(attachment->kind()));
attachmentInsert.bindValue(
":mime", sqlText(attachment->mimeType()));
attachmentInsert.bindValue(":size", attachment->size());
if (!attachmentInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id
<< "attachment insert failed:"
<< attachmentInsert.lastError().text();
break;
}
}
}
}
if (!ok || !handle.commit()) {
@@ -497,6 +549,34 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
<< messageId << ":"
<< generationQuery.lastError().text();
}
QSqlQuery attachmentQuery(db);
attachmentQuery.prepare(
"SELECT name, path, kind, mime, size FROM "
"attachments WHERE message_id = :mid "
"ORDER BY rowid");
attachmentQuery.bindValue(":mid", messageId);
if (attachmentQuery.exec()) {
while (attachmentQuery.next()) {
AttachmentRow attachment;
attachment.name =
attachmentQuery.value(0).toString();
attachment.path =
attachmentQuery.value(1).toString();
attachment.kind =
attachmentQuery.value(2).toInt();
attachment.mime =
attachmentQuery.value(3).toString();
attachment.size =
attachmentQuery.value(4).toLongLong();
message.attachments.append(attachment);
}
} else {
qWarning()
<< "ChatStore: failed to load attachments "
"for message"
<< messageId << ":"
<< attachmentQuery.lastError().text();
}
rows.append(message);
}
}
@@ -523,6 +603,22 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
row.timestamp);
if (!row.attachments.isEmpty()) {
QList<Attachment*> attachments;
for (const AttachmentRow& attachmentRow :
row.attachments) {
attachments.append(new Attachment(
attachmentRow.name,
attachmentRow.path,
attachmentRow.kind == 0
? Attachment::Kind::Image
: Attachment::Kind::File,
attachmentRow.mime,
attachmentRow.size,
message));
}
message->setAttachments(attachments);
}
int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow =
+4 -4
View File
@@ -198,7 +198,7 @@ const char* CodeHighlighter::roleName(uint8_t role) {
QString CodeHighlighter::resolveId(const QString& language) {
const QString tag = language.trimmed().toLower();
const QString alias = aliases().value(tag);
return alias.isEmpty() ? tag : alias; // unknown tags = grammar id
return alias.isEmpty() ? tag : alias;
}
QString CodeHighlighter::cacheKey(const QString& id, const QString& code) {
@@ -223,8 +223,8 @@ void CodeHighlighter::storeSpans(
const QString& language,
const QVariantList& spans) const {
if (spans.isEmpty() || code.isEmpty()) return;
static constexpr int kMaxEntries = 32;
static constexpr int kMaxBytes = 1024 * 1024;
static constexpr int kMaxEntries = 64;
static constexpr int kMaxBytes = 2 * 1024 * 1024;
const QString key = cacheKey(resolveId(language), code);
const int bytes = static_cast<int>(code.toUtf8().size());
QMutexLocker locker(&m_cacheMutex);
@@ -369,7 +369,7 @@ QVariantList CodeHighlighter::doHighlight(
if (c < 0x80)
cu[b + 1] += 1;
else if (c < 0xC0)
; // continuation byte
;
else if (c < 0xF0)
cu[b + 1] += 1;
else
+15
View File
@@ -176,6 +176,21 @@ void ChatGeneration::appendReasoning(const QString& piece) {
openReasoningSegment()->appendText(piece);
}
void ChatGeneration::setError(const QString& value) {
const bool hasError = !value.isEmpty();
if (m_hasError == hasError && m_errorMessage == value) return;
m_hasError = hasError;
m_errorMessage = value;
Q_EMIT errorChanged();
}
void ChatGeneration::dismissError() {
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
void ChatGeneration::setStreaming(bool value) {
if (m_streaming == value) return;
m_streaming = value;
+9
View File
@@ -21,6 +21,8 @@ class ChatGeneration : public QObject {
Q_PROPERTY(
QString content READ content WRITE setContent NOTIFY contentChanged)
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged)
Q_PROPERTY(bool error READ error NOTIFY errorChanged)
Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged)
Q_PROPERTY(
qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
elapsedMsChanged)
@@ -48,6 +50,8 @@ class ChatGeneration : public QObject {
[[nodiscard]] QString content() const;
[[nodiscard]] QString reasoning() const;
[[nodiscard]] bool error() const { return m_hasError; }
[[nodiscard]] QString errorMessage() const { return m_errorMessage; }
[[nodiscard]] bool reasoningActive() const;
[[nodiscard]] qint64 reasoningElapsedMs() const;
[[nodiscard]] qint64 contentElapsedMs() const;
@@ -65,11 +69,13 @@ class ChatGeneration : public QObject {
Q_INVOKABLE void approveTools();
Q_INVOKABLE void denyTools();
Q_INVOKABLE void dismissError();
void setContent(const QString& value);
void appendContent(const QString& piece);
void appendReasoning(const QString& piece);
void setStreaming(bool value);
void setError(const QString& value);
[[nodiscard]] LlmSegment* openContentSegment();
[[nodiscard]] LlmSegment* openReasoningSegment();
@@ -81,6 +87,7 @@ class ChatGeneration : public QObject {
Q_SIGNALS:
void contentChanged();
void reasoningChanged();
void errorChanged();
void reasoningActiveChanged();
void elapsedMsChanged();
void streamingChanged();
@@ -98,6 +105,8 @@ class ChatGeneration : public QObject {
bool m_streaming = false;
bool m_approvalPending = false;
qint64 m_timestamp;
bool m_hasError = false;
QString m_errorMessage;
};
} // namespace ZShell::llm
+219 -57
View File
@@ -1,5 +1,6 @@
#include "llmclient.hpp"
#include "attachment.hpp"
#include "generation.hpp"
#include "message.hpp"
#include "messagemodel.hpp"
@@ -7,6 +8,7 @@
#include "session.hpp"
#include <QDebug>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
@@ -132,26 +134,30 @@ QString LlmClient::completionsPath(
return base + subpath;
}
QString LlmClient::jsonErrorMessage(const QByteArray& body) {
const QJsonDocument doc = QJsonDocument::fromJson(body);
if (!doc.isObject()) return QString();
const QJsonValue errorValue = doc.object().value("error");
if (errorValue.isObject())
return errorValue.toObject().value("message").toString();
if (errorValue.isString()) return errorValue.toString();
return QString();
}
QString LlmClient::serverErrorMessage(
const QByteArray& body, const QString& fallback) {
const QJsonDocument doc = QJsonDocument::fromJson(body);
if (doc.isObject()) {
const QJsonObject obj = doc.object();
if (obj.contains("error")) {
const QJsonValue errorValue = obj["error"];
if (errorValue.isObject()) {
const QString message =
errorValue.toObject()["message"].toString();
if (!message.isEmpty()) return message;
} else if (!errorValue.toString().isEmpty()) {
return errorValue.toString();
}
}
}
const QString message = jsonErrorMessage(body);
if (!message.isEmpty()) return message;
return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed")
: fallback;
}
void LlmClient::setServerError(const QString& value) {
if (m_serverError == value) return;
m_serverError = value;
Q_EMIT serverErrorChanged();
}
void LlmClient::setBusy(bool value) {
if (m_busy == value) return;
m_busy = value;
@@ -175,14 +181,17 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) {
m_refreshTimer.setParent(this);
m_refreshTimer.setInterval(kRefreshIntervalMs);
connect(
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer);
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshAllStates);
m_refreshTimer.start();
}
LlmClient::~LlmClient() {
if (m_reply) m_reply->abort();
if (m_reply) {
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
}
m_tools->cancelAll();
endStream();
}
void LlmClient::setEndpoint(const QString& value) {
@@ -190,7 +199,7 @@ void LlmClient::setEndpoint(const QString& value) {
m_endpoint = value;
Q_EMIT endpointChanged();
m_refreshTimer.start();
refreshFromServer();
refreshAllStates();
}
void LlmClient::setModel(const QString& value) {
@@ -204,9 +213,16 @@ void LlmClient::setTemperature(double value) {
m_temperature = value;
}
void LlmClient::setSandboxEnabled(bool value) {
m_sandboxEnabled = value;
}
QString LlmClient::activeWorkspace() const {
return m_active ? m_active->workspacePath() : QString();
}
void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
if (m_busy || !session || !target) return;
refreshModels();
m_contextRetryUsed = false;
m_active = session;
m_streaming = target;
@@ -242,6 +258,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
probeContextSize([this, session, target]() {
if (m_active != session || m_streaming != target) return;
refreshModels();
sendRound();
});
}
@@ -253,6 +270,7 @@ void LlmClient::sendRound() {
m_callBuilders.clear();
m_callResults.clear();
m_roundDone = false;
m_streamDone = false;
m_toolPhase = false;
m_pendingCalls = 0;
m_buffer.clear();
@@ -308,6 +326,8 @@ void LlmClient::sendRound() {
}
}
appendSandboxPreamble(messages);
QJsonObject body;
QJsonObject streamOptions;
streamOptions[QStringLiteral("include_usage")] = true;
@@ -323,15 +343,24 @@ void LlmClient::sendRound() {
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Accept", "text/event-stream");
m_reply = m_manager.post(request, QJsonDocument(body).toJson());
QNetworkReply* reply =
m_manager.post(request, QJsonDocument(body).toJson());
if (!reply) {
fail(QStringLiteral("Failed to start request to LLM server"));
return;
}
m_reply = reply;
connect(m_reply, &QNetworkReply::readyRead, this, [this]() {
if (m_reply) m_buffer.append(m_reply->readAll());
connect(reply, &QNetworkReply::readyRead, this, [this, reply]() {
if (m_reply != reply) return;
m_buffer.append(reply->readAll());
drainBuffer();
});
connect(m_reply, &QNetworkReply::finished, this, [this]() {
QNetworkReply* reply = m_reply;
if (!reply) return;
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
if (m_reply != reply) {
reply->deleteLater();
return;
}
m_reply = nullptr;
const QNetworkReply::NetworkError error = reply->error();
@@ -343,25 +372,37 @@ void LlmClient::sendRound() {
drainBuffer();
if (!m_streaming) return;
if (error == QNetworkReply::NoError)
roundFinished();
else if (error == QNetworkReply::OperationCanceledError)
finishTurn();
else {
const QString message =
serverErrorMessage(responseBody, errorString);
const int reported = contextSizeFromErrorMessage(message);
if (reported > 0 && reported != m_contextSize &&
!m_contextRetryUsed) {
qWarning() << "LlmClient: prompt overflow, server reports"
<< reported << "tokens; retrying round";
setContextSize(reported);
m_contextRetryUsed = true;
sendRound();
if (error == QNetworkReply::NoError) {
if (m_streamDone) {
roundFinished();
return;
}
fail(message);
const QByteArray tail = m_buffer;
m_buffer.clear();
const QString serverMessage = jsonErrorMessage(tail.simplified());
fail(
serverMessage.isEmpty()
? QStringLiteral(
"LLM server closed the stream before "
"completing the response")
: serverMessage);
return;
}
if (error == QNetworkReply::OperationCanceledError) {
finishTurn();
return;
}
const QString message = serverErrorMessage(responseBody, errorString);
const int reported = contextSizeFromErrorMessage(message);
if (reported > 0 && reported != m_contextSize && !m_contextRetryUsed) {
qWarning() << "LlmClient: prompt overflow, server reports"
<< reported << "tokens; retrying round";
setContextSize(reported);
m_contextRetryUsed = true;
sendRound();
return;
}
fail(message);
});
}
@@ -373,12 +414,51 @@ QJsonArray LlmClient::buildContextMessages(
const auto* message = model->at(row);
const auto* generation = message->activeGeneration();
if (!generation) continue;
const bool hasAnyContent = !generation->content().isEmpty() ||
!generation->reasoning().isEmpty() ||
generation->toolCallCount() > 0;
if (message->role() != ChatMessage::Role::User && !hasAnyContent)
continue;
ContextUnit unit;
if (message->role() == ChatMessage::Role::User) {
QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user");
user[QStringLiteral("content")] = generation->content();
const QString text = generation->content();
const QList<Attachment*> attachments = message->attachments();
if (attachments.isEmpty()) {
user[QStringLiteral("content")] = text;
} else {
QJsonArray parts;
if (!text.isEmpty()) {
QJsonObject textPart;
textPart[QStringLiteral("type")] = QStringLiteral("text");
textPart[QStringLiteral("text")] = text;
parts.append(textPart);
}
for (const auto* attachment : attachments) {
if (attachment->kind() != Attachment::Kind::Image) continue;
QFile file(attachment->path());
if (!file.open(QIODevice::ReadOnly)) continue;
const QByteArray base64 = file.readAll().toBase64();
file.close();
if (base64.isEmpty()) continue;
QJsonObject imageUrl;
imageUrl[QStringLiteral("url")] =
QStringLiteral("data:%1;base64,%2")
.arg(attachment->mimeType())
.arg(QString::fromLatin1(base64));
QJsonObject imagePart;
imagePart[QStringLiteral("type")] =
QLatin1String("image_url");
imagePart[QStringLiteral("image_url")] = imageUrl;
parts.append(imagePart);
}
if (parts.isEmpty())
user[QStringLiteral("content")] = text;
else
user[QStringLiteral("content")] = parts;
}
unit.messages.append(user);
} else {
QList<const LlmSegment*> toolSegments;
@@ -455,6 +535,48 @@ QJsonArray LlmClient::buildContextMessages(
return messages;
}
void LlmClient::appendSandboxPreamble(QJsonArray& messages) {
if (!m_sandboxEnabled) return;
ChatSession* active = m_active;
if (!active) return;
const QString workspace = active->workspacePath();
if (workspace.isEmpty()) return;
const auto* model = active->messagesModel();
QStringList files;
QSet<QString> seenPaths;
for (int row = 0; row < model->rowCount(); ++row) {
const auto* message = model->at(row);
if (message->role() != ChatMessage::Role::User) continue;
for (const auto* attachment : message->attachments()) {
if (attachment->kind() != Attachment::Kind::File) continue;
const QString relativePath =
attachment->path().mid(workspace.size() + 1);
if (seenPaths.contains(relativePath)) continue;
seenPaths.insert(relativePath);
files.append(QStringLiteral("- %1 (available as %2)")
.arg(attachment->name(), relativePath));
}
}
if (files.isEmpty()) return;
QJsonObject preamble;
preamble[QStringLiteral("role")] = QStringLiteral("system");
preamble[QStringLiteral("content")] =
QStringLiteral(
"The user attached the following files to this "
"conversation. They are available in your sandbox "
"directory at %1:\n%2\nruncommand executes shell "
"commands inside a bubblewrap sandbox: the rest of the "
"filesystem is read-only, there is no network access, "
"and only the sandbox directory is writable. Use it to "
"inspect, transform, or extract attached files (for "
"example to extract archives or search across many "
"files). Files you create stay inside the sandbox "
"directory. Use the webfetch tool for anything that "
"needs the network.")
.arg(workspace, files.join(QStringLiteral("\n")));
messages.prepend(preamble);
}
void LlmClient::applyToolCallDelta(const QJsonObject& call) {
ChatGeneration* streaming = m_streaming;
if (!streaming) return;
@@ -688,10 +810,12 @@ void LlmClient::stop() {
finishTurn();
return;
}
if (m_reply)
m_reply->abort();
else
endStream();
if (m_reply) {
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
}
endStream();
}
void LlmClient::endStream() {
@@ -700,6 +824,11 @@ void LlmClient::endStream() {
ChatSession* session = m_active;
m_streaming = nullptr;
m_active = nullptr;
if (m_reply) {
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
}
generation->setStreaming(false);
m_approvalPending = false;
generation->setApprovalPending(false);
@@ -708,7 +837,7 @@ void LlmClient::endStream() {
segment->status() == LlmSegment::Status::Pending)
segment->finishTool(QStringLiteral("Cancelled"), false);
if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
generation->toolCallCount() == 0) {
generation->toolCallCount() == 0 && !generation->error()) {
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
if (message->generationCount() <= 1) {
if (session) session->removeMessage(message);
@@ -730,7 +859,7 @@ void LlmClient::finishTurn() {
m_pendingClear.clear();
}
if (session) session->persist();
refreshFromServer();
refreshAllStates();
}
void LlmClient::clearOnFinish(ChatSession* session) {
@@ -747,6 +876,7 @@ void LlmClient::sessionRemoved(ChatSession* session) {
void LlmClient::fail(const QString& message) {
qWarning() << "LlmClient:" << message;
if (ChatGeneration* streaming = m_streaming) streaming->setError(message);
finishTurn();
Q_EMIT errorOccurred(message);
}
@@ -766,6 +896,7 @@ void LlmClient::handleLine(const QByteArray& line) {
const QByteArray data = line.mid(5).trimmed();
if (data == "[DONE]") {
m_streamDone = true;
roundFinished();
return;
}
@@ -776,8 +907,12 @@ void LlmClient::handleLine(const QByteArray& line) {
updateTokenUsage(obj);
if (obj.contains("error")) {
const QJsonObject error = obj["error"].toObject();
const QString message = error["message"].toString();
const QJsonValue errorValue = obj["error"];
QString message;
if (errorValue.isObject())
message = errorValue.toObject()["message"].toString();
else if (errorValue.isString())
message = errorValue.toString();
fail(
message.isEmpty() ? QStringLiteral("LLM server returned an error")
: message);
@@ -805,25 +940,36 @@ void LlmClient::handleLine(const QByteArray& line) {
}
}
void LlmClient::refreshFromServer() {
if (m_endpoint.trimmed().isEmpty()) return;
probeContextSize();
refreshModels();
void LlmClient::refreshAllStates() {
if (m_endpoint.trimmed().isEmpty()) {
setServerError(QStringLiteral("LLM endpoint is not configured"));
return;
}
probeContextSize([this]() { refreshModels(); });
}
void LlmClient::refreshModels() {
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
if (!url.isValid() || url.host().isEmpty()) return;
if (!url.isValid() || url.host().isEmpty()) {
setServerError(QStringLiteral("LLM endpoint is not configured"));
return;
}
auto* reply = m_manager.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
const QByteArray data = reply->readAll();
reply->deleteLater();
if (error != QNetworkReply::NoError) {
qWarning() << "LlmClient: failed to fetch models:" << error;
const QString serverMessage = jsonErrorMessage(data);
setServerError(
serverMessage.isEmpty()
? QStringLiteral("Cannot reach LLM server: %1")
.arg(errorString)
: serverMessage);
return;
}
const QJsonArray dataArr =
@@ -838,7 +984,12 @@ void LlmClient::refreshModels() {
models.append(id);
}
}
if (models.isEmpty()) return;
if (models.isEmpty()) {
setServerError(QStringLiteral(
"LLM server is running but has no models loaded"));
return;
}
setServerError(QString());
m_availableModels = models;
Q_EMIT availableModelsChanged();
@@ -866,6 +1017,8 @@ void LlmClient::probeContextSize(std::function<void()> done) {
base.chop(1);
const QUrl url = QUrl::fromUserInput(base + "/props");
if (!url.isValid() || url.host().isEmpty()) {
setServerError(
QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint));
if (done) done();
return;
}
@@ -877,11 +1030,13 @@ void LlmClient::probeContextSize(std::function<void()> done) {
*settled = true;
const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
const QByteArray data = reply->readAll();
reply->deleteLater();
int size = 0;
if (error == QNetworkReply::NoError) {
setServerError(QString());
const QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isArray()) {
for (const auto& value : doc.array()) {
@@ -899,6 +1054,13 @@ void LlmClient::probeContextSize(std::function<void()> done) {
.toObject()["n_ctx"]
.toInt(0);
}
} else {
const QString serverMessage = jsonErrorMessage(data);
setServerError(
serverMessage.isEmpty()
? QStringLiteral("Cannot reach LLM server: %1")
.arg(errorString)
: serverMessage);
}
if (size > 0)
setContextSize(size);
+12 -1
View File
@@ -39,16 +39,20 @@ class LlmClient : public QObject {
}
[[nodiscard]] int contextSize() const { return m_contextSize; }
[[nodiscard]] ToolRegistry* tools() const { return m_tools; }
[[nodiscard]] QString serverError() const { return m_serverError; }
void setEndpoint(const QString& value);
void setModel(const QString& value);
void setTemperature(double value);
void setContextSize(int size);
void probeContextSize(std::function<void()> done = {});
Q_INVOKABLE void refreshFromServer();
void refreshAllStates();
[[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
void setToolsEnabled(bool value) { m_tools->setEnabled(value); }
[[nodiscard]] bool sandboxEnabled() const { return m_sandboxEnabled; }
void setSandboxEnabled(bool value);
[[nodiscard]] QString activeWorkspace() const;
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
[[nodiscard]] ChatSession* streamingSession() const { return m_active; }
@@ -74,6 +78,7 @@ class LlmClient : public QObject {
void toolsEnabledChanged();
void streamingChatIdChanged();
void errorOccurred(const QString& message);
void serverErrorChanged();
void titleSuggested(ZShell::llm::ChatSession* session, const QString& title);
void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon);
@@ -89,6 +94,7 @@ class LlmClient : public QObject {
void sendRound();
QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
void appendSandboxPreamble(QJsonArray& messages);
void applyToolCallDelta(const QJsonObject& call);
void roundFinished();
void executeAllCalls();
@@ -102,6 +108,7 @@ class LlmClient : public QObject {
void updateTokenUsage(const QJsonObject& data);
void setBusy(bool value);
void setStreamingChatId(const QString& id);
void setServerError(const QString& value);
void shortRequest(
const QString& tag,
const QString& systemPrompt,
@@ -109,6 +116,7 @@ class LlmClient : public QObject {
std::function<void(QString result)> onResult);
static QString completionsPath(
const QString& endpoint, const QString& subpath);
static QString jsonErrorMessage(const QByteArray& body);
static QString serverErrorMessage(
const QByteArray& body, const QString& fallback);
@@ -125,6 +133,7 @@ class LlmClient : public QObject {
QString m_endpoint;
QString m_model;
QStringList m_availableModels;
QString m_serverError;
double m_temperature = 0.7;
int m_contextSize = 0;
@@ -139,7 +148,9 @@ class LlmClient : public QObject {
int m_round = 0;
qsizetype m_contentMark = 0;
qsizetype m_reasoningMark = 0;
bool m_sandboxEnabled = true;
bool m_roundDone = false;
bool m_streamDone = false;
bool m_toolPhase = false;
bool m_approvalPending = false;
bool m_contextRetryUsed = false;
+1 -6
View File
@@ -11,12 +11,7 @@ class LlmMarkdown : public QObject {
QML_UNCREATABLE("Blocks are produced by LlmSegment")
public:
enum class Type : int {
Text = 0, // Paragraph, list, quote, table; "text" is markdown source
Heading, // "level" + "text" (markdown source of the content)
Code, // "language" + "code"
Math // "latex" (display math, without the $$ delimiters)
};
enum class Type : int { Text = 0, Heading, Code, Math };
Q_ENUM(Type)
explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {}
+182 -42
View File
@@ -10,9 +10,14 @@
#include <QFile>
#include <QFontDatabase>
#include <QHash>
#include <QMutex>
#include <QMutexLocker>
#include <QPainter>
#include <QPointer>
#include <QThreadPool>
#include <QtQuick/QQuickImageProvider>
namespace ZShell::llm {
namespace {
@@ -71,26 +76,27 @@ const LatinModern& loadLatinModern() {
return fonts;
}
} // namespace
LlmMathText::LlmMathText(QObject* parent)
: QObject(parent)
, m_renderer(
std::make_shared<JKQTMathText>(nullptr, /* useFontsForGUI */ true)) {
void configureLatinModern(JKQTMathText& renderer) {
const LatinModern& fonts = loadLatinModern();
if (fonts.roman)
m_renderer->setFontRomanAndMath(
renderer.setFontRomanAndMath(
QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode);
if (fonts.math) {
m_renderer->setFontMathRoman(
renderer.setFontMathRoman(
QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFallbackFontSymbols(
renderer.setFallbackFontSymbols(
QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
}
}
} // namespace
LlmMathText::LlmMathText(QObject* parent) : QObject(parent) {
loadLatinModern();
}
void LlmMathText::setLatex(const QString& value) {
if (m_latex == value) return;
m_latex = value;
@@ -120,23 +126,88 @@ namespace {
struct MathRender {
bool ok = false;
QImage image;
QUrl url;
QString id;
qreal width = 0;
qreal height = 0;
};
QHash<QString, MathRender>& mathCache() {
static QHash<QString, MathRender> cache;
return cache;
struct MathStore {
QMutex mutex;
QHash<QString, MathRender> entries;
QStringList order;
QHash<QString, QString> idIndex;
QHash<QString, int> idRefs;
qint64 bytes = 0;
quint64 nextId = 1;
};
MathStore& mathStore() {
static MathStore store;
return store;
}
qint64 imageBytes(const QImage& image) {
return image.isNull() ? 0 : static_cast<qint64>(image.sizeInBytes());
}
void retainMathId(const QString& id) {
if (id.isEmpty()) return;
auto& store = mathStore();
QMutexLocker locker(&store.mutex);
++store.idRefs[id];
}
void releaseMathId(const QString& id) {
if (id.isEmpty()) return;
auto& store = mathStore();
QMutexLocker locker(&store.mutex);
auto it = store.idRefs.find(id);
if (it == store.idRefs.end()) return;
if (--it.value() <= 0) store.idRefs.erase(it);
}
} // namespace
namespace {
constexpr qint64 kMaxCacheBytes = 64 * 1024 * 1024;
class MathImageProvider : public QQuickImageProvider {
public:
MathImageProvider() : QQuickImageProvider(QQuickImageProvider::Image) {}
QImage requestImage(
const QString& id, QSize* size, const QSize& requestedSize) override {
Q_UNUSED(requestedSize)
auto& store = mathStore();
QMutexLocker locker(&store.mutex);
const auto keyIt = store.idIndex.constFind(id);
if (keyIt == store.idIndex.constEnd()) return {};
const QString key = keyIt.value();
const auto entryIt = store.entries.constFind(key);
if (entryIt == store.entries.constEnd()) return {};
if (size) *size = entryIt->image.size();
const qsizetype pos = store.order.indexOf(key);
if (pos >= 0) store.order.move(pos, store.order.size() - 1);
return entryIt->image;
}
};
void ensureImageProvider(QObject* owner) {
if (auto* engine = qmlEngine(owner))
if (!engine->imageProvider(QStringLiteral("llmmath")))
engine->addImageProvider(
QStringLiteral("llmmath"), new MathImageProvider());
}
} // namespace
void LlmMathText::reRender() {
ensureImageProvider(this);
++m_requestId;
if (m_latex.trimmed().isEmpty()) {
m_image = QImage();
m_imageUrl = QUrl();
releaseMathId(m_imageId);
m_imageId.clear();
m_width = 0;
m_height = 0;
m_ok = false;
@@ -147,12 +218,30 @@ void LlmMathText::reRender() {
const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() +
QLatin1Char(0x1f) + QString::number(m_fontPointSize) +
QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
if (auto it = mathCache().find(key); it != mathCache().end()) {
m_image = it->image;
m_imageUrl = it->url;
m_width = it->width;
m_height = it->height;
m_ok = it->ok;
bool cacheHit = false;
QString hitId;
qreal hitW = 0;
qreal hitH = 0;
bool hitOk = false;
{
auto& store = mathStore();
QMutexLocker locker(&store.mutex);
const auto it = store.entries.constFind(key);
if (it != store.entries.constEnd()) {
cacheHit = true;
hitId = it->id;
hitW = it->width;
hitH = it->height;
hitOk = it->ok;
}
}
if (cacheHit) {
releaseMathId(m_imageId);
retainMathId(hitId);
m_imageId = hitId;
m_width = hitW;
m_height = hitH;
m_ok = hitOk;
Q_EMIT changed();
return;
}
@@ -164,33 +253,46 @@ void LlmMathText::reRender() {
const QColor color = m_color;
const double pointSize = m_fontPointSize;
const qreal dpr = m_devicePixelRatio;
auto renderer = m_renderer;
QThreadPool::globalInstance()->start(
[this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() {
[this, id = m_requestId, key, latex, color, pointSize, dpr]() {
JKQTMathText renderer(nullptr, true);
configureLatinModern(renderer);
MathRender render;
renderer->setFontPointSize(pointSize);
renderer->setFontColor(color);
if (renderer->parse(
renderer.setFontPointSize(pointSize);
renderer.setFontColor(color);
if (renderer.parse(
latex,
JKQTMathText::LatexParser,
JKQTMathText::DefaultParseOptions)) {
const QImage image = renderer->drawIntoImage(
/* drawBoxes */ false,
{
QImage scratch(1, 1, QImage::Format_ARGB32);
QPainter painter(&scratch);
const QSizeF size = renderer.getSize(painter);
render.width = size.width() + 2 * kRenderMargin;
render.height = size.height() + 2 * kRenderMargin;
}
QPointer<LlmMathText> sizeGuard(this);
const qreal sizeW = render.width;
const qreal sizeH = render.height;
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[sizeGuard, id, sizeW, sizeH]() {
LlmMathText* self = sizeGuard;
if (!self || id != self->m_requestId) return;
self->m_width = sizeW;
self->m_height = sizeH;
Q_EMIT self->changed();
},
Qt::QueuedConnection);
const QImage image = renderer.drawIntoImage(
false,
QColor(Qt::transparent),
kRenderMargin,
dpr,
kResolutionDpi);
if (!image.isNull()) {
QByteArray png;
{
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
}
render.image = image;
render.url = QUrl(
QStringLiteral("data:image/png;base64,") +
QString::fromLatin1(png.toBase64()));
render.width = image.width() / dpr;
render.height = image.height() / dpr;
render.ok = true;
@@ -208,12 +310,46 @@ void LlmMathText::reRender() {
return;
}
if (render.ok) {
auto& cache = mathCache();
if (cache.size() >= kCacheLimit) cache.clear();
cache.insert(key, render);
auto& store = mathStore();
QMutexLocker locker(&store.mutex);
const bool isNew = !store.entries.contains(key);
if (isNew)
render.id = QString::number(store.nextId++);
else
render.id = store.entries[key].id;
store.idIndex.insert(render.id, key);
if (isNew) {
const qint64 newBytes = imageBytes(render.image);
while (store.order.size() >= kCacheLimit ||
store.bytes + newBytes > kMaxCacheBytes) {
bool evicted = false;
for (int i = 0; i < store.order.size(); ++i) {
const QString oldKey = store.order.at(i);
const auto oldIt =
store.entries.constFind(oldKey);
if (oldIt == store.entries.constEnd()) {
store.order.removeAt(i);
continue;
}
if (store.idRefs.value(oldIt->id, 0) > 0)
continue;
store.order.removeAt(i);
store.bytes -= imageBytes(oldIt->image);
store.idIndex.remove(oldIt->id);
store.entries.remove(oldKey);
evicted = true;
break;
}
if (!evicted) break;
}
store.entries.insert(key, render);
store.order.append(key);
store.bytes += imageBytes(render.image);
}
}
self->m_image = render.image;
self->m_imageUrl = render.url;
releaseMathId(self->m_imageId);
retainMathId(render.id);
self->m_imageId = render.id;
self->m_width = render.width;
self->m_height = render.height;
self->m_ok = render.ok;
@@ -223,4 +359,8 @@ void LlmMathText::reRender() {
});
}
LlmMathText::~LlmMathText() {
releaseMathId(m_imageId);
}
} // namespace ZShell::llm
+4 -13
View File
@@ -1,16 +1,10 @@
#pragma once
#include <QColor>
#include <QImage>
#include <QObject>
#include <QString>
#include <QUrl>
#include <QtQml>
#include <memory>
#include <jkqtmathtext/jkqtmathtext.h>
namespace ZShell::llm {
class LlmMathText : public QObject {
@@ -25,14 +19,14 @@ class LlmMathText : public QObject {
Q_PROPERTY(
qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio
NOTIFY changed)
Q_PROPERTY(QImage image READ image NOTIFY changed)
Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed)
Q_PROPERTY(QString imageId READ imageId NOTIFY changed)
Q_PROPERTY(qreal width READ width NOTIFY changed)
Q_PROPERTY(qreal height READ height NOTIFY changed)
Q_PROPERTY(bool ok READ ok NOTIFY changed)
public:
explicit LlmMathText(QObject* parent = nullptr);
~LlmMathText() override;
[[nodiscard]] QString latex() const { return m_latex; }
void setLatex(const QString& value);
@@ -42,8 +36,7 @@ class LlmMathText : public QObject {
void setFontPointSize(double value);
[[nodiscard]] qreal devicePixelRatio() const { return m_devicePixelRatio; }
void setDevicePixelRatio(qreal value);
[[nodiscard]] QImage image() const { return m_image; }
[[nodiscard]] QUrl imageUrl() const { return m_imageUrl; }
[[nodiscard]] QString imageId() const { return m_imageId; }
[[nodiscard]] qreal width() const { return m_width; }
[[nodiscard]] qreal height() const { return m_height; }
[[nodiscard]] bool ok() const { return m_ok; }
@@ -54,13 +47,11 @@ class LlmMathText : public QObject {
private:
void reRender();
std::shared_ptr<JKQTMathText> m_renderer;
QString m_latex;
QColor m_color;
double m_fontPointSize = 12.0;
qreal m_devicePixelRatio = 1.0;
QImage m_image;
QUrl m_imageUrl;
QString m_imageId;
qreal m_width = 0;
qreal m_height = 0;
bool m_ok = false;
+14
View File
@@ -57,6 +57,20 @@ void ChatMessage::setActiveGeneration(int index) {
setActiveInternal(index);
}
void ChatMessage::setAttachments(QList<Attachment*> attachments) {
QList<Attachment*> stale;
for (Attachment* attachment : m_attachments)
if (!attachments.contains(attachment)) stale.append(attachment);
m_attachments.clear();
for (Attachment* attachment : attachments) {
attachment->setParent(this);
m_attachments.append(attachment);
}
Q_EMIT attachmentsChanged();
for (Attachment* attachment : stale)
attachment->deleteLater();
}
void ChatMessage::edit(const QString& newContent) {
if (auto* generation = activeGeneration())
generation->setContent(newContent);
+10
View File
@@ -1,5 +1,6 @@
#pragma once
#include "attachment.hpp"
#include "generation.hpp"
#include <QList>
@@ -27,6 +28,9 @@ class ChatMessage : public QObject {
Q_PROPERTY(
int activeGenerationIndex READ activeGenerationIndex NOTIFY
activeGenerationChanged)
Q_PROPERTY(
QList<ZShell::llm::Attachment*> attachments READ attachments NOTIFY
attachmentsChanged)
public:
enum class Role : int { User = 0, Assistant };
@@ -46,12 +50,16 @@ class ChatMessage : public QObject {
return generation(m_active);
}
[[nodiscard]] int activeGenerationIndex() const { return m_active; }
[[nodiscard]] QList<Attachment*> attachments() const {
return m_attachments;
}
[[nodiscard]] ChatGeneration* generation(int index) const {
if (index < 0 || index >= m_generations.size()) return nullptr;
return m_generations.at(index);
}
Q_INVOKABLE void setActiveGeneration(int index);
void setAttachments(QList<Attachment*> attachments);
Q_INVOKABLE void edit(const QString& newContent);
Q_INVOKABLE void retry();
Q_INVOKABLE void generate();
@@ -63,6 +71,7 @@ class ChatMessage : public QObject {
Q_SIGNALS:
void generationsChanged();
void activeGenerationChanged();
void attachmentsChanged();
private:
void setActiveInternal(int index);
@@ -70,6 +79,7 @@ class ChatMessage : public QObject {
Role m_role;
qint64 m_timestamp;
QList<ChatGeneration*> m_generations;
QList<Attachment*> m_attachments;
int m_active = -1;
};
+1 -1
View File
@@ -11,7 +11,7 @@ namespace ZShell::llm {
namespace {
constexpr int kMarkdownRefreshMs = 150;
} // namespace
}
LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent)
: QObject(parent), m_type(type), m_timestamp(timestamp) {
+174 -6
View File
@@ -1,16 +1,31 @@
#include "session.hpp"
#include "attachment.hpp"
#include "chatstore.hpp"
#include "llmclient.hpp"
#include <QClipboard>
#include <QDebug>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImage>
#include <QMimeDatabase>
#include <QMimeData>
#include <QMimeType>
#include <QStandardPaths>
#include <QUrl>
#include <QtGlobal>
namespace ZShell::llm {
namespace {
constexpr qint64 kMaxImageBytes = 20 * 1024 * 1024;
constexpr qint64 kMaxFileBytes = 100 * 1024 * 1024;
QString titleFrom(const QString& content) {
const QString flat = content.simplified();
if (flat.isEmpty()) return QString();
@@ -21,7 +36,7 @@ QString titleFrom(const QString& content) {
} // namespace
ChatSession::ChatSession(const QString& id, QObject* parent)
: QObject(parent), m_id(id) {
: QObject(parent), m_id(id), m_workspace(sandboxDirFor(id)) {
m_model = new ChatMessageModel(this, this);
connect(
m_model,
@@ -157,26 +172,179 @@ void ChatSession::startGeneration(ChatMessage* target) {
persist();
}
QString ChatSession::sandboxDirFor(const QString& sessionId) {
const QString state =
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation);
return state + QStringLiteral("/zshell/sandboxes/") + sessionId;
}
void ChatSession::ensureWorkspace() {
QDir().mkpath(m_workspace);
}
void ChatSession::setAttachmentError(const QString& value) {
if (m_attachmentError == value) return;
m_attachmentError = value;
Q_EMIT attachmentErrorChanged();
}
QVariantList ChatSession::addAttachments(const QStringList& paths) {
ensureWorkspace();
setAttachmentError(QString());
QVariantList result;
for (const QString& rawPath : paths) {
const QFileInfo info(rawPath);
if (!info.exists() || !info.isFile()) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": file not found or not a file";
setAttachmentError(
QStringLiteral("File not found: %1").arg(info.fileName()));
continue;
}
const QString mimeType = QMimeDatabase().mimeTypeForFile(info).name();
const bool isImage =
mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive);
const qint64 cap = isImage ? kMaxImageBytes : kMaxFileBytes;
if (info.size() > cap) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": too large (" << info.size() << "bytes, cap" << cap
<< ")";
setAttachmentError(
QStringLiteral("File too large: %1").arg(info.fileName()));
continue;
}
QString destination = m_workspace + QLatin1Char('/') + info.fileName();
int suffix = 1;
while (QFile::exists(destination)) {
const QString extension = info.suffix().isEmpty()
? QString()
: QLatin1Char('.') + info.suffix();
destination = m_workspace + QLatin1Char('/') +
info.completeBaseName() +
QStringLiteral("_%1%2").arg(suffix).arg(extension);
++suffix;
}
if (!QFile::copy(info.absoluteFilePath(), destination)) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": copy to" << destination << "failed";
setAttachmentError(
QStringLiteral("Could not copy file: %1").arg(info.fileName()));
continue;
}
auto* attachment = new Attachment(
info.fileName(),
destination,
isImage ? Attachment::Kind::Image : Attachment::Kind::File,
mimeType,
info.size(),
this);
result.append(QVariant::fromValue(attachment));
}
return result;
}
bool ChatSession::clipboardHasAttachment() {
QClipboard* clipboard = QGuiApplication::clipboard();
if (!clipboard) return false;
const QMimeData* data = clipboard->mimeData();
if (!data) return false;
if (data->hasImage()) return true;
for (const QUrl& url : data->urls())
if (url.isLocalFile()) return true;
return false;
}
QVariantList ChatSession::pasteAttachments() {
QClipboard* clipboard = QGuiApplication::clipboard();
const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr;
if (!data) {
qWarning() << "ChatSession: pasteAttachments: no clipboard data";
return {};
}
qInfo() << "ChatSession: pasteAttachments: formats =" << data->formats()
<< "hasImage =" << data->hasImage()
<< "hasUrls =" << data->hasUrls() << "hasText =" << data->hasText();
QStringList paths;
QString tempImagePath;
if (data->hasImage()) {
const QImage image = clipboard->image();
qInfo() << "ChatSession: pasteAttachments: image"
<< (image.isNull() ? "null"
: QString::number(image.width()) + "x" +
QString::number(image.height()));
if (image.isNull()) {
setAttachmentError(
QStringLiteral("Clipboard image could not be decoded"));
return {};
}
tempImagePath =
QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png")
.arg(QDateTime::currentMSecsSinceEpoch());
if (!image.save(tempImagePath)) {
qWarning() << "ChatSession: pasteAttachments: saving image to"
<< tempImagePath << "failed";
setAttachmentError(
QStringLiteral("Could not save the pasted image"));
return {};
}
paths.append(tempImagePath);
} else if (data->hasUrls()) {
for (const QUrl& url : data->urls()) {
qInfo() << "ChatSession: pasteAttachments: url =" << url.toString()
<< "local =" << url.isLocalFile();
if (url.isLocalFile()) paths.append(url.toLocalFile());
}
}
if (paths.isEmpty()) {
if (data->hasImage() || data->hasUrls())
setAttachmentError(QStringLiteral(
"Nothing usable on the clipboard (expected an image "
"or copied files)"));
return {};
}
const QVariantList attachments = addAttachments(paths);
if (!tempImagePath.isEmpty()) QFile::remove(tempImagePath);
qInfo() << "ChatSession: pasteAttachments: result =" << attachments.size()
<< "attachment(s)";
return attachments;
}
void ChatSession::sendMessage(const QString& text) {
sendMessage(text, QVariantList());
}
void ChatSession::sendMessage(
const QString& text, const QVariantList& attachments) {
QList<Attachment*> attachmentList;
for (const QVariant& variant : attachments)
if (auto* attachment =
qobject_cast<Attachment*>(variant.value<QObject*>()))
attachmentList.append(attachment);
const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) return;
if (trimmed.isEmpty() && attachmentList.isEmpty()) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
const qint64 now = QDateTime::currentMSecsSinceEpoch();
appendNewest(ChatMessage::Role::User, trimmed, now);
auto* userMessage = appendNewest(ChatMessage::Role::User, trimmed, now);
if (!attachmentList.isEmpty()) userMessage->setAttachments(attachmentList);
while (m_model->rowCount() > 200)
m_model->removeMessage(m_model->at(m_model->rowCount() - 1));
if (m_title.isEmpty()) {
m_title = titleFrom(trimmed);
QString titleSource = trimmed;
if (titleSource.isEmpty() && !attachmentList.isEmpty())
titleSource = attachmentList.first()->name();
m_title = titleFrom(titleSource);
Q_EMIT titleChanged();
qInfo() << "ChatSession:" << m_id << "new conversation,"
<< "fallback title" << m_title
<< "- requesting generated title and icon";
if (auto* clientObject = client()) {
clientObject->requestTitle(this, trimmed);
clientObject->requestIcon(this, trimmed);
clientObject->requestTitle(this, titleSource);
clientObject->requestIcon(this, titleSource);
}
}
auto* assistant =
+21
View File
@@ -6,6 +6,8 @@
#include <QDateTime>
#include <QObject>
#include <QString>
#include <QStringList>
#include <QVariantList>
#include <QtQml>
namespace ZShell::llm {
@@ -27,6 +29,10 @@ class ChatSession : public QObject {
Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged)
Q_PROPERTY(
ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT)
Q_PROPERTY(QString workspace READ workspacePath CONSTANT)
Q_PROPERTY(
QString attachmentError READ attachmentError NOTIFY
attachmentErrorChanged)
public:
explicit ChatSession(const QString& id, QObject* parent = nullptr);
@@ -55,6 +61,13 @@ class ChatSession : public QObject {
[[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; }
void setLastTokenCount(int value);
[[nodiscard]] QString workspacePath() const { return m_workspace; }
[[nodiscard]] QString attachmentError() const { return m_attachmentError; }
void setAttachmentError(const QString& value);
static QString sandboxDirFor(const QString& sessionId);
void ensureWorkspace();
void setTitle(const QString& value);
void setIcon(const QString& value);
void setUpdatedAt(qint64 value);
@@ -71,7 +84,12 @@ class ChatSession : public QObject {
void removeMessage(ChatMessage* message);
void clearMessages();
Q_INVOKABLE QVariantList addAttachments(const QStringList& paths);
Q_INVOKABLE bool clipboardHasAttachment();
Q_INVOKABLE QVariantList pasteAttachments();
Q_INVOKABLE void sendMessage(const QString& text);
Q_INVOKABLE void sendMessage(
const QString& text, const QVariantList& attachments);
Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target);
Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message);
Q_INVOKABLE void clear();
@@ -83,6 +101,7 @@ class ChatSession : public QObject {
void pinnedChanged();
void messageCountChanged();
void loaded();
void attachmentErrorChanged();
private:
void onModelRowsChanged();
@@ -103,6 +122,8 @@ class ChatSession : public QObject {
bool m_loaded = false;
bool m_clearPending = false;
int m_lastTokenCount = 0;
QString m_workspace;
QString m_attachmentError;
};
} // namespace ZShell::llm
+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