diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 3fdf1db..599dee5 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -171,7 +171,7 @@ Item { Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter elide: Text.ElideRight font: Tokens.font.title.medium - text: qsTr(root.chatData.title) + text: qsTr(root.chatData?.title || ChatState.title) } } @@ -540,17 +540,17 @@ Item { anchors.margins: Tokens.padding.medium bg.border.color: Colors.palette.m3outlineVariant bg.color: Colors.tPalette.m3surfaceContainerLowest - fastGlow: Chat.busy - glow.opacity: input.activeFocus && (!Chat.busy || ChatState.isWindow) + fastGlow: LlmClient.busy + glow.opacity: input.activeFocus && (!LlmClient.busy || ChatState.isWindow) font: Tokens.font.body.small implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) focus: true placeholderText: qsTr("Send a message") sendIcon.font: Tokens.font.icon.medium - sendIcon.icon: Chat.busy ? "stop" : "arrow_upward" + sendIcon.icon: LlmClient.busy ? "stop" : "arrow_upward" sendIcon.padding: Tokens.padding.extraSmall - sendIcon.opacity: text || Chat.busy ? 1.0 : 0.5 - sendIcon.enabled: text || Chat.busy + sendIcon.opacity: text || LlmClient.busy ? 1.0 : 0.5 + sendIcon.enabled: text || LlmClient.busy Component.onCompleted: { root.focusInput(); @@ -572,8 +572,8 @@ Item { } } onButtonPressed: { - if (Chat.busy) - Chat.stop(); + if (LlmClient.busy) + LlmClient.stop(); else root.send(text); } diff --git a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml index b7a6721..3480d08 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -161,10 +161,6 @@ CustomClippingRect { CustomRect { id: fadeMask - // anchors.top: titleText.top - // anchors.bottom: titleText.bottom - // anchors.left: titleText.left - // anchors.right: parent.right anchors.fill: titleWrapper layer.enabled: true @@ -259,7 +255,7 @@ CustomClippingRect { font: Tokens.font.body.small maximumLineCount: 1 opacity: !root.expanded && !root.slim ? 1 : 0 - text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet") + text: root.modelData.messagesModel.lastMessage?.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet") Behavior on y { Anim {} @@ -284,7 +280,7 @@ CustomClippingRect { color: Colors.palette.m3onSurfaceVariant maximumLineCount: 5 opacity: root.expanded ? 1 : 0 - text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? qsTr("No messages yet") + text: root.modelData.messagesModel.lastMessage?.content ?? qsTr("No messages yet") wrapMode: Text.WrapAtWordBoundaryOrAnywhere Behavior on y { diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index 3b65771..adf488c 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -5,6 +5,7 @@ import QtQuick import QtQuick.Layouts import ZShell.Llm import ZShell.Config +import qs.Modules.Notifications.Sidebar.Chat.Content import qs.Services import qs.Components @@ -21,8 +22,13 @@ Item { signal loadChatRequest(content: ChatSession, index: int) signal newChatRequest + onSlimChanged: { + if (slim) + modelsContainer.open = false; + } + CustomText { - id: header + id: chatHeader anchors.left: parent.left anchors.margins: Tokens.padding.extraSmall @@ -41,7 +47,7 @@ Item { anchors.bottom: modelsContainer.top anchors.left: parent.left anchors.right: parent.right - anchors.top: header.bottom + anchors.top: chatHeader.bottom anchors.topMargin: Tokens.spacing.medium clip: true spacing: Tokens.spacing.medium @@ -98,156 +104,24 @@ Item { } } - CustomClippingRect { + ModelsContainer { id: modelsContainer - property bool expanded: false - anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: fabRoot.left anchors.margins: Tokens.padding.small anchors.rightMargin: Tokens.spacing.small - implicitHeight: expanded ? 400 : fabRoot.implicitHeight - radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2 + rootParent: root + fabRoot: fabRoot - function prettyModelName(): string { - if (modelsList.count < 1) - return qsTr("No models found"); + opacity: root.slim ? 0 : 1 + visible: opacity > 0 - const pathList = Chat.model.split("\/"); - const name = pathList[pathList.length - 1]; - - if (!name) - return qsTr("No model selected"); - - return name; - } - - Behavior on implicitHeight { - Anim {} - } - - Behavior on radius { - Anim {} - } - - Item { - id: modelName - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - implicitHeight: fabRoot.implicitHeight - - CustomText { - id: label - text: modelsContainer.prettyModelName() - color: Colors.palette.m3onSurfaceVariant - anchors.left: parent.left - anchors.leftMargin: Tokens.padding.large - anchors.verticalCenter: parent.verticalCenter - } - - MaterialIcon { - color: Colors.palette.m3onSurfaceVariant - anchors.right: parent.right - anchors.rightMargin: Tokens.padding.large - anchors.verticalCenter: parent.verticalCenter - text: modelsContainer.expanded ? "unfold_less" : "unfold_more" - animate: true - fontStyle: Tokens.font.icon.large - visible: modelsList.count > 0 - } - - StateLayer { - id: modelLayer - enabled: modelsList.count > 0 - - onClicked: { - ChatState.fabExpanded = false; - modelsContainer.expanded = !modelsContainer.expanded; - } - radius: modelsContainer.radius - } - } - - VerticalFadeListView { - id: modelsList - - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.top: modelName.bottom - anchors.margins: Tokens.padding.medium - anchors.topMargin: Tokens.spacing.medium - model: Chat.availableModels - opacity: modelsContainer.expanded ? 1 : 0 - currentIndex: Chat.availableModels.indexOf(Chat.model) - visible: opacity > 0 - - Behavior on opacity { - Anim {} - } - - CustomScrollBar.vertical: CustomScrollBar { - flickable: modelsList - } - - delegate: CustomRect { - id: model - - required property var modelData - required property int index - readonly property bool selected: model.ListView.view.currentIndex === model.index - - implicitWidth: ListView.view.width - implicitHeight: layout.implicitHeight + Tokens.padding.small * 2 - radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium - color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0) - - Behavior on radius { - Anim { - type: Anim.SlowEffects - } - } - - RowLayout { - id: layout - - anchors.fill: parent - anchors.margins: Tokens.padding.small - anchors.leftMargin: Tokens.padding.large - anchors.rightMargin: Tokens.padding.large - - CustomText { - text: model.modelData - color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface - } - - MaterialIcon { - Layout.alignment: Qt.AlignRight - visible: model.selected - color: Colors.palette.m3onTertiaryContainer - fontStyle: Tokens.font.icon.normal - opacity: model.selected ? 1 : 0 - text: "check" - - Behavior on opacity { - Anim { - type: Anim.SlowEffects - } - } - } - } - - StateLayer { - id: stateLayer - - onClicked: { - Chat.selectModel(model.modelData); - } - } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects } } } @@ -262,19 +136,11 @@ Item { font.pointSize: Math.round(18 * 1.2) icon: root.slim ? "left_panel_open" : "add" isRound: ChatState.fabExpanded - // opacity: root.slim ? 0 : 1 - // visible: opacity > 0 - // - // Behavior on opacity { - // Anim { - // type: Anim.DefaultEffects - // } - // } label.transform: Rotation { origin.y: fabRoot.label.height / 2 origin.x: fabRoot.label.width / 2 - angle: ChatState.fabExpanded ? 135 : 0 + angle: ChatState.fabExpanded && (ChatState.narrowSidebarExpanded && !ChatState.isWide || !ChatState.isWindow) ? 135 : 0 Behavior on angle { Anim {} @@ -287,7 +153,6 @@ Item { return; } - modelsContainer.expanded = false; ChatState.fabExpanded = !ChatState.fabExpanded; } diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml index 6f8c765..b7a5959 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -17,15 +17,26 @@ Item { anchors.margins: Tokens.padding.small Component.onCompleted: { + if (ChatState.firstOpen) { + ChatState.openDraft(); + stack.push(chatList); + stack.push(chatContent); + ChatState.firstOpen = false; + return; + } + if (ChatState.inChat) { stack.push(chatList); - stack.push(chatContent, { - "chatData": ChatState.chatSession - }); + stack.push(chatContent); } else stack.push(chatList); } + function openChat(): void { + if (stack.depth < 2) + stack.push(chatContent); + } + StackView { id: stack anchors.fill: parent @@ -35,27 +46,23 @@ Item { id: chatList ChatList { - model: ScriptModel { - values: Chat.chats.values - } + model: LlmClient.chats onDeleteChatRequest: chat => { - Chat.chats.remove(chat); + const wasCurrent = chat === ChatState.chatSession; + LlmClient.chats.remove(chat); + if (wasCurrent) + ChatState.openDraft(); } onLoadChatRequest: (chat, index) => { - stack.push(chatContent, { - "chatData": chat - }); - ChatState.inChat = true; ChatState.chatSession = chat; + openChat(); + ChatState.inChat = true; } onNewChatRequest: { - const data = Chat.chats.insert(); - stack.push(chatContent, { - "chatData": data - }); + ChatState.openDraft(); + openChat(); ChatState.inChat = true; - ChatState.chatSession = data; } } } @@ -64,9 +71,12 @@ Item { id: chatContent ChatContent { + chatData: ChatState.chatSession + onRequestClose: { stack.pop(); ChatState.inChat = false; + ChatState.openDraft(); } } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml index 0240a94..5536f1d 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatState.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -8,9 +8,10 @@ import qs.Services Singleton { id: root - property ChatSession chatSession - readonly property string title: chatSession ? chatSession.title : "Chat" + property ChatSession chatSession: LlmClient.chats.draft() + readonly property string title: chatSession?.title || "Chat" property int currentIdx: -1 + property bool firstOpen: true property bool inChat: false property bool fabExpanded: false property bool isWindow: false @@ -22,6 +23,11 @@ Singleton { property ShellScreen screen property bool sidebarVisible: ShellState.forActive().sidebar + function openDraft(): void { + root.chatSession = LlmClient.chats.draft(); + root.currentIdx = -1; + } + onSidebarVisibleChanged: if (!sidebarVisible) fabExpanded = false } diff --git a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml index aa018d6..1795ff3 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml @@ -9,12 +9,11 @@ import qs.Services Item { id: root - required property ChatGeneration current required property TextEdit edit required property bool hovered required property bool isUser required property ChatMessage message - required property LlmSegment segment + required property bool running implicitHeight: editButton.implicitHeight implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small @@ -63,7 +62,7 @@ Item { id: editTools anchors.right: parent.right - opacity: !root.current.streaming && root.hovered ? 1 : 0 + opacity: !root.running && root.hovered ? 1 : 0 spacing: Tokens.spacing.small visible: opacity > 0 @@ -99,7 +98,7 @@ Item { Anim {} } - onClicked: Quickshell.clipboardText = root.current.content + onClicked: Quickshell.clipboardText = root.message.content } IconButton { diff --git a/Modules/Notifications/Sidebar/Chat/Content/ApprovalPrompt.qml b/Modules/Notifications/Sidebar/Chat/Content/ApprovalPrompt.qml new file mode 100644 index 0000000..c40a051 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ApprovalPrompt.qml @@ -0,0 +1,400 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Components +import ZShell.Llm +import ZShell.Blobs +import qs.Components +import qs.Services + +Item { + id: root + + required property Item rootParent + required property ChatGeneration current + + implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2 + + CustomRect { + id: approvalBg + + anchors.fill: parent + radius: Tokens.rounding.medium + color: Colors.palette.m3surface + border.width: 1 + border.color: Colors.palette.m3outlineVariant + + ColumnLayout { + id: approvalLayout + anchors.fill: parent + anchors.margins: Tokens.padding.medium + spacing: Tokens.spacing.small + + CustomText { + id: requestHeader + text: qsTr("Tool call request") + font: Tokens.font.headline.medium + color: Colors.palette.m3onSurface + } + + Item { + id: toolsBg + + property bool open: false + property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2 + property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight) + property int openWidth: root.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 ? root.rootParent : toolsBg; + 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 = c.x - toolsWrapper.width / 2; + toolsWrapper.y = c.y - toolsWrapper.height / 2; + } + + onOpenChanged: { + if (open) + root.rootParent.interactive = false; + else + root.rootParent.interactive = true; + } + + BlobGroup { + id: blobGroup + + color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer + + Behavior on color { + CAnim {} + } + } + + MouseArea { + id: backdrop + + anchors.fill: parent + enabled: false + hoverEnabled: enabled + preventStealing: true + parent: toolsBg.open ? root.rootParent : toolsBg + + onClicked: toolsBg.open = false + } + + Item { + id: toolsWrapper + + width: toolsBg.width + height: toolBox.implicitHeight + rotation: parent === root.rootParent ? 180 : 0 + z: 1 + + states: State { + name: "open" + when: toolsBg.open + + PropertyChanges { + backdrop.enabled: true + toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased + toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased + toolsBackground.topRightRadius: Tokens.rounding.largeIncreased + toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased + toolsContent.opacity: 1 + toolsWrapper.height: toolsBg.openHeight + toolsWrapper.width: toolsBg.openWidth + toolsWrapper.x: (root.rootParent.width - toolsBg.openWidth) / 2 + toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium + elevation.opacity: 1 + toolBox.opacity: 0 + } + } + transitions: Transition { + id: dialogTransition + + SequentialAnimation { + ScriptAction { + script: toolsBg.reparentWrapper() + } + + Anim { + properties: "x,y" + } + } + + PropertyAction { + property: "enabled" + } + + Anim { + properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" + type: Anim.DefaultEffects + } + + Anim { + properties: "width,height" + } + } + + Elevation { + id: elevation + + anchors.fill: parent + bottomLeftRadius: toolsBackground.bottomLeftRadius + bottomRightRadius: toolsBackground.bottomRightRadius + level: 4 + opacity: 0 + radius: toolsBackground.radius + + transform: Matrix4x4 { + matrix: toolsBackground.deformMatrix + } + } + + BlobRect { + id: toolsBackground + + anchors.fill: parent + bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + deformScale: 0 + group: blobGroup + opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) + } + + CustomRect { + id: toolBox + anchors.left: parent.left + anchors.right: parent.right + height: Math.min(implicitHeight, parent.height) + implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2 + radius: Tokens.rounding.small + + StateLayer { + onClicked: { + toolsBg.open = true; + } + } + + ColumnLayout { + id: toolList + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.medium + + Repeater { + id: repeater + + model: root.current.pendingToolCalls?.slice(0, 4) ?? [] + + delegate: RowLayout { + id: toolText + + required property var modelData + required property int index + + spacing: Tokens.spacing.medium + + function toolTarget(seg: var): 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); + } + + MaterialIcon { + color: Colors.palette.m3onPrimaryContainer + text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet" + fill: 1 + } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("%1").arg(toolText.modelData.name) + } + + MaterialIcon { + color: Colors.palette.m3onPrimaryContainer + text: "arrow_right_alt" + } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData)) + } + } + } + + 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 var 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: var): 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: Tokens.font.icon.medium + 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 + + IconTextButton { + icon: "check_circle" + text: qsTr("Allow") + horizontalPadding: Tokens.padding.large + verticalPadding: Tokens.padding.small + isRound: true + shapeMorph: true + onClicked: root.current.approveTools() + } + + IconTextButton { + icon: "cancel" + text: qsTr("Deny") + isRound: true + shapeMorph: true + inactiveColor: Colors.palette.m3error + inactiveOnColor: Colors.palette.m3onError + horizontalPadding: Tokens.padding.large + verticalPadding: Tokens.padding.small + onClicked: root.current.denyTools() + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml index 1c4669c..572970a 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -10,12 +10,13 @@ Item { id: root required property bool isUser - required property LlmSegment segment + required property string text + required property var blocks + required property bool running required property bool hovered required property Repeater repeater required property int index required property ChatMessage message - required property ChatGeneration current readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge @@ -34,7 +35,7 @@ Item { radius: Tokens.rounding.medium color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer implicitWidth: layoutFrozen ? frozenWidth : layoutWidth - implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 + implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : markdown.implicitHeight + markdown.anchors.topMargin * 2 anchors.right: root.isUser ? parent.right : undefined layer.enabled: false layer.smooth: true @@ -74,7 +75,7 @@ Item { TextEditBase { id: msgText - property string cachedText: root.segment.text + property string cachedText: root.text property bool cancelled: false visible: root.isUser @@ -88,7 +89,7 @@ Item { font: Tokens.font.body.small readOnly: true selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4) - text: root.isUser ? root.segment.text : "" + text: root.isUser ? root.text : "" textFormat: CustomText.MarkdownText wrapMode: Text.WordWrap @@ -129,23 +130,22 @@ Item { } } - // Assistant content: parsed markdown blocks. MarkdownBlocks { - id: blocks + id: markdown visible: !root.isUser anchors.topMargin: Tokens.padding.medium anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - blocks: root.segment.markdown + blocks: root.blocks } } Actions { id: actionsRow - readonly property bool shouldBeActive: (root.segment.type === LlmSegment.Type.Content) && !root.segment.running && root.repeater.count === (root.index + 1) + readonly property bool shouldBeActive: !root.running && root.repeater.count === (root.index + 1) anchors.left: parent.left anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right @@ -156,8 +156,7 @@ Item { edit: msgText hovered: root.hovered isUser: root.isUser - segment: root.segment - current: root.current + running: root.running message: root.message Behavior on opacity { diff --git a/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml index d3370e7..af0bfef 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml @@ -5,28 +5,28 @@ import qs.Components import qs.Services ColumnLayout { - id: root + id: root - spacing: Tokens.spacing.small + spacing: Tokens.spacing.small - Item { - Layout.fillHeight: true - } + Item { + Layout.fillHeight: true + } - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - color: Colors.tPalette.m3outlineVariant - font.pointSize: 36 - text: "chat" - } + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3outlineVariant + fontStyle: Tokens.font.icon.extraLarge + text: "chat" + } - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.tPalette.m3onSurfaceVariant - text: qsTr("Send a message to get started") - } + CustomText { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3onSurfaceVariant + text: qsTr("Send a message to get started") + } - Item { - Layout.fillHeight: true - } + Item { + Layout.fillHeight: true + } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml index f3cfca7..9a73c55 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml @@ -108,7 +108,7 @@ Item { wrapMode: CustomText.WordWrap color: Colors.palette.m3onErrorContainer - text: root.current.errorMessage || Chat.serverError + text: root.current.errorMessage || LlmClient.serverError } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml index 877f7e7..1c1117e 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml @@ -23,10 +23,10 @@ Column { role: "type" DelegateChoice { - roleValue: LlmMarkdown.Type.Code + roleValue: MarkdownBlock.Type.Code delegate: CodeBlockView { - required property var modelData + required property MarkdownBlock modelData language: modelData.language code: modelData.code @@ -37,10 +37,10 @@ Column { } DelegateChoice { - roleValue: LlmMarkdown.Type.Math + roleValue: MarkdownBlock.Type.Math delegate: MathBlockView { - required property var modelData + required property MarkdownBlock modelData latex: modelData.latex anchors.right: parent.right @@ -50,10 +50,10 @@ Column { } DelegateChoice { - roleValue: LlmMarkdown.Type.Heading + roleValue: MarkdownBlock.Type.Heading delegate: TextEditBase { - required property var modelData + required property MarkdownBlock modelData color: Colors.palette.m3onSurface anchors.right: parent.right @@ -74,10 +74,10 @@ Column { } DelegateChoice { - roleValue: LlmMarkdown.Type.Text + roleValue: MarkdownBlock.Type.Text delegate: BubbleEdit { - required property var modelData + required property MarkdownBlock modelData anchors.right: parent.right anchors.left: parent.left diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 6d37c87..20e2533 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -19,7 +19,16 @@ MouseArea { required property ChatListView chatList readonly property bool isUser: modelData.role === ChatMessage.Role.User - readonly property var blocks: blockify(current.segments, current.error) + + readonly property var blocks: root.isUser ? [ + { + kind: "content", + id: 0, + text: root.modelData.content, + markdown: [], + running: false + } + ] : blockify(current.segments, current.error) property ChatGeneration current: modelData.activeGeneration property bool reasoningExpanded: false @@ -54,7 +63,9 @@ MouseArea { blocks.push({ kind: "content", id: i, - segments: [seg] + text: seg.text, + markdown: seg.markdown, + running: seg.running }); } else { const last = blocks.length ? blocks[blocks.length - 1] : null; @@ -330,11 +341,12 @@ MouseArea { required property var modelData isUser: root.isUser - segment: modelData.segments[0] + text: modelData.text + blocks: modelData.markdown + running: modelData.running width: root.width repeater: segmentRep message: root.modelData - current: root.current hovered: root.containsMouse onEdit: text => { diff --git a/Modules/Notifications/Sidebar/Chat/Content/ModelsContainer.qml b/Modules/Notifications/Sidebar/Chat/Content/ModelsContainer.qml new file mode 100644 index 0000000..800e456 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ModelsContainer.qml @@ -0,0 +1,351 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Components +import ZShell.Llm +import ZShell.Blobs +import qs.Modules.Notifications.Sidebar.Chat +import qs.Components +import qs.Services + +Item { + id: root + + required property Item rootParent + required property Item fabRoot + + property bool open: false + + property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2 + property int openHeight: Math.min(modelsContent.item?.implicitHeight, maxHeight) + property int openWidth: root.rootParent.width - Tokens.padding.medium * 2 + property real openHeightProg: openHeight / maxHeight + property int collapsedY: 0 + + property int horizontalContentMargin + property string header: qsTr("Available models") + property string acceptLabel: qsTr("Accept") + + z: open || dialogTransition.running ? 2 : 0 + Layout.fillWidth: true + + implicitHeight: fabRoot.implicitHeight + + function prettyModelName(): string { + if (modelsContent.item?.modelsList.count < 1) + return qsTr("No models found"); + + const pathList = LlmClient.model.split("\/"); + const name = pathList[pathList.length - 1]; + + if (!name) + return qsTr("No model selected"); + + return name; + } + + Behavior on implicitHeight { + Anim {} + } + + function reparentWrapper(): void { + const newParent = open ? root.rootParent : root; + const c = modelsWrapper.parent.mapToItem(newParent, modelsWrapper.x + modelsWrapper.width / 2, modelsWrapper.y + modelsWrapper.height / 2); + root.collapsedY = modelsWrapper.y; + modelsWrapper.parent = newParent; + modelsWrapper.x = c.x - modelsWrapper.width / 2; + modelsWrapper.y = c.y - modelsWrapper.height / 2; + } + + BlobGroup { + id: blobGroup + + color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer + + Behavior on color { + CAnim {} + } + } + + MouseArea { + id: backdrop + + anchors.fill: parent + enabled: false + hoverEnabled: enabled + preventStealing: true + parent: root.open ? root.rootParent : root + + onClicked: root.open = false + } + + Item { + id: modelsWrapper + + width: root.width + height: modelBox.implicitHeight + z: 1 + + states: State { + name: "open" + when: root.open + + PropertyChanges { + backdrop.enabled: true + modelsBackground.radius: Tokens.rounding.largeIncreased + modelsContent.opacity: 1 + modelsWrapper.height: root.openHeight + modelsWrapper.width: root.openWidth + modelsWrapper.x: (root.rootParent.width - root.openWidth) / 2 + modelsWrapper.y: root.rootParent.height - root.openHeight - Tokens.spacing.medium - Tokens.padding.medium - root.fabRoot.implicitHeight + elevation.opacity: 1 + modelBox.opacity: 0 + } + } + transitions: Transition { + id: dialogTransition + + SequentialAnimation { + ScriptAction { + script: root.reparentWrapper() + } + + Anim { + properties: "x,y" + } + } + + PropertyAction { + property: "enabled" + } + + Anim { + properties: "opacity,radius" + type: Anim.DefaultEffects + } + + Anim { + properties: "width,height" + } + } + + Elevation { + id: elevation + + anchors.fill: parent + level: 4 + opacity: 0 + radius: modelsBackground.radius + + transform: Matrix4x4 { + matrix: modelsBackground.deformMatrix + } + } + + BlobRect { + id: modelsBackground + + anchors.fill: parent + radius: modelLayer.pressed ? Tokens.rounding.small : Tokens.rounding.extraLarge + deformScale: 0 + group: blobGroup + opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) + + Behavior on radius { + Anim { + type: Anim.DefaultEffects + } + } + } + + CustomRect { + id: modelBox + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + implicitHeight: root.fabRoot.implicitHeight + radius: modelsBackground.radius + + CustomText { + id: label + text: root.prettyModelName() + color: Colors.palette.m3onSurfaceVariant + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + } + + MaterialIcon { + color: Colors.palette.m3onSurfaceVariant + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + text: "unfold_more" + fontStyle: Tokens.font.icon.large + } + + StateLayer { + id: modelLayer + + onClicked: { + ChatState.fabExpanded = false; + root.open = true; + } + } + } + + Loader { + id: modelsContent + + active: opacity > 0 + anchors.fill: parent + asynchronous: false + opacity: 0 + + sourceComponent: MouseArea { + readonly property alias modelsList: modelsList + + onWheel: event => event.accepted = true + preventStealing: true + clip: true + implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins * 2 + + ColumnLayout { + id: innerLayout + anchors.bottomMargin: Tokens.padding.largeIncreased + anchors.fill: parent + anchors.margins: Tokens.padding.extraLarge + spacing: 0 + + CustomText { + font: Tokens.font.title.builders.large.weight(Font.Normal).build() + text: root.header + } + + VerticalFadeListView { + id: modelsList + + Layout.fillWidth: true + Layout.leftMargin: root.horizontalContentMargin + Layout.topMargin: Tokens.spacing.medium + Layout.bottomMargin: Tokens.spacing.medium + Layout.rightMargin: root.horizontalContentMargin + Layout.preferredHeight: 400 + Layout.maximumHeight: 400 + model: LlmClient.availableModels + currentIndex: LlmClient.availableModels.indexOf(LlmClient.model) + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + + CustomScrollBar.vertical: CustomScrollBar { + flickable: modelsList + } + + delegate: CustomRect { + id: model + + required property var modelData + required property int index + readonly property bool selected: ListView.view.currentIndex === model.index + + implicitWidth: ListView.view.width + implicitHeight: name.implicitHeight + Tokens.padding.small * 2 + radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium + color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0) + + Behavior on radius { + Anim { + type: Anim.SlowEffects + } + } + + RowLayout { + id: layout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.small + anchors.leftMargin: Tokens.padding.large + anchors.rightMargin: Tokens.padding.large + + CustomText { + id: name + + text: model.modelData + elide: CustomText.ElideRight + color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + + Layout.fillWidth: true + } + + MaterialIcon { + id: check + + color: Colors.palette.m3onTertiaryContainer + fontStyle: Tokens.font.icon.medium + opacity: model.selected ? 1 : 0 + visible: opacity > 0 + text: "check" + + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + } + + StateLayer { + id: stateLayer + + onClicked: { + model.ListView.view.currentIndex = model.index; + } + } + } + } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.extraSmall + + TextButton { + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: qsTr("Cancel") + type: TextButton.Text + verticalPadding: Tokens.padding.small + stateLayer.preventStealing: true + + onClicked: { + root.open = false; + } + } + + TextButton { + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: root.acceptLabel + type: TextButton.Text + stateLayer.preventStealing: true + verticalPadding: Tokens.padding.small + + onClicked: { + LlmClient.selectModel(modelsList.currentItem.modelData); + root.open = false; + } + } + } + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index 4bcfdc7..43ad59a 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -18,9 +18,9 @@ Item { required property ChatGeneration current readonly property bool isActive: index === blocks.length - 1 property bool expanded: false - readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] + readonly property var lastSegment: root.segments[root.segments.length - 1] readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) - readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s)) + readonly property bool pendingApproval: current.toolApprovalPending && segments.some(s => s.type === LlmSegment.Type.ToolCall && s.status === LlmToolCall.Status.Pending) implicitHeight: { const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin; @@ -112,396 +112,17 @@ Item { } } - Item { + ApprovalPrompt { id: approvalPrompt + anchors.top: layout.bottom anchors.topMargin: Tokens.spacing.small anchors.left: parent.left anchors.right: parent.right - implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2 visible: root.pendingApproval - CustomRect { - id: approvalBg - - anchors.fill: parent - radius: Tokens.rounding.medium - color: Colors.palette.m3surface - border.width: 1 - border.color: Colors.palette.m3outlineVariant - - ColumnLayout { - id: approvalLayout - anchors.fill: parent - anchors.margins: Tokens.padding.medium - spacing: Tokens.spacing.small - - CustomText { - id: requestHeader - text: qsTr("Tool call request") - font: Tokens.font.headline.medium - color: Colors.palette.m3onSurface - } - - Item { - id: toolsBg - - property bool open: false - property Item rootParent: root.rootParent - 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 c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2); - toolsBg.collapsedY = toolsWrapper.y; - toolsWrapper.parent = newParent; - 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 { - id: blobGroup - - color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer - - Behavior on color { - CAnim {} - } - } - - MouseArea { - id: backdrop - - anchors.fill: parent - enabled: false - hoverEnabled: enabled - preventStealing: true - parent: toolsBg.open ? toolsBg.rootParent : toolsBg - - onClicked: toolsBg.open = false - } - - Item { - id: toolsWrapper - - width: toolsBg.width - height: toolBox.implicitHeight - rotation: parent === toolsBg.rootParent ? 180 : 0 - z: 1 - - states: State { - name: "open" - when: toolsBg.open - - PropertyChanges { - backdrop.enabled: true - toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased - toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased - toolsBackground.topRightRadius: Tokens.rounding.largeIncreased - toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased - toolsContent.opacity: 1 - toolsWrapper.height: toolsBg.openHeight - toolsWrapper.width: toolsBg.openWidth - toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2 - toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium - elevation.opacity: 1 - toolBox.opacity: 0 - } - } - transitions: Transition { - id: dialogTransition - - SequentialAnimation { - ScriptAction { - script: toolsBg.reparentWrapper() - } - - Anim { - properties: "x,y" - } - } - - PropertyAction { - property: "enabled" - } - - Anim { - properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" - type: Anim.DefaultEffects - } - - Anim { - properties: "width,height" - } - } - - Elevation { - id: elevation - - anchors.fill: parent - bottomLeftRadius: toolsBackground.bottomLeftRadius - bottomRightRadius: toolsBackground.bottomRightRadius - level: 4 - opacity: 0 - radius: toolsBackground.radius - - transform: Matrix4x4 { - matrix: toolsBackground.deformMatrix - } - } - - BlobRect { - id: toolsBackground - - anchors.fill: parent - bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small - bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small - topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small - topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small - deformScale: 0 - group: blobGroup - opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) - } - - CustomRect { - id: toolBox - anchors.left: parent.left - anchors.right: parent.right - height: Math.min(implicitHeight, parent.height) - implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2 - radius: Tokens.rounding.small - - StateLayer { - onClicked: { - toolsBg.open = true; - } - } - - ColumnLayout { - id: toolList - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.margins: Tokens.padding.medium - - Repeater { - id: repeater - - model: root.current.pendingToolCalls?.slice(0, 4) ?? [] - - delegate: RowLayout { - id: toolText - - required property LlmSegment modelData - required property int index - - spacing: Tokens.spacing.medium - - function toolTarget(seg: LlmSegment): string { - var a = null; - try { - a = JSON.parse(seg.arguments); - } catch (e) {} - if (a && typeof a === "object") { - const v = a.url ?? a.path; - if (v !== undefined && v !== null) - return String(v); - } - return String(seg.arguments); - } - - MaterialIcon { - color: Colors.palette.m3onPrimaryContainer - text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet" - fill: 1 - } - - CustomText { - color: Colors.palette.m3onPrimaryContainer - text: qsTr("%1").arg(toolText.modelData.name) - } - - MaterialIcon { - color: Colors.palette.m3onPrimaryContainer - text: "arrow_right_alt" - } - - CustomText { - color: Colors.palette.m3onPrimaryContainer - text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData)) - } - } - } - - 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: Tokens.font.icon.medium - 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 - - IconTextButton { - icon: "check_circle" - text: qsTr("Allow") - horizontalPadding: Tokens.padding.large - verticalPadding: Tokens.padding.small - isRound: true - shapeMorph: true - onClicked: root.current.approveTools() - } - - IconTextButton { - icon: "cancel" - text: qsTr("Deny") - isRound: true - shapeMorph: true - inactiveColor: Colors.palette.m3error - inactiveOnColor: Colors.palette.m3onError - horizontalPadding: Tokens.padding.large - verticalPadding: Tokens.padding.small - onClicked: root.current.denyTools() - } - } - } - } + rootParent: root.rootParent + current: root.current } CustomClippingRect { @@ -533,7 +154,7 @@ Item { delegate: Item { id: segment - required property LlmSegment modelData + required property var modelData required property int index implicitWidth: ListView.view.width @@ -560,7 +181,7 @@ Item { CustomText { id: content - function toolTarget(seg: LlmSegment): string { + function toolTarget(seg: var): string { var a = null; try { a = JSON.parse(seg.arguments); @@ -573,29 +194,29 @@ Item { return String(seg.arguments); } - function prettyTool(seg: LlmSegment): string { + function prettyTool(seg: var): string { if (seg.name === "webfetch") { switch (seg.status) { - case LlmSegment.Status.Running: + case LlmToolCall.Status.Running: return qsTr("Fetching"); - case LlmSegment.Status.Pending: + case LlmToolCall.Status.Pending: return qsTr("Pending fetch"); - case LlmSegment.Status.Success: + case LlmToolCall.Status.Success: return qsTr("Fetched"); - case LlmSegment.Status.Error: + case LlmToolCall.Status.Error: return qsTr("Failed to fetch"); default: return qsTr("Fetched website"); } } else if (seg.name === "readfile") { switch (seg.status) { - case LlmSegment.Status.Running: + case LlmToolCall.Status.Running: return qsTr("Reading"); - case LlmSegment.Status.Pending: + case LlmToolCall.Status.Pending: return qsTr("Pending read"); - case LlmSegment.Status.Success: + case LlmToolCall.Status.Success: return qsTr("Read"); - case LlmSegment.Status.Error: + case LlmToolCall.Status.Error: return qsTr("Failed to read"); default: return qsTr("Read file"); diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml index 748a3ae..38b0e50 100644 --- a/Modules/Notifications/Sidebar/Chat/SidebarView.qml +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -65,9 +65,7 @@ Item { } } - model: ScriptModel { - values: Chat.chats.values - } + model: LlmClient.chats onLoadChatRequest: (chat, index) => { ChatState.currentIdx = index; @@ -75,17 +73,13 @@ Item { } onDeleteChatRequest: chat => { - if (chat === ChatState.chatSession) { - ChatState.chatSession = null; - ChatState.currentIdx = -1; - } - Chat.chats.remove(chat); - } - onNewChatRequest: { - const data = Chat.chats.insert(); - ChatState.currentIdx = 0; - ChatState.chatSession = data; + const wasCurrent = chat === ChatState.chatSession; + LlmClient.chats.remove(chat); + + if (wasCurrent) + ChatState.openDraft(); } + onNewChatRequest: ChatState.openDraft() onRequestExpand: { ChatState.narrowSidebarExpanded = true; @@ -157,30 +151,6 @@ Item { root.chatOpen = true } - ColumnLayout { - id: noChatLayout - anchors.centerIn: parent - opacity: ChatState.chatSession ? 0 : 1 - visible: opacity > 0 - - Behavior on opacity { - Anim {} - } - - MaterialIcon { - Layout.alignment: Qt.AlignCenter - text: "chat_bubble_off" - fontStyle: Tokens.font.icon.extraLarge - color: Colors.tPalette.m3onSurfaceVariant - } - - CustomText { - Layout.alignment: Qt.AlignCenter - text: qsTr("Start or create a chat") - color: Colors.tPalette.m3onSurfaceVariant - } - } - MouseArea { id: dimArea anchors.fill: parent @@ -192,8 +162,10 @@ Item { const x = mouseX; const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2; - if (x > invertedRect.borderLeft - margin) + if (x > invertedRect.borderLeft - margin) { ChatState.narrowSidebarExpanded = false; + ChatState.fabExpanded = false; + } } HoverHandler { diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 41a6728..6167cb7 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -107,7 +107,7 @@ Item { GeminiGlow { innerGlow: 50 z: 5 - opacity: Chat.busy ? 1.0 : 0.0 + opacity: LlmClient.busy ? 1.0 : 0.0 glowSize: 0 visible: opacity > 0 && !ChatState.isWindow diff --git a/Modules/Notifications/Sidebar/Wrapper.qml b/Modules/Notifications/Sidebar/Wrapper.qml index e286918..6934644 100644 --- a/Modules/Notifications/Sidebar/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Wrapper.qml @@ -30,9 +30,9 @@ Item { } Connections { - target: Chat + target: LlmClient - function onErrorOccurred(message: string): void { + function onGenerationFailed(session: ChatSession, message: string): void { if (root.shouldBeActive && root.chatActive) return; diff --git a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml index d97d5be..9eda863 100644 --- a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml +++ b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml @@ -88,6 +88,7 @@ PageBase { id: selectScheme acceptLabel: qsTr("Confirm") + settingAnchor: "panels-sidebar-llm-scheme" enabled: Object.keys(model).length > 0 header: qsTr("Select scheme") first: true diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index 5501b34..5c218f6 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -232,22 +232,25 @@ qml_module(ZShell-llm URI ZShell.Llm SOURCES attachment.hpp attachment.cpp - chat.hpp chat.cpp chatstore.hpp chatstore.cpp + clipboard.hpp clipboard.cpp codehighlighter.hpp codehighlighter.cpp filetool.hpp filetool.cpp generation.hpp generation.cpp llmclient.hpp llmclient.cpp - markdownblock.hpp + markdownblock.hpp markdownblock.cpp markdownparser.hpp markdownparser.cpp mathtext.hpp mathtext.cpp message.hpp message.cpp messagemodel.hpp messagemodel.cpp + prompts.hpp prompts.cpp segment.hpp segment.cpp session.hpp session.cpp shelltool.hpp shelltool.cpp tool.hpp tool.cpp + turn.hpp turn.cpp webfetchtool.hpp webfetchtool.cpp + workspace.hpp workspace.cpp LIBRARIES Qt::Network Qt::Sql diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp deleted file mode 100644 index 4440386..0000000 --- a/Plugins/ZShell/Llm/chat.cpp +++ /dev/null @@ -1,201 +0,0 @@ -#include "chat.hpp" - -#include "filetool.hpp" -#include "llmclient.hpp" -#include "shelltool.hpp" -#include "webfetchtool.hpp" - -#include "config/rootnodes.hpp" -#include "config/llm.hpp" - -#include - -namespace ZShell::llm { - -Chat::Chat(QObject* parent) - : QObject(parent) - , m_store(new ChatStore(this)) - , m_client(new LlmClient(this)) { - 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::ConfigSingleton::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()); - }); - connect(llm, &config::Llm::modelChanged, this, [this, llm]() { - m_client->setModel(llm->model()); - }); - connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() { - m_client->setTemperature(llm->temperature()); - }); - 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_hasError || !m_errorMessage.isEmpty())) { - m_hasError = false; - m_errorMessage.clear(); - Q_EMIT errorChanged(); - } - Q_EMIT busyChanged(); - }); - connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); - connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged); - connect( - m_client, - &LlmClient::availableModelsChanged, - this, - &Chat::availableModelsChanged); - connect( - m_client, - &LlmClient::contextSizeChanged, - this, - &Chat::contextSizeChanged); - connect( - m_client, - &LlmClient::streamingChatIdChanged, - this, - &Chat::streamingChatIdChanged); - connect( - m_client, - &LlmClient::toolsEnabledChanged, - this, - &Chat::toolsEnabledChanged); - connect( - m_client, - &LlmClient::errorOccurred, - this, - [this](const QString& message) { - 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, - this, - [this](ChatSession* session) { m_client->sessionRemoved(session); }); - connect( - m_client, - &LlmClient::titleSuggested, - this, - [this](ChatSession* session, const QString& title) { - qInfo() << "Chat: applying generated title" << session->id() - << title << "(was" << session->title() << ")"; - session->setTitle(title); - m_store->saveMeta(session); - }); - connect( - m_client, - &LlmClient::iconSuggested, - this, - [this](ChatSession* session, const QString& icon) { - qInfo() << "Chat: applying generated icon" << session->id() << icon - << "(was" << session->icon() << ")"; - session->setIcon(icon); - m_store->saveMeta(session); - }); -} - -bool Chat::busy() const { - return m_client->busy(); -} - -QString Chat::endpoint() const { - return m_client->endpoint(); -} - -QString Chat::model() const { - return m_client->model(); -} - -QStringList Chat::availableModels() const { - return m_client->availableModels(); -} - -int Chat::contextSize() const { - return m_client->contextSize(); -} - -bool Chat::toolsEnabled() const { - return m_client->toolsEnabled(); -} - -void Chat::setToolsEnabled(bool value) { - m_client->setToolsEnabled(value); - if (auto* config = config::ConfigSingleton::instance()) - 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::ConfigSingleton::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*) { - if (!s_instance) s_instance = new Chat(); - return s_instance; -} - -void Chat::stop() { - m_client->stop(); -} - -void Chat::dismissError() { - if (!m_hasError && m_errorMessage.isEmpty()) return; - m_hasError = false; - m_errorMessage.clear(); - Q_EMIT errorChanged(); -} - -void Chat::refreshModels() { - m_client->refreshModels(); -} - -void Chat::refreshFromServer() { - m_client->refreshAllStates(); -} - -void Chat::selectModel(const QString& id) { - if (id.isEmpty()) return; - m_client->setModel(id); - if (auto* config = config::ConfigSingleton::instance()) - config->llm()->set_model(id); -} - -} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp deleted file mode 100644 index 89a37c1..0000000 --- a/Plugins/ZShell/Llm/chat.hpp +++ /dev/null @@ -1,91 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "chatstore.hpp" - -class QQmlEngine; -class QJSEngine; - -namespace ZShell::llm { - -class LlmClient; - -class Chat : public QObject { - Q_OBJECT - QML_ELEMENT - QML_SINGLETON - - Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) - Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged) - Q_PROPERTY(QString model READ model NOTIFY modelChanged) - Q_PROPERTY( - QStringList availableModels READ availableModels NOTIFY - availableModelsChanged) - Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) - Q_PROPERTY( - bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY - toolsEnabledChanged) - 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 - streamingChatIdChanged) - - public: - explicit Chat(QObject* parent = nullptr); - - [[nodiscard]] bool busy() const; - [[nodiscard]] QString endpoint() const; - [[nodiscard]] QString model() const; - [[nodiscard]] QStringList availableModels() const; - [[nodiscard]] int contextSize() const; - [[nodiscard]] bool toolsEnabled() const; - void setToolsEnabled(bool value); - [[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; - - Q_INVOKABLE void stop(); - Q_INVOKABLE void dismissError(); - Q_INVOKABLE void refreshModels(); - Q_INVOKABLE void refreshFromServer(); - Q_INVOKABLE void selectModel(const QString& id); - - static Chat* create(QQmlEngine*, QJSEngine*); - - Q_SIGNALS: - void busyChanged(); - void endpointChanged(); - void modelChanged(); - void availableModelsChanged(); - void contextSizeChanged(); - void toolsEnabledChanged(); - void sandboxEnabledChanged(); - void errorOccurred(const QString& message); - void errorChanged(); - void serverErrorChanged(); - void streamingChatIdChanged(); - - private: - ChatStore* m_store = nullptr; - LlmClient* m_client = nullptr; - bool m_hasError = false; - QString m_errorMessage; - - static Chat* s_instance; -}; - -} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 86ef46b..2feac92 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -1,6 +1,8 @@ #include "chatstore.hpp" +#include "attachment.hpp" #include "llmclient.hpp" +#include "markdownblock.hpp" #include "message.hpp" #include "segment.hpp" @@ -11,15 +13,12 @@ #include #include #include -#include #include #include #include #include #include -#include - namespace ZShell::llm { namespace { @@ -76,22 +75,25 @@ struct AttachmentRow { struct MessageRow { bool user = false; qint64 timestamp = 0; + QString content; QVector generations; QVector attachments; }; } // namespace -ChatStore::ChatStore(QObject* parent) - : QObject(parent), m_connectionName(QUuid::createUuid().toString()) { +ChatStore::ChatStore(LlmClient* client, QObject* parent) + : QAbstractListModel(parent) + , m_client(client) + , m_connectionName(QUuid::createUuid().toString()) { openDb(); load(); } ChatStore::~ChatStore() { if (m_connectionName.isEmpty()) return; - QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); - db.close(); + QSqlDatabase handle = QSqlDatabase::database(m_connectionName, false); + handle.close(); QSqlDatabase::removeDatabase(m_connectionName); } @@ -105,20 +107,20 @@ void ChatStore::openDb() { QStringLiteral("/zshell/chats.sqlite"); QDir().mkpath(QFileInfo(m_dbPath).absolutePath()); - QSqlDatabase db = + QSqlDatabase handle = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); - db.setDatabaseName(m_dbPath); - if (!db.open()) { + handle.setDatabaseName(m_dbPath); + if (!handle.open()) { qWarning() << "ChatStore: failed to open database" << m_dbPath << ":" - << db.lastError().text(); + << handle.lastError().text(); return; } { - QSqlQuery pragma(db); + QSqlQuery pragma(handle); pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON")); } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS sessions (\n" " id TEXT PRIMARY KEY,\n" @@ -130,18 +132,23 @@ void ChatStore::openDb() { ")")); } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS messages (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " session_id TEXT NOT NULL REFERENCES sessions (id) " "ON DELETE CASCADE,\n" " role TEXT NOT NULL,\n" + " content TEXT NOT NULL DEFAULT '',\n" " timestamp INTEGER NOT NULL\n" ")")); + if (!query.exec(QStringLiteral( + "ALTER TABLE messages ADD COLUMN content TEXT " + "NOT NULL DEFAULT ''"))) { + } } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS generations (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" @@ -152,7 +159,7 @@ void ChatStore::openDb() { ")")); } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS segments (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" @@ -170,7 +177,7 @@ void ChatStore::openDb() { ")")); } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS attachments (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" @@ -184,7 +191,7 @@ void ChatStore::openDb() { ")")); } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE INDEX IF NOT EXISTS idx_messages_session " "ON messages (session_id)")); @@ -200,93 +207,156 @@ void ChatStore::openDb() { } } -int ChatStore::count() const { +int ChatStore::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; return static_cast(m_sessions.size()); } -QVariantList ChatStore::values() const { - QVariantList vals; - vals.reserve(m_sessions.size()); - for (const auto* session : m_sessions) - vals.append(QVariant::fromValue(session)); - return vals; +QVariant ChatStore::data(const QModelIndex& index, int role) const { + if (role != Qt::UserRole || !index.isValid() || index.row() < 0 || + index.row() >= m_sessions.size()) + return {}; + return QVariant::fromValue(m_sessions.at(index.row())); } -ChatSession* ChatStore::at(int index) const { - if (index < 0 || index >= m_sessions.size()) return nullptr; - return m_sessions.at(index); +QHash ChatStore::roleNames() const { + return {{Qt::UserRole, "modelData"}}; } -ChatSession* ChatStore::insert(int index) { +int ChatStore::rowOf(const ChatSession* session) const { + for (int i = 0; i < m_sessions.size(); ++i) + if (m_sessions.at(i) == session) return i; + return -1; +} + +bool ChatStore::firstAfter(const ChatSession* a, const ChatSession* b) { + if (a->pinned() != b->pinned()) return a->pinned(); + return a->updatedAtMs() > b->updatedAtMs(); +} + +int ChatStore::sortedRow(const ChatSession* session) const { + int row = 0; + while (row < m_sessions.size() && !firstAfter(session, m_sessions.at(row))) + ++row; + return row; +} + +void ChatStore::insertRow(ChatSession* session) { + const int row = sortedRow(session); + beginInsertRows(QModelIndex(), row, row); + m_sessions.insert(row, session); + endInsertRows(); +} + +void ChatStore::moveRow(int from, int to) { + if (from < 0 || from >= m_sessions.size() || to < 0 || + to > m_sessions.size() || from == to || to == from + 1) + return; + if (!beginMoveRows(QModelIndex(), from, from, QModelIndex(), to)) return; + m_sessions.move(from, to > from ? to - 1 : to); + endMoveRows(); +} + +void ChatStore::reposition(ChatSession* session) { + if (m_loading) return; + const int row = rowOf(session); + if (row < 0) return; + const int target = sortedRow(session); + if (target == row) return; + moveRow(row, target); +} + +ChatSession* ChatStore::draft() { + if (m_draft) return m_draft; const qint64 now = QDateTime::currentMSecsSinceEpoch(); - const QString id = QUuid::createUuid().toString(); - { - QSqlQuery query(db()); - query.prepare( - "INSERT INTO sessions (id, title, created_at, updated_at) " - "VALUES (:id, '', :created_at, :updated_at)"); - query.bindValue(":id", id); - query.bindValue(":created_at", now); - query.bindValue(":updated_at", now); - if (!query.exec()) - qWarning() << "ChatStore: failed to insert session" << id << ":" - << query.lastError().text(); + m_draft = new ChatSession(this, QUuid::createUuid().toString(), this); + m_draft->hydrate(QString(), QString(), now, now, 0, false); + return m_draft; +} + +void ChatStore::commitDraft(ChatSession* session) { + if (!session || session->isCommitted()) return; + if (m_draft == session) m_draft = nullptr; + + if (!writeSessionRow(session)) return; + session->setCommitted(true); + insertRow(session); +} + +bool ChatStore::writeSessionRow(ChatSession* session) { + QSqlQuery query(db()); + query.prepare( + "INSERT INTO sessions (id, title, icon, created_at, updated_at, " + "pinned) VALUES (:id, :title, :icon, :created_at, :updated_at, " + ":pinned)"); + query.bindValue(":id", session->id()); + query.bindValue(":title", sqlText(session->title())); + query.bindValue(":icon", sqlText(session->icon())); + query.bindValue(":created_at", session->createdAtMs()); + query.bindValue(":updated_at", session->updatedAtMs()); + query.bindValue(":pinned", session->pinned() ? 1 : 0); + if (!query.exec()) { + qWarning() << "ChatStore: failed to insert session" << session->id() + << ":" << query.lastError().text(); + return false; } - auto* session = new ChatSession(id, this); - session->setMeta(QString(), now, now, 0); - const int pos = index >= 0 && index <= m_sessions.size() ? index : 0; - m_sessions.insert(pos, session); - Q_EMIT countChanged(); - Q_EMIT valuesChanged(); - return session; + return true; } -void ChatStore::remove(int index) { - removeSession(at(index)); -} +void ChatStore::removeRow(ChatSession* session) { + if (!session) return; + const int row = rowOf(session); -void ChatStore::remove(ChatSession* chat) { - removeSession(chat); -} - -void ChatStore::removeSession(ChatSession* session) { - if (!session || !m_sessions.contains(session)) return; - const QList before = m_sessions; Q_EMIT sessionRemoved(session); + { QSqlQuery query(db()); query.prepare("DELETE FROM sessions WHERE id = :id"); query.bindValue(":id", session->id()); query.exec(); } - QDir(ChatSession::sandboxDirFor(session->id())).removeRecursively(); - m_sessions.removeOne(session); + + if (row >= 0) { + beginRemoveRows(QModelIndex(), row, row); + m_sessions.removeAt(row); + endRemoveRows(); + } session->deleteLater(); - notify(before); } -void ChatStore::move(int from, int to) { - if (from < 0 || from >= m_sessions.size() || to < 0 || - to >= m_sessions.size() || from == to) +void ChatStore::remove(ChatSession* chat) { + if (!chat) return; + m_pendingPersists.remove(chat); + m_pendingClears.remove(chat); + if (m_draft == chat) m_draft = nullptr; + + if (!chat->isCommitted()) { + Q_EMIT sessionRemoved(chat); + chat->deleteLater(); return; - m_sessions.move(from, to); - Q_EMIT valuesChanged(); + } + + removeRow(chat); } void ChatStore::clear() { const QList sessions = m_sessions; for (ChatSession* session : sessions) - removeSession(session); + remove(session); } -ChatSession* ChatStore::sessionById(const QString& id) { - for (auto* session : m_sessions) - if (session->id() == id) return session; - return nullptr; +void ChatStore::requestClear(ChatSession* session) { + if (!session) return; + m_pendingClears.insert(session); + if (session->isLoaded() && m_client->streamingSession() != session) + applyPendingClear(session); } -void ChatStore::setLlmClient(LlmClient* client) { - m_llmClient = client; +void ChatStore::applyPendingClear(ChatSession* session) { + if (!session || !m_pendingClears.remove(session)) return; + if (!session->isLoaded()) return; + session->clearMessages(); + persist(session); } void ChatStore::persist(ChatSession* session) { @@ -297,21 +367,23 @@ void ChatStore::persist(ChatSession* session) { } session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); if (!saveSession(session)) return; - sortAndNotify(); + reposition(session); } -void ChatStore::saveMeta(ChatSession* session) { +void ChatStore::saveFlags(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; QSqlQuery query(db()); query.prepare( - "UPDATE sessions SET title = :title, icon = :icon " - "WHERE id = :id"); + "UPDATE sessions SET title = :title, icon = :icon, " + "pinned = :pinned WHERE id = :id"); query.bindValue(":title", sqlText(session->title())); query.bindValue(":icon", sqlText(session->icon())); + query.bindValue(":pinned", session->pinned() ? 1 : 0); query.bindValue(":id", session->id()); if (!query.exec()) - qWarning() << "ChatStore: failed to save meta for" << session->id() + qWarning() << "ChatStore: failed to save flags for" << session->id() << ":" << query.lastError().text(); + reposition(session); } bool ChatStore::saveSession(ChatSession* session) { @@ -326,10 +398,12 @@ bool ChatStore::saveSession(ChatSession* session) { { QSqlQuery query(handle); query.prepare( - "UPDATE sessions SET title = :title, updated_at = :updated_at " - "WHERE id = :id"); + "UPDATE sessions SET title = :title, icon = :icon, " + "updated_at = :updated_at, pinned = :pinned WHERE id = :id"); query.bindValue(":title", sqlText(session->title())); + query.bindValue(":icon", sqlText(session->icon())); query.bindValue(":updated_at", session->updatedAtMs()); + query.bindValue(":pinned", session->pinned() ? 1 : 0); query.bindValue(":id", session->id()); ok = query.exec(); } @@ -342,8 +416,8 @@ bool ChatStore::saveSession(ChatSession* session) { if (ok) { QSqlQuery messageInsert(handle); ok = messageInsert.prepare( - "INSERT INTO messages (session_id, role, timestamp) " - "VALUES (:id, :role, :timestamp)"); + "INSERT INTO messages (session_id, role, content, timestamp) " + "VALUES (:id, :role, :content, :timestamp)"); QSqlQuery generationInsert(handle); ok = ok && generationInsert.prepare( @@ -371,6 +445,7 @@ bool ChatStore::saveSession(ChatSession* session) { message->role() == ChatMessage::Role::User ? QStringLiteral("user") : QStringLiteral("assistant")); + messageInsert.bindValue(":content", sqlText(message->content())); messageInsert.bindValue(":timestamp", message->timestamp()); if (!messageInsert.exec()) { ok = false; @@ -401,16 +476,24 @@ bool ChatStore::saveSession(ChatSession* session) { segmentInsert.bindValue(":gid", generationId); segmentInsert.bindValue( ":type", segmentTypeName(segment->type())); - segmentInsert.bindValue(":text", sqlText(segment->text())); - segmentInsert.bindValue(":name", sqlText(segment->name())); + const auto* text = + qobject_cast(segment); segmentInsert.bindValue( - ":tool_call_id", sqlText(segment->toolCallId())); + ":text", sqlText(text ? text->text() : QString())); + const auto* call = + qobject_cast(segment); segmentInsert.bindValue( - ":arguments", sqlText(segment->arguments())); + ":name", sqlText(call ? call->name() : QString())); segmentInsert.bindValue( - ":result", sqlText(segment->result())); + ":tool_call_id", + sqlText(call ? call->toolCallId() : QString())); segmentInsert.bindValue( - ":status", static_cast(segment->status())); + ":arguments", + sqlText(call ? call->arguments() : QString())); + segmentInsert.bindValue( + ":result", sqlText(call ? call->result() : QString())); + segmentInsert.bindValue( + ":status", call ? static_cast(call->status()) : 0); segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs()); segmentInsert.bindValue(":timestamp", segment->timestamp()); if (!segmentInsert.exec()) { @@ -448,12 +531,13 @@ bool ChatStore::saveSession(ChatSession* session) { qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back"; handle.rollback(); - ok = false; qWarning() << "ChatStore: failed to save session" << session->id() << ":" << handle.lastError().text(); + return false; } - return ok; + return true; } + void ChatStore::loadMessagesInto(ChatSession* session) { if (!session) return; const QString sessionId = session->id(); @@ -467,15 +551,15 @@ void ChatStore::loadMessagesInto(ChatSession* session) { QList rows; const QString connName = QUuid::createUuid().toString(); { - QSqlDatabase db = + QSqlDatabase handle = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName); - db.setDatabaseName(path); - if (db.open()) { - QSqlQuery busy(db); + handle.setDatabaseName(path); + if (handle.open()) { + QSqlQuery busy(handle); busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); - QSqlQuery query(db); + QSqlQuery query(handle); query.prepare( - "SELECT id, role, timestamp FROM messages " + "SELECT id, role, content, timestamp FROM messages " "WHERE session_id = :id ORDER BY rowid DESC"); query.bindValue(":id", sessionId); if (!query.exec()) { @@ -487,8 +571,9 @@ void ChatStore::loadMessagesInto(ChatSession* session) { MessageRow message; message.user = query.value(1).toString() == QLatin1String("user"); - message.timestamp = query.value(2).toLongLong(); - QSqlQuery generationQuery(db); + message.content = query.value(2).toString(); + message.timestamp = query.value(3).toLongLong(); + QSqlQuery generationQuery(handle); generationQuery.prepare( "SELECT id, timestamp, is_active FROM " "generations WHERE message_id = :mid " @@ -501,7 +586,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) { generationQuery.value(1).toLongLong(); generation.active = generationQuery.value(2).toInt() != 0; - QSqlQuery segmentQuery(db); + QSqlQuery segmentQuery(handle); segmentQuery.prepare( "SELECT type, text, name, tool_call_id, " "arguments, result, status, elapsed_ms, " @@ -549,7 +634,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) { << messageId << ":" << generationQuery.lastError().text(); } - QSqlQuery attachmentQuery(db); + QSqlQuery attachmentQuery(handle); attachmentQuery.prepare( "SELECT name, path, kind, mime, size FROM " "attachments WHERE message_id = :mid " @@ -580,10 +665,10 @@ void ChatStore::loadMessagesInto(ChatSession* session) { rows.append(message); } } - db.close(); + handle.close(); } else { qWarning() << "ChatStore: failed to open database for load:" - << db.lastError().text(); + << handle.lastError().text(); } } QSqlDatabase::removeDatabase(connName); @@ -595,7 +680,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) { ChatSession* s = session; if (!st || !s) return; - auto* model = s->model(); + auto* model = s->messagesModel(); if (!model) return; QList messages; for (const MessageRow& row : rows) { @@ -603,6 +688,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) { row.user ? ChatMessage::Role::User : ChatMessage::Role::Assistant, row.timestamp); + if (!row.attachments.isEmpty()) { QList attachments; for (const AttachmentRow& attachmentRow : @@ -619,27 +705,54 @@ void ChatStore::loadMessagesInto(ChatSession* session) { } message->setAttachments(attachments); } + + if (row.user) { + QString content = row.content; + if (content.isEmpty() && !row.generations.isEmpty()) { + for (const SegmentRow& segment : + row.generations.first().segments) + if (segmentTypeFromName(segment.type) == + LlmSegment::Type::Content) { + content = segment.text; + break; + } + } + if (!content.isEmpty()) message->setContent(content); + messages.append(message); + continue; + } + int activeIndex = 0; for (int i = 0; i < row.generations.size(); ++i) { const GenerationRow& generationRow = row.generations.at(i); auto* generation = message->addGeneration(generationRow.timestamp); + if (!generation) continue; for (const SegmentRow& segmentRow : generationRow.segments) { - auto* segment = new LlmSegment( - segmentTypeFromName(segmentRow.type), - segmentRow.timestamp, - generation); - segment->setText(segmentRow.text); - segment->setName(segmentRow.name); - segment->setToolCallId(segmentRow.toolCallId); - segment->appendArguments(segmentRow.arguments); - segment->setResult(segmentRow.result); - segment->setStatus( - static_cast( - segmentRow.status)); - segment->restore(segmentRow.elapsedMs); + const LlmSegment::Type type = + segmentTypeFromName(segmentRow.type); + LlmSegment* segment = nullptr; + if (type == LlmSegment::Type::ToolCall) { + auto* call = new LlmToolCall( + segmentRow.timestamp, generation); + call->setName(segmentRow.name); + call->setToolCallId(segmentRow.toolCallId); + call->appendArguments(segmentRow.arguments); + call->setResult(segmentRow.result); + call->setStatus( + static_cast( + segmentRow.status)); + call->restore(segmentRow.elapsedMs); + segment = call; + } else { + auto* text = new LlmTextSegment( + type, segmentRow.timestamp, generation); + text->setText(segmentRow.text); + text->restore(segmentRow.elapsedMs); + segment = text; + } generation->addSegment(segment); } if (generationRow.active) activeIndex = i; @@ -658,54 +771,35 @@ void ChatStore::loadMessagesInto(ChatSession* session) { s->markLoaded(); - if (s->takeClearPending()) { - s->clear(); - } else if (st->m_pendingPersists.remove(s)) { - st->persist(s); - } + st->applyPendingClear(s); + if (st->m_pendingPersists.remove(s)) st->persist(s); }, Qt::QueuedConnection); }); } void ChatStore::load() { + m_loading = true; QSqlQuery query(db()); query.exec( "SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, " "(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt " "FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC"); while (query.next()) { - auto* session = new ChatSession(query.value(0).toString(), this); - session->setMeta( + auto* session = new ChatSession(this, query.value(0).toString(), this); + session->hydrate( query.value(1).toString(), + query.value(2).toString(), query.value(3).toLongLong(), query.value(4).toLongLong(), - query.value(6).toInt()); - session->setIcon(query.value(2).toString()); - session->setPinned(query.value(5).toInt() != 0); + query.value(6).toInt(), + query.value(5).toInt() != 0); m_sessions.append(session); } - sortAndNotify(); -} + m_loading = false; -void ChatStore::sortAndNotify() { - const QList before = m_sessions; - std::stable_sort( - m_sessions.begin(), - m_sessions.end(), - [](const ChatSession* a, const ChatSession* b) { - if (a->pinned() != b->pinned()) return a->pinned() > b->pinned(); - return a->updatedAtMs() > b->updatedAtMs(); - }); - notify(before); -} - -void ChatStore::notify(const QList& before) { - if (before.size() != m_sessions.size()) Q_EMIT countChanged(); - bool same = before.size() == m_sessions.size(); - for (int i = 0; same && i < m_sessions.size(); ++i) - if (before.at(i) != m_sessions.at(i)) same = false; - if (!same) Q_EMIT valuesChanged(); + beginResetModel(); + endResetModel(); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp index 75b335b..25b901b 100644 --- a/Plugins/ZShell/Llm/chatstore.hpp +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -2,65 +2,76 @@ #include "session.hpp" -#include +#include +#include +#include #include #include #include -#include +#include namespace ZShell::llm { class LlmClient; -class ChatStore : public QObject { +class ChatStore : public QAbstractListModel { Q_OBJECT - QML_ANONYMOUS - - Q_PROPERTY(int count READ count NOTIFY countChanged) - Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged) + QML_ELEMENT + QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton") public: - explicit ChatStore(QObject* parent = nullptr); + explicit ChatStore(LlmClient* client, QObject* parent = nullptr); ~ChatStore() override; - [[nodiscard]] int count() const; - [[nodiscard]] QVariantList values() const; - [[nodiscard]] ChatSession* at(int index) const; + [[nodiscard]] int rowCount( + const QModelIndex& parent = QModelIndex()) const override; + [[nodiscard]] QVariant data( + const QModelIndex& index, int role = Qt::DisplayRole) const override; + [[nodiscard]] QHash roleNames() const override; - Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1); - Q_INVOKABLE void remove(int index); + [[nodiscard]] LlmClient* client() const { return m_client; } + + [[nodiscard]] int rowOf(const ChatSession* session) const; + + Q_INVOKABLE ZShell::llm::ChatSession* draft(); Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat); - Q_INVOKABLE void move(int from, int to); Q_INVOKABLE void clear(); - [[nodiscard]] ChatSession* sessionById(const QString& id); - [[nodiscard]] LlmClient* llmClient() const { return m_llmClient; } - void setLlmClient(LlmClient* client); + void commitDraft(ZShell::llm::ChatSession* session); - void persist(ChatSession* session); - void saveMeta(ChatSession* session); - void loadMessagesInto(ChatSession* session); + void persist(ZShell::llm::ChatSession* session); + void saveFlags(ZShell::llm::ChatSession* session); + void loadMessagesInto(ZShell::llm::ChatSession* session); + + void requestClear(ZShell::llm::ChatSession* session); + void applyPendingClear(ZShell::llm::ChatSession* session); + + void reposition(ZShell::llm::ChatSession* session); Q_SIGNALS: - void countChanged(); - void valuesChanged(); void sessionRemoved(ZShell::llm::ChatSession* session); private: void openDb(); void load(); - bool saveSession(ChatSession* session); - void sortAndNotify(); - void removeSession(ChatSession* session); - void notify(const QList& before); + [[nodiscard]] int sortedRow(const ChatSession* session) const; + [[nodiscard]] static bool firstAfter( + const ChatSession* a, const ChatSession* b); + void insertRow(ChatSession* session); + void moveRow(int from, int to); + void removeRow(ChatSession* session); + [[nodiscard]] bool saveSession(ChatSession* session); + [[nodiscard]] bool writeSessionRow(ChatSession* session); + [[nodiscard]] QSqlDatabase db() const; + LlmClient* m_client; QList m_sessions; - LlmClient* m_llmClient = nullptr; + ChatSession* m_draft = nullptr; QString m_connectionName; QString m_dbPath; QSet m_pendingPersists; - - [[nodiscard]] QSqlDatabase db() const; + QSet m_pendingClears; + bool m_loading = false; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/clipboard.cpp b/Plugins/ZShell/Llm/clipboard.cpp new file mode 100644 index 0000000..917cd55 --- /dev/null +++ b/Plugins/ZShell/Llm/clipboard.cpp @@ -0,0 +1,61 @@ +#include "clipboard.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm::clipboard { + +bool hasAttachment() { + 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; +} + +QStringList attachmentPaths(QString& error) { + error.clear(); + QClipboard* clipboard = QGuiApplication::clipboard(); + const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr; + if (!data) { + qWarning() << "clipboard: no clipboard data"; + return {}; + } + + QStringList paths; + if (data->hasImage()) { + const QImage image = clipboard->image(); + if (image.isNull()) { + error = QStringLiteral("Clipboard image could not be decoded"); + return {}; + } + const QString tempPath = + QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png") + .arg(QDateTime::currentMSecsSinceEpoch()); + if (!image.save(tempPath)) { + error = QStringLiteral("Could not save the pasted image"); + return {}; + } + paths.append(tempPath); + } else if (data->hasUrls()) { + for (const QUrl& url : data->urls()) + if (url.isLocalFile()) paths.append(url.toLocalFile()); + } + + if (paths.isEmpty() && (data->hasImage() || data->hasUrls())) + error = QStringLiteral( + "Nothing usable on the clipboard (expected an image " + "or copied files)"); + return paths; +} + +} // namespace ZShell::llm::clipboard diff --git a/Plugins/ZShell/Llm/clipboard.hpp b/Plugins/ZShell/Llm/clipboard.hpp new file mode 100644 index 0000000..5a2d168 --- /dev/null +++ b/Plugins/ZShell/Llm/clipboard.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +namespace ZShell::llm::clipboard { + +[[nodiscard]] bool hasAttachment(); + +[[nodiscard]] QStringList attachmentPaths(QString& error); + +} // namespace ZShell::llm::clipboard diff --git a/Plugins/ZShell/Llm/filetool.cpp b/Plugins/ZShell/Llm/filetool.cpp index d4c7b6b..c58ad82 100644 --- a/Plugins/ZShell/Llm/filetool.cpp +++ b/Plugins/ZShell/Llm/filetool.cpp @@ -6,23 +6,8 @@ namespace ZShell::llm { -namespace { - -QJsonObject makeOutput(const QString& text) { - QJsonObject obj; - obj[QStringLiteral("output")] = text; - return obj; -} - -QJsonObject makeError(const QString& message) { - QJsonObject obj; - obj[QStringLiteral("error")] = message; - return obj; -} - -} // namespace - -FileReadTool::FileReadTool(QObject* parent) : LlmTool(parent) {} +FileReadTool::FileReadTool(ToolRegistry* registry, QObject* parent) + : LlmTool(registry, parent) {} QString FileReadTool::name() const { return QStringLiteral("readfile"); @@ -34,8 +19,8 @@ QString FileReadTool::description() const { "its content. At most a limited number of characters is returned " "per call; use offset and limit to page through larger files. " "Binary files are not supported. Full output of webfetch calls " - "is stored under /tmp/zshell-llm/webfetch/ and can be read this " - "way. This tool is read-only."); + "is saved in the sandbox directory and can be read this way. " + "This tool is read-only."); } QJsonObject FileReadTool::parameters() const { @@ -72,19 +57,16 @@ QJsonObject FileReadTool::parameters() const { } void FileReadTool::execute( - const QString& toolCallId, - const QJsonObject& args, - int outputBudgetChars, - std::function done) { - Q_UNUSED(toolCallId); + const ToolContext& context, const QJsonObject& args, ToolDone done) { const QString path = args[QStringLiteral("path")].toString().trimmed(); if (path.isEmpty()) { - done(makeError(QStringLiteral("Missing required argument 'path'"))); + done({false, QStringLiteral("Missing required argument 'path'")}); return; } const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0)); - int budget = inlineBudgetChars(); - if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars); + const int budget = context.outputBudgetChars > 0 + ? context.outputBudgetChars + : registry()->outputBudgetChars(1); const int limit = qBound(1, args[QStringLiteral("limit")].toInt(budget), budget); @@ -92,33 +74,33 @@ void FileReadTool::execute( if (!file.open(QIODevice::ReadOnly)) { const QFileInfo info(path); if (info.exists() && info.isDir()) { - done(makeError(QStringLiteral( - "Path is a directory, not a " - "file: %1") - .arg(path))); + done( + {false, + QStringLiteral("Path is a directory, not a file: %1") + .arg(path)}); } else { - done(makeError(QStringLiteral("Cannot open file: %1").arg(path))); + done({false, QStringLiteral("Cannot open file: %1").arg(path)}); } return; } const qint64 fileSize = file.size(); if (fileSize == 0) { file.close(); - done(makeOutput(QString())); + done({true, QString()}); return; } if (offset >= fileSize) { file.close(); - done(makeError(QStringLiteral( - "Offset %1 is beyond the end of the " - "file (%2 bytes)") - .arg(offset) - .arg(fileSize))); + done( + {false, + QStringLiteral("Offset %1 is beyond the end of the file (%2 bytes)") + .arg(offset) + .arg(fileSize)}); return; } if (!file.seek(offset)) { file.close(); - done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path))); + done({false, QStringLiteral("Cannot seek in file: %1").arg(path)}); return; } const qint64 wanted = qint64(limit) * 4 + 16; @@ -126,13 +108,14 @@ void FileReadTool::execute( file.close(); if (chunk.contains('\0')) { - done(makeError( - QStringLiteral("Binary files are not supported: %1").arg(path))); + done( + {false, + QStringLiteral("Binary files are not supported: %1").arg(path)}); return; } const QString content = QString::fromUtf8(chunk); if (content.size() <= limit) { - done(makeOutput(content)); + done({true, content}); return; } const QString head = content.left(limit); @@ -147,7 +130,7 @@ void FileReadTool::execute( .arg(nextOffset) .arg(fileSize) .arg(nextOffset); - done(makeOutput(output)); + done({true, output}); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/filetool.hpp b/Plugins/ZShell/Llm/filetool.hpp index cd2c06e..eac43ed 100644 --- a/Plugins/ZShell/Llm/filetool.hpp +++ b/Plugins/ZShell/Llm/filetool.hpp @@ -8,16 +8,15 @@ class FileReadTool : public LlmTool { Q_OBJECT public: - explicit FileReadTool(QObject* parent = nullptr); + explicit FileReadTool(ToolRegistry* registry, QObject* parent = nullptr); QString name() const override; QString description() const override; QJsonObject parameters() const override; void execute( - const QString& toolCallId, + const ToolContext& context, const QJsonObject& args, - int outputBudgetChars, - std::function done) override; + ToolDone done) override; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp index 407db6b..e6a8b8e 100644 --- a/Plugins/ZShell/Llm/generation.cpp +++ b/Plugins/ZShell/Llm/generation.cpp @@ -1,37 +1,50 @@ #include "generation.hpp" #include "llmclient.hpp" -#include "messagemodel.hpp" +#include "message.hpp" #include "session.hpp" #include namespace ZShell::llm { -ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) - : QObject(parent), m_timestamp(timestamp) { +ChatGeneration::ChatGeneration( + ChatMessage* message, qint64 timestamp, QObject* parent) + : QObject(parent), m_message(message), m_timestamp(timestamp) { m_timer.setParent(this); m_timer.setInterval(500); m_timer.setTimerType(Qt::CoarseTimer); connect(&m_timer, &QTimer::timeout, this, [this]() { - bool anyRunning = false; + bool runningText = false; + bool runningTool = false; for (auto* segment : m_segments) { if (!segment->running()) continue; - anyRunning = true; - segment->elapsedMsChanged(); + Q_EMIT segment->elapsedMsChanged(); + if (segment->type() == LlmSegment::Type::ToolCall) + runningTool = true; + else + runningText = true; } - if (anyRunning) Q_EMIT elapsedMsChanged(); - if (!anyRunning && !m_streaming) m_timer.stop(); + if (runningText) { + Q_EMIT reasoningElapsedMsChanged(); + Q_EMIT contentElapsedMsChanged(); + } + if (runningTool) Q_EMIT toolsElapsedMsChanged(); + if (!runningText && !runningTool && !m_streaming) m_timer.stop(); }); } +ChatMessage* ChatGeneration::message() const { + return m_message; +} + QString ChatGeneration::content() const { QStringList parts; for (const auto* segment : m_segments) { - if (segment->type() != LlmSegment::Type::Content || - segment->text().isEmpty()) - continue; - parts.append(segment->text()); + if (segment->type() != LlmSegment::Type::Content) continue; + const auto* text = qobject_cast(segment); + if (!text || text->text().isEmpty()) continue; + parts.append(text->text()); } return parts.join(QStringLiteral("\n\n")); } @@ -39,10 +52,10 @@ QString ChatGeneration::content() const { QString ChatGeneration::reasoning() const { QStringList parts; for (const auto* segment : m_segments) { - if (segment->type() != LlmSegment::Type::Reasoning || - segment->text().isEmpty()) - continue; - parts.append(segment->text()); + if (segment->type() != LlmSegment::Type::Reasoning) continue; + const auto* text = qobject_cast(segment); + if (!text || text->text().isEmpty()) continue; + parts.append(text->text()); } return parts.join(QStringLiteral("\n\n")); } @@ -77,25 +90,27 @@ qint64 ChatGeneration::toolsElapsedMs() const { return total; } +QList ChatGeneration::toolCalls() const { + QList calls; + for (auto* segment : m_segments) + if (auto* call = qobject_cast(segment)) + calls.append(call); + return calls; +} + int ChatGeneration::toolCallCount() const { - int count = 0; - for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall) ++count; - return count; + return static_cast(toolCalls().size()); } bool ChatGeneration::hasRunningTool() const { - for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall && segment->running()) - return true; + for (const auto* call : toolCalls()) + if (call->running()) return true; return false; } bool ChatGeneration::hasPendingTool() const { - for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall && - segment->status() == LlmSegment::Status::Pending) - return true; + for (const auto* call : toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) return true; return false; } @@ -103,30 +118,37 @@ void ChatGeneration::setApprovalPending(bool value) { if (m_approvalPending == value) return; m_approvalPending = value; Q_EMIT toolApprovalPendingChanged(); + Q_EMIT pendingToolCallsChanged(); } QVariantList ChatGeneration::pendingToolCalls() const { QVariantList out; if (!m_approvalPending) return out; - for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall && - segment->status() == LlmSegment::Status::Pending) - out.append(QVariant::fromValue(segment)); + for (const auto* call : toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + out.append(QVariant::fromValue(call)); return out; } void ChatGeneration::approveTools() { - if (auto* llmClient = client()) llmClient->approveTools(); + if (auto* llmClient = client()) llmClient->approveTools(this); } void ChatGeneration::denyTools() { - if (auto* llmClient = client()) llmClient->denyTools(); + if (auto* llmClient = client()) llmClient->denyTools(this); +} + +void ChatGeneration::dismissError() { + if (!m_hasError && m_errorMessage.isEmpty()) return; + m_hasError = false; + m_errorMessage.clear(); + Q_EMIT errorChanged(); + Q_EMIT errorMessageChanged(); } LlmClient* ChatGeneration::client() const { - if (auto* message = qobject_cast(parent())) - if (auto* model = qobject_cast(message->parent())) - if (auto* session = model->session()) return session->client(); + if (m_message && m_message->session()) + return m_message->session()->client(); return nullptr; } @@ -138,21 +160,19 @@ void ChatGeneration::updateReasoningActive() { } void ChatGeneration::setContent(const QString& value) { - LlmSegment* first = nullptr; + LlmTextSegment* first = nullptr; for (auto* segment : m_segments) { if (segment->type() != LlmSegment::Type::Content) continue; + auto* text = qobject_cast(segment); + if (!text) continue; if (!first) - first = segment; + first = text; else - segment->setText(QString()); + text->setText(QString()); } if (!first) { if (value.isEmpty()) return; - first = new LlmSegment( - LlmSegment::Type::Content, - QDateTime::currentMSecsSinceEpoch(), - this); - addSegment(first); + first = openTextSegment(LlmSegment::Type::Content); } first->setText(value); } @@ -164,7 +184,7 @@ void ChatGeneration::appendContent(const QString& piece) { segment->running()) segment->close(); } - openContentSegment()->appendText(piece); + openTextSegment(LlmSegment::Type::Content)->appendText(piece); } void ChatGeneration::appendReasoning(const QString& piece) { @@ -173,7 +193,7 @@ void ChatGeneration::appendReasoning(const QString& piece) { if (segment->type() == LlmSegment::Type::Content && segment->running()) segment->close(); } - openReasoningSegment()->appendText(piece); + openTextSegment(LlmSegment::Type::Reasoning)->appendText(piece); } void ChatGeneration::setError(const QString& value) { @@ -182,13 +202,7 @@ void ChatGeneration::setError(const QString& value) { 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(); + Q_EMIT errorMessageChanged(); } void ChatGeneration::setStreaming(bool value) { @@ -201,70 +215,67 @@ void ChatGeneration::setStreaming(bool value) { closeOpenSegments(); m_timer.stop(); } - Q_EMIT elapsedMsChanged(); + Q_EMIT reasoningElapsedMsChanged(); + Q_EMIT contentElapsedMsChanged(); + Q_EMIT toolsElapsedMsChanged(); updateReasoningActive(); } -LlmSegment* ChatGeneration::openContentSegment() { - for (auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::Content && segment->running()) - return segment; - auto* segment = new LlmSegment( - LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this); - segment->begin(); - addSegment(segment); - return segment; +LlmTextSegment* ChatGeneration::openTextSegment(LlmSegment::Type type) { + for (auto* segment : m_segments) { + if (segment->type() != type) continue; + auto* text = qobject_cast(segment); + if (text && text->running()) return text; + } + auto* text = + new LlmTextSegment(type, QDateTime::currentMSecsSinceEpoch(), this); + text->begin(); + addSegment(text); + return text; } -LlmSegment* ChatGeneration::openReasoningSegment() { - for (auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::Reasoning && - segment->running()) - return segment; - auto* segment = new LlmSegment( - LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this); - segment->begin(); - addSegment(segment); - return segment; -} - -LlmSegment* ChatGeneration::beginToolCall( +LlmToolCall* ChatGeneration::beginToolCall( const QString& name, const QString& toolCallId) { closeOpenSegments(); - auto* segment = new LlmSegment( - LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); - segment->setName(name); - segment->setToolCallId(toolCallId); - segment->setStatus(LlmSegment::Status::Pending); - segment->begin(); - addSegment(segment); - Q_EMIT toolStateChanged(); - return segment; + auto* call = new LlmToolCall(QDateTime::currentMSecsSinceEpoch(), this); + call->setName(name); + call->setToolCallId(toolCallId); + call->setStatus(LlmToolCall::Status::Pending); + call->begin(); + addSegment(call); + Q_EMIT toolCallCountChanged(); + return call; } void ChatGeneration::addSegment(LlmSegment* segment) { if (!segment || m_segments.contains(segment)) return; segment->setParent(this); - connect(segment, &LlmSegment::textChanged, this, [this, segment]() { - if (segment->type() == LlmSegment::Type::Reasoning) - Q_EMIT reasoningChanged(); - else if (segment->type() == LlmSegment::Type::Content) - Q_EMIT contentChanged(); - updateReasoningActive(); - }); - connect(segment, &LlmSegment::statusChanged, this, [this]() { - Q_EMIT toolStateChanged(); - }); - connect(segment, &LlmSegment::resultChanged, this, [this]() { - Q_EMIT toolStateChanged(); - }); - connect(segment, &LlmSegment::runningChanged, this, [this]() { - Q_EMIT elapsedMsChanged(); - Q_EMIT toolStateChanged(); + connect(segment, &LlmSegment::runningChanged, this, [this, segment]() { + Q_EMIT toolCallCountChanged(); + if (segment->type() == LlmSegment::Type::ToolCall) + Q_EMIT hasRunningToolChanged(); updateReasoningActive(); }); + if (auto* text = qobject_cast(segment)) + connect(text, &LlmTextSegment::textChanged, this, [this, text]() { + if (text->type() == LlmSegment::Type::Reasoning) + Q_EMIT reasoningChanged(); + else + Q_EMIT contentChanged(); + updateReasoningActive(); + }); + if (auto* call = qobject_cast(segment)) { + connect(call, &LlmToolCall::statusChanged, this, [this, call]() { + Q_EMIT hasRunningToolChanged(); + Q_EMIT pendingToolCallsChanged(); + if (call->status() == LlmToolCall::Status::Pending) + Q_EMIT toolApprovalPendingChanged(); + updateReasoningActive(); + }); + } m_segments.append(segment); Q_EMIT segmentsChanged(); + Q_EMIT toolCallCountChanged(); } void ChatGeneration::closeOpenSegments() { diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp index ac1a85c..b7b732b 100644 --- a/Plugins/ZShell/Llm/generation.hpp +++ b/Plugins/ZShell/Llm/generation.hpp @@ -5,12 +5,13 @@ #include #include #include -#include #include #include namespace ZShell::llm { +class ChatMessage; +class ChatSession; class LlmClient; class ChatGeneration : public QObject { @@ -18,35 +19,39 @@ class ChatGeneration : public QObject { QML_ELEMENT QML_UNCREATABLE("Chat generations are managed by ChatMessage") - Q_PROPERTY( - QString content READ content WRITE setContent NOTIFY contentChanged) + Q_PROPERTY(QString content READ content 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(QString errorMessage READ errorMessage NOTIFY errorMessageChanged) Q_PROPERTY( qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY - elapsedMsChanged) + reasoningElapsedMsChanged) Q_PROPERTY( - qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) - Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged) + qint64 contentElapsedMs READ contentElapsedMs NOTIFY + contentElapsedMsChanged) + Q_PROPERTY( + qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY toolsElapsedMsChanged) Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) Q_PROPERTY( bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) - Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged) + Q_PROPERTY( + bool hasRunningTool READ hasRunningTool NOTIFY hasRunningToolChanged) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) Q_PROPERTY( QList segments READ segments NOTIFY segmentsChanged) - Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) + Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY toolCallCountChanged) Q_PROPERTY( bool toolApprovalPending READ toolApprovalPending NOTIFY toolApprovalPendingChanged) Q_PROPERTY( QVariantList pendingToolCalls READ pendingToolCalls NOTIFY - toolApprovalPendingChanged) + pendingToolCallsChanged) public: - explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); + ChatGeneration(ChatMessage* message, qint64 timestamp, QObject* parent); + + [[nodiscard]] ChatMessage* message() const; [[nodiscard]] QString content() const; [[nodiscard]] QString reasoning() const; @@ -59,12 +64,12 @@ class ChatGeneration : public QObject { [[nodiscard]] bool streaming() const { return m_streaming; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } [[nodiscard]] QList segments() const { return m_segments; } + [[nodiscard]] QList toolCalls() const; [[nodiscard]] int toolCallCount() const; [[nodiscard]] bool hasRunningTool() const; [[nodiscard]] bool hasPendingTool() const; [[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; } - void setApprovalPending(bool value); [[nodiscard]] QVariantList pendingToolCalls() const; Q_INVOKABLE void approveTools(); @@ -76,10 +81,10 @@ class ChatGeneration : public QObject { void appendReasoning(const QString& piece); void setStreaming(bool value); void setError(const QString& value); + void setApprovalPending(bool value); - [[nodiscard]] LlmSegment* openContentSegment(); - [[nodiscard]] LlmSegment* openReasoningSegment(); - [[nodiscard]] LlmSegment* beginToolCall( + [[nodiscard]] LlmTextSegment* openTextSegment(LlmSegment::Type type); + [[nodiscard]] LlmToolCall* beginToolCall( const QString& name, const QString& toolCallId); void addSegment(LlmSegment* segment); void closeOpenSegments(); @@ -88,17 +93,23 @@ class ChatGeneration : public QObject { void contentChanged(); void reasoningChanged(); void errorChanged(); + void errorMessageChanged(); void reasoningActiveChanged(); - void elapsedMsChanged(); + void reasoningElapsedMsChanged(); + void contentElapsedMsChanged(); + void toolsElapsedMsChanged(); void streamingChanged(); - void toolStateChanged(); + void hasRunningToolChanged(); void segmentsChanged(); + void toolCallCountChanged(); void toolApprovalPendingChanged(); + void pendingToolCallsChanged(); private: void updateReasoningActive(); - LlmClient* client() const; + [[nodiscard]] LlmClient* client() const; + ChatMessage* m_message; QTimer m_timer; QList m_segments; bool m_reasoningActive = false; diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index 1dc831a..74aeff4 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -1,118 +1,36 @@ #include "llmclient.hpp" #include "attachment.hpp" +#include "filetool.hpp" #include "generation.hpp" #include "message.hpp" #include "messagemodel.hpp" -#include "segment.hpp" +#include "prompts.hpp" #include "session.hpp" +#include "shelltool.hpp" +#include "webfetchtool.hpp" +#include "workspace.hpp" + +#include "config/llm.hpp" +#include "config/rootnodes.hpp" #include -#include #include #include #include #include #include +#include #include #include +#include #include -#include - namespace ZShell::llm { namespace { -constexpr int kMinContextTokens = 512; - -qsizetype jsonValueChars(const QJsonValue& value) { - switch (value.type()) { - case QJsonValue::String: - return value.toString().size(); - case QJsonValue::Array: { - qsizetype total = 0; - for (const QJsonValue& item : value.toArray()) - total += jsonValueChars(item); - return total; - } - case QJsonValue::Object: { - qsizetype total = 0; - const QJsonObject obj = value.toObject(); - for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) - total += jsonValueChars(it.value()); - return total; - } - default: - return 2; - } -} - -int estimateMessageTokens(const QJsonObject& message) { - return int(jsonValueChars(message) / LlmTool::CharsPerToken) + 4; -} - -struct ContextUnit { - QList messages; - int tokens = 0; -}; - -void shrinkUnitToBudget(QList& unit, int budgetTokens) { - static const QStringList kTextKeys = { - QStringLiteral("content"), QStringLiteral("reasoning_content")}; - for (int pass = 0; pass < 8; ++pass) { - int tokens = 0; - for (const QJsonObject& message : unit) - tokens += estimateMessageTokens(message); - if (tokens <= budgetTokens) return; - qsizetype bestMessage = -1; - QString bestKey; - qsizetype bestLength = 0; - for (qsizetype i = 0; i < unit.size(); ++i) { - const QJsonObject message = unit.at(i); - for (const QString& key : kTextKeys) { - const QJsonValue value = message.value(key); - if (value.isString() && value.toString().size() > bestLength) { - bestMessage = i; - bestKey = key; - bestLength = value.toString().size(); - } - } - } - if (bestMessage < 0) return; - QString text = unit.at(bestMessage).value(bestKey).toString(); - text.truncate(qMax(32, text.size() * 3 / 4)); - const QString marker = QStringLiteral("\n[…]"); - if (!text.endsWith(marker)) text += marker; - unit[bestMessage][bestKey] = text; - } -} - -int estimateArrayTokens(const QJsonArray& messages) { - int total = 0; - for (const QJsonValue& value : messages) - total += estimateMessageTokens(value.toObject()); - return total; -} - -constexpr int kToolResultStubChars = 512; - -constexpr double kPromptEstimateDiscount = 10.0 / 12.0; - -constexpr int kMinToolResultTokens = 128; - -int completionReserve(int contextSize) { - return qBound(2048, contextSize / 8, 8192); -} - -int promptBudgetTokens(int contextSize) { - return int( - (contextSize - completionReserve(contextSize)) * - kPromptEstimateDiscount); -} - constexpr int kRefreshIntervalMs = 60'000; - constexpr int kProbeTimeoutMs = 2000; int contextSizeFromErrorMessage(const QString& message) { @@ -164,25 +82,92 @@ void LlmClient::setBusy(bool value) { Q_EMIT busyChanged(); } -void LlmClient::setStreamingChatId(const QString& id) { - if (m_streamingChatId == id) return; - m_streamingChatId = id; - Q_EMIT streamingChatIdChanged(); +void LlmClient::setModel(const QString& value) { + if (m_model == value) return; + m_model = value; + Q_EMIT modelChanged(); + if (m_model.isEmpty()) refreshModels(); +} + +void LlmClient::setContextSize(int size) { + if (size <= 0 || m_tools->contextSize() == size) return; + m_tools->setContextSize(size); + Q_EMIT contextSizeChanged(); +} + +void LlmClient::setToolsEnabled(bool value) { + if (m_tools->enabled() == value) return; + m_tools->setEnabled(value); + Q_EMIT toolsEnabledChanged(); + if (value) refreshAllStates(); +} + +void LlmClient::setSandboxEnabled(bool value) { + if (m_tools->sandboxEnabled() == value) return; + m_tools->setSandboxEnabled(value); + Q_EMIT sandboxEnabledChanged(); } LlmClient::LlmClient(QObject* parent) : QObject(parent) { m_tools = new ToolRegistry(this); - connect( - m_tools, - &ToolRegistry::enabledChanged, - this, - &LlmClient::toolsEnabledChanged); + m_store = new ChatStore(this, this); + + m_tools->registerTool(new WebFetchTool(m_tools, m_tools)); + m_tools->registerTool(new FileReadTool(m_tools, m_tools)); + m_tools->registerTool(new ShellTool(m_tools, m_tools)); + + auto* llm = config::ConfigSingleton::instance()->llm(); + if (llm) { + m_endpoint = llm->endpoint(); + m_model = llm->model(); + m_temperature = llm->temperature(); + m_configEnabled = llm->enabled(); + m_tools->setEnabled(llm->tools()); + m_tools->setSandboxEnabled(llm->sandbox()); + + connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { + m_endpoint = llm->endpoint(); + refreshAllStates(); + }); + connect(llm, &config::Llm::modelChanged, this, [this, llm]() { + setModel(llm->model()); + }); + connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() { + m_temperature = llm->temperature(); + }); + connect(llm, &config::Llm::toolsChanged, this, [this, llm]() { + setToolsEnabled(llm->tools()); + }); + connect(llm, &config::Llm::sandboxChanged, this, [this, llm]() { + setSandboxEnabled(llm->sandbox()); + }); + connect(llm, &config::Llm::enabledChanged, this, [this, llm]() { + m_configEnabled = llm->enabled(); + if (m_configEnabled) { + m_refreshTimer.setInterval(kRefreshIntervalMs); + m_refreshTimer.start(); + refreshAllStates(); + } else { + m_refreshTimer.stop(); + } + }); + } + + connect( + m_store, &ChatStore::sessionRemoved, this, [this](ChatSession* session) { + if (m_turn && m_turn->session() == session) { + stop(); + endStream(); + } + }); - m_refreshTimer.setParent(this); m_refreshTimer.setInterval(kRefreshIntervalMs); connect( &m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshAllStates); - m_refreshTimer.start(); + if (m_configEnabled) { + m_refreshTimer.start(); + refreshAllStates(); + } } LlmClient::~LlmClient() { @@ -194,86 +179,47 @@ LlmClient::~LlmClient() { m_tools->cancelAll(); } -void LlmClient::setEndpoint(const QString& value) { - if (m_endpoint == value) return; - m_endpoint = value; - Q_EMIT endpointChanged(); - m_refreshTimer.start(); - refreshAllStates(); +void LlmClient::selectModel(const QString& id) { + if (id.isEmpty()) return; + if (auto* config = config::ConfigSingleton::instance()) + config->llm()->set_model(id); + setModel(id); } -void LlmClient::setModel(const QString& value) { - if (m_model == value) return; - m_model = value; - Q_EMIT modelChanged(); - if (m_model.isEmpty()) refreshModels(); -} - -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(); +ToolContext LlmClient::toolContext() const { + ToolContext context; + if (m_turn) { + context.workspace = m_turn->session()->workspace()->path(); + context.outputBudgetChars = m_turn->outputBudgetChars(); + } + return context; } void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { - if (m_busy || !session || !target) return; - m_contextRetryUsed = false; - m_active = session; - m_streaming = target; + if (m_busy || !session || !target || !m_configEnabled) return; + + m_turn = std::make_unique(session, target, *m_tools); + if (m_turn->targetRow() < 0) { + fail(QStringLiteral( + "Internal error: generation target is not in the session")); + return; + } + target->setStreaming(true); setBusy(true); - setStreamingChatId(session->id()); - - const QUrl url = - QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); - if (!url.isValid() || url.host().isEmpty()) { - fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); - return; - } - - const auto* model = session->messagesModel(); - const int targetRow = - model->rowOf(qobject_cast(target->parent())); - if (targetRow < 0) { - fail(QStringLiteral( - "Internal error: generation target is not in the " - "session")); - return; - } - - m_transcript = QJsonArray(); - m_callBuilders.clear(); - m_callResults.clear(); - m_finishReason.clear(); - m_round = 0; - m_contentMark = 0; - m_reasoningMark = 0; - setApprovalPending(false); probeContextSize([this, session, target]() { - if (m_active != session || m_streaming != target) return; + if (!m_turn || m_turn->session() != session || + m_turn->generation() != target) + return; refreshModels(); sendRound(); }); } void LlmClient::sendRound() { - if (!m_active || !m_streaming) return; + if (!m_turn) return; probeContextSize(); - m_finishReason.clear(); - m_callBuilders.clear(); - m_callResults.clear(); - m_roundDone = false; - m_streamDone = false; - m_toolPhase = false; - m_pendingCalls = 0; - m_buffer.clear(); const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); @@ -282,51 +228,8 @@ void LlmClient::sendRound() { return; } - ChatSession* active = m_active; - ChatGeneration* streaming = m_streaming; - const auto* model = active->messagesModel(); - const int targetRow = - model->rowOf(qobject_cast(streaming->parent())); - if (targetRow < 0) { - fail(QStringLiteral( - "Internal error: generation target is not in the " - "session")); - return; - } - - int transcriptTokens = 0; - for (const QJsonValue& value : m_transcript) - transcriptTokens += estimateMessageTokens(value.toObject()); - QJsonArray messages = - buildContextMessages(m_active, targetRow, transcriptTokens); - for (const QJsonValue& value : m_transcript) - messages.append(value); - - if (m_contextSize > 0) { - const int promptLimit = promptBudgetTokens(m_contextSize); - while (estimateArrayTokens(messages) > promptLimit) { - bool stubbed = false; - for (qsizetype i = 0; i < messages.size(); ++i) { - QJsonObject message = messages.at(i).toObject(); - if (message.value("role").toString() != QLatin1String("tool")) - continue; - const QString content = message.value("content").toString(); - if (content.size() <= kToolResultStubChars) continue; - QString stub = content.left(kToolResultStubChars); - stub += QStringLiteral( - "\n[truncated to fit the context window; was " - "%1 characters]") - .arg(content.size()); - message["content"] = stub; - messages[i] = message; - stubbed = true; - break; - } - if (!stubbed) break; - } - } - - appendSandboxPreamble(messages); + Turn* turn = m_turn.get(); + QJsonArray messages = turn->contextMessages(); QJsonObject body; QJsonObject streamOptions; @@ -336,8 +239,10 @@ void LlmClient::sendRound() { body[QStringLiteral("stream")] = true; body[QStringLiteral("temperature")] = m_temperature; if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model; - const QJsonArray toolSpecs = m_tools->specifications(); - if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs; + if (m_tools->enabled()) { + const QJsonArray toolSpecs = m_tools->specifications(toolContext()); + if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs; + } QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); @@ -351,12 +256,12 @@ void LlmClient::sendRound() { } m_reply = reply; - connect(reply, &QNetworkReply::readyRead, this, [this, reply]() { - if (m_reply != reply) return; + connect(reply, &QNetworkReply::readyRead, this, [this, reply, turn]() { + if (m_reply != reply || m_turn.get() != turn) return; m_buffer.append(reply->readAll()); drainBuffer(); }); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { + connect(reply, &QNetworkReply::finished, this, [this, reply, turn]() { if (m_reply != reply) { reply->deleteLater(); return; @@ -369,11 +274,12 @@ void LlmClient::sendRound() { m_buffer.append(responseBody); reply->deleteLater(); + if (m_turn.get() != turn) return; + drainBuffer(); - if (!m_streaming) return; if (error == QNetworkReply::NoError) { - if (m_streamDone) { + if (turn->streamDone) { roundFinished(); return; } @@ -394,11 +300,12 @@ void LlmClient::sendRound() { } const QString message = serverErrorMessage(responseBody, errorString); const int reported = contextSizeFromErrorMessage(message); - if (reported > 0 && reported != m_contextSize && !m_contextRetryUsed) { + if (reported > 0 && reported != m_tools->contextSize() && + !turn->contextRetryUsed) { qWarning() << "LlmClient: prompt overflow, server reports" << reported << "tokens; retrying round"; setContextSize(reported); - m_contextRetryUsed = true; + turn->contextRetryUsed = true; sendRound(); return; } @@ -406,185 +313,14 @@ void LlmClient::sendRound() { }); } -QJsonArray LlmClient::buildContextMessages( - ChatSession* session, int stopBeforeRow, int extraReserveTokens) const { - const auto* model = session->messagesModel(); - QList units; - for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { - 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"); - const QString text = generation->content(); - const QList 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 toolSegments; - for (const auto* segment : generation->segments()) - if (segment->type() == LlmSegment::Type::ToolCall) - toolSegments.append(segment); - - QJsonObject assistant; - assistant[QStringLiteral("role")] = QStringLiteral("assistant"); - if (toolSegments.isEmpty()) - assistant[QStringLiteral("content")] = generation->content(); - else if (!generation->content().isEmpty()) - assistant[QStringLiteral("content")] = generation->content(); - if (!generation->reasoning().isEmpty()) - assistant[QStringLiteral("reasoning_content")] = - generation->reasoning(); - if (!toolSegments.isEmpty()) { - QJsonArray calls; - for (const auto* segment : toolSegments) { - QJsonObject function; - function[QStringLiteral("name")] = segment->name(); - function[QStringLiteral("arguments")] = - segment->arguments(); - QJsonObject call; - call[QStringLiteral("id")] = segment->toolCallId(); - call[QStringLiteral("type")] = QStringLiteral("function"); - call[QStringLiteral("function")] = function; - calls.append(call); - } - assistant[QStringLiteral("tool_calls")] = calls; - } - unit.messages.append(assistant); - for (const auto* segment : toolSegments) { - QJsonObject toolMessage; - toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); - toolMessage[QStringLiteral("tool_call_id")] = - segment->toolCallId(); - toolMessage[QStringLiteral("content")] = segment->result(); - unit.messages.append(toolMessage); - } - } - for (const QJsonObject& messageJson : unit.messages) - unit.tokens += estimateMessageTokens(messageJson); - units.append(unit); - } - - if (m_contextSize > 0) { - int budget = m_contextSize - completionReserve(m_contextSize) - - extraReserveTokens; - budget = qMax(budget, kMinContextTokens); - - int total = 0; - for (const ContextUnit& unit : units) - total += unit.tokens; - while (units.size() > 1 && total > budget) { - total -= units.first().tokens; - units.removeFirst(); - } - while ( - units.size() > 1 && - units.first().messages.first()[QStringLiteral("role")].toString() == - QLatin1String("assistant")) { - total -= units.first().tokens; - units.removeFirst(); - } - if (units.size() == 1 && units.first().tokens > budget) - shrinkUnitToBudget(units.first().messages, budget); - } - - QJsonArray messages; - for (const ContextUnit& unit : units) - for (const QJsonObject& messageJson : unit.messages) - messages.append(messageJson); - return messages; -} - -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 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; + Turn* turn = m_turn.get(); + if (!turn) return; const int index = call[QStringLiteral("index")].toInt(-1); if (index < 0) return; - while (m_callBuilders.size() <= index) - m_callBuilders.append(ToolCallBuilder{}); - ToolCallBuilder& builder = m_callBuilders[index]; + while (turn->calls().size() <= index) + turn->calls().append(Turn::Call{}); + Turn::Call& builder = turn->calls()[index]; builder.seen = true; const QString id = call[QStringLiteral("id")].toString(); if (!id.isEmpty()) builder.id = id; @@ -595,8 +331,9 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) { if (!arguments.isEmpty()) builder.arguments += arguments; if (!builder.segment) { - streaming->closeOpenSegments(); - builder.segment = streaming->beginToolCall(builder.name, builder.id); + turn->generation()->closeOpenSegments(); + builder.segment = + turn->generation()->beginToolCall(builder.name, builder.id); } builder.segment->setName(builder.name); builder.segment->setToolCallId(builder.id); @@ -604,90 +341,49 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) { } void LlmClient::roundFinished() { - if (m_roundDone || !m_streaming) return; - ChatGeneration* streaming = m_streaming; - m_roundDone = true; + Turn* turn = m_turn.get(); + if (!turn || turn->roundDone) return; + turn->roundDone = true; - bool hasCalls = false; - for (const auto& builder : m_callBuilders) - if (builder.seen) hasCalls = true; - if (!hasCalls) { + if (!turn->roundHasCalls()) { finishTurn(); return; } - if (m_finishReason != QLatin1String("tool_calls")) { - qWarning() << "LlmClient: stream ended" << m_finishReason + if (turn->finishReason != QLatin1String("tool_calls")) { + qWarning() << "LlmClient: stream ended" << turn->finishReason << "with incomplete tool calls, ending turn"; - for (const auto& builder : m_callBuilders) { + for (const auto& builder : turn->calls()) { if (!builder.seen || !builder.segment) continue; - builder.segment->finishTool( + builder.segment->finish( QStringLiteral( - "Stream truncated before the tool call " - "completed"), + "Stream truncated before the tool call completed"), false); } finishTurn(); return; } - if (m_round >= kMaxToolRounds) { + if (turn->round >= kMaxToolRounds) { qWarning() << "LlmClient: tool round limit reached, ending turn"; finishTurn(); return; } - streaming->closeOpenSegments(); - - const QString content = streaming->content(); - const QString reasoning = streaming->reasoning(); - QJsonObject assistant; - assistant[QStringLiteral("role")] = QStringLiteral("assistant"); - if (content.size() > m_contentMark) { - assistant[QStringLiteral("content")] = content.mid(m_contentMark); - m_contentMark = content.size(); - } - if (reasoning.size() > m_reasoningMark) { - assistant[QStringLiteral("reasoning_content")] = - reasoning.mid(m_reasoningMark); - m_reasoningMark = reasoning.size(); - } - QJsonArray calls; - for (const auto& builder : m_callBuilders) { - if (!builder.seen) continue; - QJsonObject function; - function[QStringLiteral("name")] = builder.name; - function[QStringLiteral("arguments")] = builder.arguments; - QJsonObject call; - call[QStringLiteral("id")] = builder.id; - call[QStringLiteral("type")] = QStringLiteral("function"); - call[QStringLiteral("function")] = function; - calls.append(call); - } - assistant[QStringLiteral("tool_calls")] = calls; - m_transcript.append(assistant); - m_round++; + turn->generation()->closeOpenSegments(); + turn->appendToTranscript(turn->roundAssistantMessage()); + ++turn->round; setApprovalPending(true); } void LlmClient::executeAllCalls() { - m_toolPhase = true; - m_pendingCalls = 0; - m_callResults = QList(m_callBuilders.size()); + Turn* turn = m_turn.get(); + if (!turn) return; + turn->toolPhase = true; + turn->pendingCalls = 0; + turn->results() = QList(turn->calls().size()); - int batchSize = 0; - for (const auto& call : m_callBuilders) - if (call.seen) ++batchSize; - int perCallChars = 0; - if (batchSize > 0 && m_contextSize > 0) { - const int available = promptBudgetTokens(m_contextSize) - - estimateArrayTokens(m_transcript); - const int perCallTokens = - qMax(available / batchSize, kMinToolResultTokens); - perCallChars = perCallTokens * LlmTool::CharsPerToken; - } - - for (int i = 0; i < m_callBuilders.size(); ++i) { - const auto& call = m_callBuilders.at(i); + for (int i = 0; i < turn->calls().size(); ++i) { + const Turn::Call& call = turn->calls().at(i); if (!call.seen) continue; LlmTool* tool = m_tools->tool(call.name); @@ -701,115 +397,103 @@ void LlmClient::executeAllCalls() { QJsonDocument::fromJson(call.arguments.toUtf8()); if (!doc.isObject()) { errorText = QStringLiteral( - "Error: tool arguments are not valid " - "JSON: %1") + "Error: tool arguments are not valid JSON: %1") .arg(call.arguments); } else { args = doc.object(); } } if (!errorText.isEmpty()) { - m_callResults[i] = {errorText, false}; - if (LlmSegment* segment = call.segment) - segment->finishTool(errorText, false); + turn->results()[i] = {false, errorText}; + if (call.segment) call.segment->finish(errorText, false); continue; } - ++m_pendingCalls; - tool->execute( - call.id, - args, - perCallChars, - [this, i, call](const QJsonObject& result) { - if (!m_streaming) return; - const bool success = result.contains(QStringLiteral("output")); - const QString content = - success ? result[QStringLiteral("output")].toString() - : QStringLiteral("Error: ") + - result[QStringLiteral("error")].toString(); - m_callResults[i] = {content, success}; - if (LlmSegment* segment = call.segment) - segment->finishTool(content, success); - if (--m_pendingCalls == 0) flushCallResults(); - }); + ToolContext context = toolContext(); + context.toolCallId = call.id; + + ++turn->pendingCalls; + tool->execute(context, args, [this, turn, i](const ToolResult& result) { + if (m_turn.get() != turn) return; + turn->results()[i] = result; + if (turn->calls().at(i).segment) + turn->calls().at(i).segment->finish(result.text, result.ok); + if (--turn->pendingCalls == 0) flushCallResults(); + }); } - if (m_pendingCalls == 0) flushCallResults(); + if (turn->pendingCalls == 0) flushCallResults(); } void LlmClient::flushCallResults() { - if (!m_toolPhase) return; - m_toolPhase = false; - if (!m_streaming) return; - for (int i = 0; i < m_callBuilders.size(); ++i) { - const auto& call = m_callBuilders.at(i); - if (!call.seen) continue; - QJsonObject toolMessage; - toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); - toolMessage[QStringLiteral("tool_call_id")] = call.id; - toolMessage[QStringLiteral("content")] = m_callResults.at(i).content; - m_transcript.append(toolMessage); - } + Turn* turn = m_turn.get(); + if (!turn || !turn->toolPhase) return; + turn->toolPhase = false; + + for (const QJsonValue& value : turn->toolMessages()) + turn->appendToTranscript(value.toObject()); sendRound(); } void LlmClient::setApprovalPending(bool value) { - if (m_approvalPending == value) return; - m_approvalPending = value; - if (ChatGeneration* streaming = m_streaming) - streaming->setApprovalPending(value); + Turn* turn = m_turn.get(); + if (!turn) return; + if (turn->approvalPending == value) return; + turn->approvalPending = value; + turn->generation()->setApprovalPending(value); } -void LlmClient::approveTools() { - if (!m_approvalPending) return; - ChatGeneration* streaming = m_streaming; - if (!streaming) return; +void LlmClient::approveTools(ChatGeneration* generation) { + Turn* turn = m_turn.get(); + if (!turn || !turn->approvalPending || turn->generation() != generation) + return; setApprovalPending(false); - for (auto* segment : streaming->segments()) { - if (segment->type() != LlmSegment::Type::ToolCall || - segment->status() != LlmSegment::Status::Pending) - continue; - segment->setStatus(LlmSegment::Status::Running); - segment->begin(); + for (auto* call : generation->toolCalls()) { + if (call->status() != LlmToolCall::Status::Pending) continue; + call->setStatus(LlmToolCall::Status::Running); + call->begin(); } executeAllCalls(); } -void LlmClient::denyTools() { - if (!m_approvalPending || !m_streaming) return; +void LlmClient::denyTools(ChatGeneration* generation) { + Turn* turn = m_turn.get(); + if (!turn || !turn->approvalPending || turn->generation() != generation) + return; setApprovalPending(false); - finishPendingCalls(QStringLiteral("Denied by user")); + for (auto* call : generation->toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + call->finish("Denied by user", false); finishTurn(); } void LlmClient::finishPendingCalls(const QString& resultText) { - ChatGeneration* streaming = m_streaming; - if (!streaming) return; - for (auto* segment : streaming->segments()) - if (segment->type() == LlmSegment::Type::ToolCall && - segment->status() == LlmSegment::Status::Pending) - segment->finishTool(resultText, false); + Turn* turn = m_turn.get(); + if (!turn) return; + for (auto* call : turn->generation()->toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + call->finish(resultText, false); } void LlmClient::stop() { if (!m_busy) return; - if (m_approvalPending) { + Turn* turn = m_turn.get(); + if (!turn) return; + + if (turn->approvalPending) { setApprovalPending(false); finishPendingCalls(QStringLiteral("Cancelled")); finishTurn(); return; } - if (m_toolPhase) { + if (turn->toolPhase) { m_tools->cancelAll(); - if (ChatGeneration* streaming = m_streaming) { - for (auto* segment : streaming->segments()) { - if (segment->type() == LlmSegment::Type::ToolCall && - segment->running()) - segment->finishTool(QStringLiteral("Cancelled"), false); - } - } + for (auto* call : turn->generation()->toolCalls()) + if (call->running()) + call->finish(QStringLiteral("Cancelled"), false); finishTurn(); return; } + if (m_reply) { QNetworkReply* reply = m_reply; m_reply = nullptr; @@ -819,66 +503,55 @@ void LlmClient::stop() { } void LlmClient::endStream() { - if (!m_streaming) return; - ChatGeneration* generation = m_streaming; - ChatSession* session = m_active; - m_streaming = nullptr; - m_active = nullptr; + Turn* turn = m_turn.get(); + if (!turn) return; + ChatSession* session = turn->session(); + ChatGeneration* generation = turn->generation(); + if (m_reply) { QNetworkReply* reply = m_reply; m_reply = nullptr; reply->abort(); } + generation->setStreaming(false); - m_approvalPending = false; - generation->setApprovalPending(false); - for (auto* segment : generation->segments()) - if (segment->type() == LlmSegment::Type::ToolCall && - segment->status() == LlmSegment::Status::Pending) - segment->finishTool(QStringLiteral("Cancelled"), false); + setApprovalPending(false); + for (auto* call : generation->toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + call->finish(QStringLiteral("Cancelled"), false); + if (generation->content().isEmpty() && generation->reasoning().isEmpty() && generation->toolCallCount() == 0 && !generation->error()) { - if (auto* message = qobject_cast(generation->parent())) { - if (message->generationCount() <= 1) { - if (session) session->removeMessage(message); - } else { + ChatMessage* message = generation->message(); + if (message) { + if (message->generationCount() <= 1) + session->removeMessage(message); + else message->removeGeneration(generation); - } } } + + m_turn.reset(); setBusy(false); - setStreamingChatId(QString()); } void LlmClient::finishTurn() { - if (!m_streaming) return; - ChatSession* session = m_active; + ChatSession* session = m_turn ? m_turn->session() : nullptr; endStream(); - if (session && m_pendingClear == session) { - session->clearMessages(); - m_pendingClear.clear(); - } - if (session) session->persist(); - refreshAllStates(); -} - -void LlmClient::clearOnFinish(ChatSession* session) { - m_pendingClear = session; -} - -void LlmClient::sessionRemoved(ChatSession* session) { - if (m_pendingClear == session) m_pendingClear.clear(); - if (m_active == session) { - stop(); - endStream(); + if (session) { + m_store->applyPendingClear(session); + session->persist(); } + if (m_configEnabled) refreshAllStates(); } void LlmClient::fail(const QString& message) { qWarning() << "LlmClient:" << message; - if (ChatGeneration* streaming = m_streaming) streaming->setError(message); + ChatSession* session = m_turn ? m_turn->session() : nullptr; + if (Turn* turn = m_turn.get()) turn->generation()->setError(message); finishTurn(); - Q_EMIT errorOccurred(message); + if (session && !message.isEmpty()) + Q_EMIT generationFailed(session, message); } void LlmClient::drainBuffer() { @@ -892,11 +565,12 @@ void LlmClient::drainBuffer() { } void LlmClient::handleLine(const QByteArray& line) { - if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) return; + Turn* turn = m_turn.get(); + if (!turn || line.isEmpty() || !line.startsWith("data:")) return; const QByteArray data = line.mid(5).trimmed(); if (data == "[DONE]") { - m_streamDone = true; + turn->streamDone = true; roundFinished(); return; } @@ -920,27 +594,27 @@ void LlmClient::handleLine(const QByteArray& line) { } for (const QJsonValue& choiceValue : obj["choices"].toArray()) { - if (!m_streaming) continue; + if (!m_turn) break; const QJsonObject choice = choiceValue.toObject(); const QJsonObject delta = choice["delta"].toObject(); - const QString finishReason = - choice[QStringLiteral("finish_reason")].toString(); - if (!finishReason.isEmpty()) m_finishReason = finishReason; + const QString finishReason = choice["finish_reason"].toString(); + if (!finishReason.isEmpty()) turn->finishReason = finishReason; - m_streaming->appendContent(delta["content"].toString()); + turn->generation()->appendContent(delta["content"].toString()); QString reasoning = delta["reasoning_content"].toString(); if (reasoning.isEmpty()) reasoning = delta["reasoning"].toString(); - m_streaming->appendReasoning(reasoning); + turn->generation()->appendReasoning(reasoning); for (const QJsonValue& callValue : delta["tool_calls"].toArray()) { - if (!m_streaming) break; + if (!m_turn) break; applyToolCallDelta(callValue.toObject()); } } } void LlmClient::refreshAllStates() { + if (!m_configEnabled) return; if (m_endpoint.trimmed().isEmpty()) { setServerError(QStringLiteral("LLM endpoint is not configured")); return; @@ -949,6 +623,8 @@ void LlmClient::refreshAllStates() { } void LlmClient::refreshModels() { + if (!m_configEnabled) return; + const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); if (!url.isValid() || url.host().isEmpty()) { @@ -994,24 +670,20 @@ void LlmClient::refreshModels() { m_availableModels = models; Q_EMIT availableModelsChanged(); - if (m_model.isEmpty()) { - m_model = models.first(); - Q_EMIT modelChanged(); - } else if (!models.contains(m_model)) { - m_model = models.first(); - Q_EMIT modelChanged(); + if (m_model.isEmpty() || !models.contains(m_model)) { + if (auto* config = config::ConfigSingleton::instance()) + config->llm()->set_model(models.first()); + setModel(models.first()); } }); } -void LlmClient::setContextSize(int size) { - if (size <= 0 || m_contextSize == size) return; - m_contextSize = size; - m_tools->setContextSize(size); - Q_EMIT contextSizeChanged(); -} - void LlmClient::probeContextSize(std::function done) { + if (!m_configEnabled) { + if (done) done(); + return; + } + QString base = m_endpoint.trimmed(); while (base.endsWith('/')) base.chop(1); @@ -1064,7 +736,7 @@ void LlmClient::probeContextSize(std::function done) { } if (size > 0) setContextSize(size); - else if (m_contextSize <= 0) + else if (m_tools->contextSize() <= 0) setContextSize(4096); if (done) done(); }; @@ -1075,13 +747,12 @@ void LlmClient::probeContextSize(std::function done) { } void LlmClient::updateTokenUsage(const QJsonObject& data) { - ChatSession* active = m_active; - if (!active || m_contextSize <= 0) return; + if (!m_turn || m_tools->contextSize() <= 0) return; const QJsonObject usage = data["usage"].toObject(); if (usage.isEmpty()) return; const double used = usage.value("prompt_tokens").toDouble() + usage.value("completion_tokens").toDouble(); - if (used > 0) active->setLastTokenCount(static_cast(used)); + if (used > 0) m_turn->session()->setUsageTokens(static_cast(used)); } void LlmClient::shortRequest( @@ -1129,15 +800,6 @@ void LlmClient::shortRequest( const QByteArray data = reply->readAll(); reply->deleteLater(); - qInfo() << "LlmClient:" << tag << "request finished" - << "error=" << reply->error() << reply->errorString() - << "http=" - << reply - ->attribute(QNetworkRequest::HttpStatusCodeAttribute) - .toInt() - << "response=" - << QString::fromUtf8(data.left(400)).simplified(); - if (reply->error() != QNetworkReply::NoError) return; const QJsonDocument doc = QJsonDocument::fromJson(data); @@ -1149,7 +811,6 @@ void LlmClient::shortRequest( .toObject()[QStringLiteral("content")] .toString() .trimmed(); - qInfo() << "LlmClient:" << tag << "raw result" << result; onResult(result); }); } @@ -1157,55 +818,28 @@ void LlmClient::shortRequest( void LlmClient::requestTitle(ChatSession* session, const QString& userText) { shortRequest( QStringLiteral("title"), - QStringLiteral( - "Write a short, concise title for a chat conversation starting " - "with the user's message below. At most six words, no quotation " - "marks, no trailing punctuation. Reply with the title only."), + prompts::titleSystemPrompt(), userText, [this, session = QPointer(session)](QString title) { if (!session) { qWarning() << "LlmClient: title request: session gone"; return; } - const auto isQuote = [](QChar c) { - return c == QLatin1Char('"') || c == QLatin1Char('\'') || - c == QChar(u'\u201C') || c == QChar(u'\u201D') || - c == QChar(u'\u2018') || c == QChar(u'\u2019'); - }; - while (title.size() >= 2 && isQuote(title.at(0)) && - isQuote(title.at(title.size() - 1))) - title = title.mid(1, title.size() - 2).simplified(); - while (!title.isEmpty() && (title.endsWith(QLatin1Char('.')) || - title.endsWith(QLatin1Char('!')) || - title.endsWith(QLatin1Char('?')))) - title.chop(1); - if (title.size() < 2) { + title = prompts::cleanTitle(title); + if (title.length() < 2) { qWarning() << "LlmClient: title rejected (too short)" << title; return; } - if (title.size() > 48) title = title.left(47) + QStringLiteral("…"); qInfo() << "LlmClient: suggesting title" << title; - Q_EMIT titleSuggested(session, title); + session->setTitle(title); }); } void LlmClient::requestIcon(ChatSession* session, const QString& userText) { - const QStringList icons = { - "chat", "lightbulb", "code", "description", - "article", "school", "work", "build", - "science", "palette", "music_note", "sports_esports", - "takeout_dining", "flight", "photo_camera", "psychology_alt", - "favorite", "savings", "gamepad", "auto_awesome", - }; - const QString prompt = - QStringLiteral( - "Pick the single icon name from this list that best matches the " - "topic of the user's message below: %1. Reply with only the icon " - "name, exactly as written in the list, and nothing else.") - .arg(icons.join(QStringLiteral(", "))); + const QStringList icons = prompts::iconNames(); shortRequest( QStringLiteral("icon"), - prompt, + prompts::iconSystemPrompt(), userText, [this, session = QPointer(session), icons](QString name) { if (!session) { @@ -1226,7 +860,7 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) { name = QStringLiteral("chat"); } qInfo() << "LlmClient: suggesting icon" << name; - Q_EMIT iconSuggested(session, name); + session->setIcon(name); }); } diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index 3a1b955..a9430ed 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -1,18 +1,19 @@ #pragma once -#include "segment.hpp" +#include "chatstore.hpp" #include "tool.hpp" +#include "turn.hpp" #include #include #include #include -#include #include #include #include #include +#include class QJsonObject; class QNetworkReply; @@ -21,80 +22,75 @@ namespace ZShell::llm { class ChatGeneration; class ChatSession; -class LlmSegment; -class LlmTool; class LlmClient : public QObject { Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString model READ model NOTIFY modelChanged) + Q_PROPERTY( + QStringList availableModels READ availableModels NOTIFY + availableModelsChanged) + Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) + Q_PROPERTY( + bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY + toolsEnabledChanged) + Q_PROPERTY( + bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY + sandboxEnabledChanged) + Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged) + Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) public: explicit LlmClient(QObject* parent = nullptr); ~LlmClient() override; - [[nodiscard]] QString endpoint() const { return m_endpoint; } + [[nodiscard]] ChatStore* chats() const { return m_store; } + + [[nodiscard]] bool busy() const { return m_busy; } [[nodiscard]] QString model() const { return m_model; } - [[nodiscard]] double temperature() const { return m_temperature; } [[nodiscard]] QStringList availableModels() const { return m_availableModels; } - [[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 done = {}); - void refreshAllStates(); - - [[nodiscard]] bool busy() const { return m_busy; } + [[nodiscard]] int contextSize() const { return m_tools->contextSize(); } [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } - void setToolsEnabled(bool value) { m_tools->setEnabled(value); } - [[nodiscard]] bool sandboxEnabled() const { return m_sandboxEnabled; } + [[nodiscard]] bool sandboxEnabled() const { + return m_tools->sandboxEnabled(); + } + [[nodiscard]] QString serverError() const { return m_serverError; } + [[nodiscard]] ChatSession* streamingSession() const { + return m_turn ? m_turn->session() : nullptr; + } + + void setToolsEnabled(bool value); void setSandboxEnabled(bool value); - [[nodiscard]] QString activeWorkspace() const; - [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } - [[nodiscard]] ChatSession* streamingSession() const { return m_active; } + + Q_INVOKABLE void selectModel(const QString& id); + Q_INVOKABLE void stop(); + Q_INVOKABLE void refreshModels(); + Q_INVOKABLE void refreshFromServer(); void startGeneration(ChatSession* session, ChatGeneration* target); - void stop(); - void endStream(); - void clearOnFinish(ChatSession* session); - void sessionRemoved(ChatSession* session); - - void approveTools(); - void denyTools(); - - void refreshModels(); + void approveTools(ZShell::llm::ChatGeneration* generation); + void denyTools(ZShell::llm::ChatGeneration* generation); void requestTitle(ChatSession* session, const QString& userText); void requestIcon(ChatSession* session, const QString& userText); Q_SIGNALS: void busyChanged(); - void endpointChanged(); void modelChanged(); void availableModelsChanged(); void contextSizeChanged(); void toolsEnabledChanged(); - void streamingChatIdChanged(); - void errorOccurred(const QString& message); + void sandboxEnabledChanged(); void serverErrorChanged(); - void titleSuggested(ZShell::llm::ChatSession* session, const QString& title); - void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon); + void generationFailed( + ZShell::llm::ChatSession* session, const QString& message); private: - struct ToolCallBuilder { - QString id; - QString name; - QString arguments; - QPointer segment; - bool seen = false; - }; - 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,13 +98,17 @@ class LlmClient : public QObject { void setApprovalPending(bool value); void finishPendingCalls(const QString& resultText); void finishTurn(); + void endStream(); void fail(const QString& message); void handleLine(const QByteArray& line); void drainBuffer(); void updateTokenUsage(const QJsonObject& data); void setBusy(bool value); - void setStreamingChatId(const QString& id); void setServerError(const QString& value); + void setModel(const QString& value); + void setContextSize(int size); + void probeContextSize(std::function done = {}); + void refreshAllStates(); void shortRequest( const QString& tag, const QString& systemPrompt, @@ -119,42 +119,22 @@ class LlmClient : public QObject { static QString jsonErrorMessage(const QByteArray& body); static QString serverErrorMessage( const QByteArray& body, const QString& fallback); + [[nodiscard]] ToolContext toolContext() const; QNetworkAccessManager m_manager; + ChatStore* m_store = nullptr; ToolRegistry* m_tools = nullptr; + std::unique_ptr m_turn; QNetworkReply* m_reply = nullptr; QByteArray m_buffer; QTimer m_refreshTimer; - QPointer m_active; - QPointer m_streaming; - QPointer m_pendingClear; - bool m_busy = false; - QString m_streamingChatId; QString m_endpoint; QString m_model; QStringList m_availableModels; QString m_serverError; double m_temperature = 0.7; - int m_contextSize = 0; - - QJsonArray m_transcript; - QList m_callBuilders; - struct ToolCallResult { - QString content; - bool success = false; - }; - QList m_callResults; - QString m_finishReason; - 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; - int m_pendingCalls = 0; + bool m_busy = false; + bool m_configEnabled = true; static constexpr int kMaxToolRounds = 12; }; diff --git a/Plugins/ZShell/Llm/markdownblock.cpp b/Plugins/ZShell/Llm/markdownblock.cpp new file mode 100644 index 0000000..9b2d7e1 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownblock.cpp @@ -0,0 +1,23 @@ +#include "markdownblock.hpp" + +namespace ZShell::llm { + +MarkdownBlock::MarkdownBlock( + Type type, + const QString& id, + const QString& text, + const QString& latex, + const QString& code, + const QString& language, + int level, + QObject* parent) + : QObject(parent) + , m_type(type) + , m_id(id) + , m_text(text) + , m_latex(latex) + , m_code(code) + , m_language(language) + , m_level(level) {} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownblock.hpp b/Plugins/ZShell/Llm/markdownblock.hpp index 576a289..fee7576 100644 --- a/Plugins/ZShell/Llm/markdownblock.hpp +++ b/Plugins/ZShell/Llm/markdownblock.hpp @@ -1,20 +1,54 @@ #pragma once #include +#include #include namespace ZShell::llm { -class LlmMarkdown : public QObject { +class MarkdownBlock : public QObject { Q_OBJECT QML_ELEMENT - QML_UNCREATABLE("Blocks are produced by LlmSegment") + QML_UNCREATABLE("Blocks are produced by LlmTextSegment") + + Q_PROPERTY(Type type READ type CONSTANT) + Q_PROPERTY(QString id READ id CONSTANT) + Q_PROPERTY(QString text READ text CONSTANT) + Q_PROPERTY(QString latex READ latex CONSTANT) + Q_PROPERTY(QString code READ code CONSTANT) + Q_PROPERTY(QString language READ language CONSTANT) + Q_PROPERTY(int level READ level CONSTANT) public: enum class Type : int { Text = 0, Heading, Code, Math }; Q_ENUM(Type) - explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {} + MarkdownBlock( + Type type, + const QString& id, + const QString& text, + const QString& latex, + const QString& code, + const QString& language, + int level, + QObject* parent = nullptr); + + [[nodiscard]] Type type() const { return m_type; } + [[nodiscard]] QString id() const { return m_id; } + [[nodiscard]] QString text() const { return m_text; } + [[nodiscard]] QString latex() const { return m_latex; } + [[nodiscard]] QString code() const { return m_code; } + [[nodiscard]] QString language() const { return m_language; } + [[nodiscard]] int level() const { return m_level; } + + private: + Type m_type; + QString m_id; + QString m_text; + QString m_latex; + QString m_code; + QString m_language; + int m_level = 0; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownparser.cpp b/Plugins/ZShell/Llm/markdownparser.cpp index 756334d..5d4a5ad 100644 --- a/Plugins/ZShell/Llm/markdownparser.cpp +++ b/Plugins/ZShell/Llm/markdownparser.cpp @@ -1,18 +1,15 @@ #include "markdownparser.hpp" -#include "markdownblock.hpp" - #include #include #include #include -#include namespace ZShell::llm { -QVariantList MarkdownParser::parse(const QString& source) { - QVariantList blocks; +QVector MarkdownParser::parse(const QString& source) { + QVector blocks; if (source.trimmed().isEmpty()) return blocks; const QStringList lines = source.split('\n'); @@ -35,42 +32,36 @@ QVariantList MarkdownParser::parse(const QString& source) { QStringLiteral("\\$\\$(.+?)\\$\\$"), QRegularExpression::DotMatchesEverythingOption); - auto makeBlock = [&](LlmMarkdown::Type type) { - QVariantMap block; - block.insert("type", static_cast(type)); - block.insert( - "id", - QString::number(static_cast(blocks.size())) + - QLatin1Char(':') + QString::number(static_cast(type))); - return block; - }; - auto appendText = [&](const QString& text) { if (text.trimmed().isEmpty()) return; - QVariantMap block = makeBlock(LlmMarkdown::Type::Text); - block.insert("text", text); + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Text; + block.text = text; blocks.append(block); }; auto appendMath = [&](const QString& latex) { if (latex.trimmed().isEmpty()) return; - QVariantMap block = makeBlock(LlmMarkdown::Type::Math); - block.insert("latex", latex); + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Math; + block.latex = latex; blocks.append(block); }; auto appendCode = [&](const QString& language, const QString& code) { - QVariantMap block = makeBlock(LlmMarkdown::Type::Code); - block.insert("language", language); - block.insert("code", code); + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Code; + block.language = language; + block.code = code; blocks.append(block); }; auto appendHeading = [&](int level, const QString& text) { if (text.trimmed().isEmpty()) return; - QVariantMap block = makeBlock(LlmMarkdown::Type::Heading); - block.insert("level", level); - block.insert("text", text); + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Heading; + block.level = level; + block.text = text; blocks.append(block); }; diff --git a/Plugins/ZShell/Llm/markdownparser.hpp b/Plugins/ZShell/Llm/markdownparser.hpp index 433e06b..5ab6007 100644 --- a/Plugins/ZShell/Llm/markdownparser.hpp +++ b/Plugins/ZShell/Llm/markdownparser.hpp @@ -1,13 +1,24 @@ #pragma once +#include "markdownblock.hpp" + #include -#include +#include namespace ZShell::llm { +struct MarkdownBlockData { + MarkdownBlock::Type type = MarkdownBlock::Type::Text; + QString text; + QString latex; + QString code; + QString language; + int level = 0; +}; + class MarkdownParser { public: - [[nodiscard]] static QVariantList parse(const QString& source); + [[nodiscard]] static QVector parse(const QString& source); }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index 0cf1fba..a453b01 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -1,25 +1,36 @@ #include "message.hpp" -#include "messagemodel.hpp" #include "session.hpp" namespace ZShell::llm { -namespace { +ChatMessage::ChatMessage( + ChatSession* session, Role role, qint64 timestamp, QObject* parent) + : QObject(parent) + , m_session(session) + , m_role(role) + , m_timestamp(timestamp) {} -ChatSession* sessionOf(const ChatMessage* message) { - if (auto* model = qobject_cast(message->parent())) - return model->session(); - return nullptr; +QString ChatMessage::content() const { + if (m_role == Role::User) return m_content; + const ChatGeneration* generation = activeGeneration(); + return generation ? generation->content() : QString(); } -} // namespace - -ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) - : QObject(parent), m_role(role), m_timestamp(timestamp) {} +void ChatMessage::setContent(const QString& value) { + if (m_role != Role::User || m_content == value) return; + m_content = value; + Q_EMIT contentChanged(); +} ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) { - auto* generation = new ChatGeneration(timestamp, this); + if (m_role != Role::Assistant) return nullptr; + + auto* generation = new ChatGeneration(this, timestamp, this); + connect( + generation, &ChatGeneration::contentChanged, this, [this, generation]() { + if (generation == activeGeneration()) Q_EMIT contentChanged(); + }); m_generations.append(generation); if (m_active < 0) m_active = static_cast(m_generations.size() - 1); Q_EMIT generationsChanged(); @@ -27,7 +38,7 @@ ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) { } ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) { - auto* generation = addGeneration(timestamp); + ChatGeneration* generation = addGeneration(timestamp); setActiveInternal(static_cast(m_generations.size() - 1)); return generation; } @@ -44,13 +55,17 @@ void ChatMessage::removeGeneration(ChatGeneration* generation) { m_active = static_cast(m_generations.size() - 1); } Q_EMIT generationsChanged(); - if (wasActive) Q_EMIT activeGenerationChanged(); + if (wasActive) { + Q_EMIT activeGenerationChanged(); + Q_EMIT contentChanged(); + } } void ChatMessage::setActiveInternal(int index) { if (index < 0 || index >= m_generations.size() || index == m_active) return; m_active = index; Q_EMIT activeGenerationChanged(); + Q_EMIT contentChanged(); } void ChatMessage::setActiveGeneration(int index) { @@ -72,17 +87,25 @@ void ChatMessage::setAttachments(QList attachments) { } void ChatMessage::edit(const QString& newContent) { - if (auto* generation = activeGeneration()) + if (m_role == Role::User) { + setContent(newContent); + } else if (ChatGeneration* generation = activeGeneration()) { generation->setContent(newContent); - if (auto* session = sessionOf(this)) session->persist(); + } else { + return; + } + + if (m_session) m_session->persist(); } void ChatMessage::retry() { - if (auto* session = sessionOf(this)) session->retry(this); + if (m_role != Role::Assistant || !m_session) return; + m_session->retry(this); } void ChatMessage::generate() { - if (auto* session = sessionOf(this)) session->continueFrom(this); + if (m_role != Role::Assistant || !m_session) return; + m_session->continueFrom(this); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp index df9ff5d..022f7cf 100644 --- a/Plugins/ZShell/Llm/message.hpp +++ b/Plugins/ZShell/Llm/message.hpp @@ -10,13 +10,16 @@ namespace ZShell::llm { +class ChatSession; + class ChatMessage : public QObject { Q_OBJECT QML_ELEMENT - QML_UNCREATABLE("Chat messages are created by the Chat singleton") + QML_UNCREATABLE("Chat messages are created by ChatMessageModel") Q_PROPERTY(Role role READ role CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY(QString content READ content NOTIFY contentChanged) Q_PROPERTY( int generationCount READ generationCount NOTIFY generationsChanged) Q_PROPERTY( @@ -36,10 +39,16 @@ class ChatMessage : public QObject { enum class Role : int { User = 0, Assistant }; Q_ENUM(Role) - explicit ChatMessage(Role role, qint64 timestamp, QObject* parent = nullptr); + ChatMessage( + ChatSession* session, + Role role, + qint64 timestamp, + QObject* parent = nullptr); + [[nodiscard]] ChatSession* session() const { return m_session; } [[nodiscard]] Role role() const { return m_role; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] QString content() const; [[nodiscard]] int generationCount() const { return static_cast(m_generations.size()); } @@ -68,7 +77,10 @@ class ChatMessage : public QObject { ChatGeneration* appendGeneration(qint64 timestamp); void removeGeneration(ChatGeneration* generation); + void setContent(const QString& value); + Q_SIGNALS: + void contentChanged(); void generationsChanged(); void activeGenerationChanged(); void attachmentsChanged(); @@ -76,8 +88,10 @@ class ChatMessage : public QObject { private: void setActiveInternal(int index); + ChatSession* m_session; Role m_role; qint64 m_timestamp; + QString m_content; QList m_generations; QList m_attachments; int m_active = -1; diff --git a/Plugins/ZShell/Llm/messagemodel.cpp b/Plugins/ZShell/Llm/messagemodel.cpp index 9dd50c8..5267fd6 100644 --- a/Plugins/ZShell/Llm/messagemodel.cpp +++ b/Plugins/ZShell/Llm/messagemodel.cpp @@ -40,14 +40,18 @@ int ChatMessageModel::rowOf(const ChatMessage* message) const { ChatMessage* ChatMessageModel::createMessage( ChatMessage::Role role, qint64 timestamp) { - return new ChatMessage(role, timestamp, this); + return new ChatMessage(m_session, role, timestamp, this); } ChatMessage* ChatMessageModel::appendNewest( ChatMessage::Role role, const QString& content, qint64 timestamp) { - auto* message = new ChatMessage(role, timestamp, this); - auto* generation = message->addGeneration(timestamp); - generation->setContent(content); + auto* message = createMessage(role, timestamp); + if (role == ChatMessage::Role::User) { + message->setContent(content); + } else { + auto* generation = message->addGeneration(timestamp); + generation->setContent(content); + } beginInsertRows(QModelIndex(), 0, 0); m_messages.prepend(message); diff --git a/Plugins/ZShell/Llm/prompts.cpp b/Plugins/ZShell/Llm/prompts.cpp new file mode 100644 index 0000000..4cc5ec5 --- /dev/null +++ b/Plugins/ZShell/Llm/prompts.cpp @@ -0,0 +1,68 @@ +#include "prompts.hpp" + +namespace ZShell::llm::prompts { + +QString sandboxPreamble(const QString& workspace, const QStringList& files) { + return 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"))); +} + +QString titleSystemPrompt() { + return QStringLiteral( + "Write a short, concise title for a chat conversation starting " + "with the user's message below. At most six words, no quotation " + "marks, no trailing punctuation. Reply with the title only."); +} + +QString iconSystemPrompt() { + return QStringLiteral( + "Pick the single icon name from this list that best matches the " + "topic of the user's message below: %1. Reply with only the " + "icon " + "name, exactly as written in the list, and nothing else.") + .arg(iconNames().join(QStringLiteral(", "))); +} + +QStringList iconNames() { + return { + "chat", "lightbulb", "code", "description", + "article", "school", "work", "build", + "science", "palette", "music_note", "sports_esports", + "takeout_dining", "flight", "photo_camera", "psychology_alt", + "favorite", "savings", "gamepad", "auto_awesome", + }; +} + +QString cleanTitle(const QString& raw) { + static const auto isQuote = [](QChar c) { + return c == QLatin1Char('"') || c == QLatin1Char('\'') || + c == QChar(u'\u201C') || c == QChar(u'\u201D') || + c == QChar(u'\u2018') || c == QChar(u'\u2019'); + }; + + QString title = raw; + while (title.size() >= 2 && isQuote(title.at(0)) && + isQuote(title.at(title.size() - 1))) + title = title.mid(1, title.size() - 2).simplified(); + while (!title.isEmpty() && (title.endsWith(QLatin1Char('.')) || + title.endsWith(QLatin1Char('!')) || + title.endsWith(QLatin1Char('?')))) + title.chop(1); + + if (title.size() > MaxTitleLength) + title = title.left(MaxTitleLength - 1) + QStringLiteral("…"); + return title; +} + +} // namespace ZShell::llm::prompts diff --git a/Plugins/ZShell/Llm/prompts.hpp b/Plugins/ZShell/Llm/prompts.hpp new file mode 100644 index 0000000..4ae8ed0 --- /dev/null +++ b/Plugins/ZShell/Llm/prompts.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +namespace ZShell::llm::prompts { + +[[nodiscard]] QString sandboxPreamble( + const QString& workspace, const QStringList& files); + +[[nodiscard]] QString titleSystemPrompt(); +[[nodiscard]] QString iconSystemPrompt(); + +[[nodiscard]] QStringList iconNames(); + +constexpr int MaxTitleLength = 48; + +[[nodiscard]] QString cleanTitle(const QString& raw); + +} // namespace ZShell::llm::prompts diff --git a/Plugins/ZShell/Llm/segment.cpp b/Plugins/ZShell/Llm/segment.cpp index 9134f7d..365d722 100644 --- a/Plugins/ZShell/Llm/segment.cpp +++ b/Plugins/ZShell/Llm/segment.cpp @@ -11,16 +11,12 @@ 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) { - m_markdownTimer.setInterval(kMarkdownRefreshMs); - connect(&m_markdownTimer, &QTimer::timeout, this, [this]() { - if (!m_markdownDirty) return; - if (parseMarkdown()) m_markdownDirty = false; - }); -} + : QObject(parent), m_type(type), m_timestamp(timestamp) {} + +LlmSegment::~LlmSegment() = default; qint64 LlmSegment::elapsedMs() const { if (m_startedAt <= 0) return 0; @@ -45,60 +41,7 @@ void LlmSegment::close() { Q_EMIT runningChanged(); Q_EMIT elapsedMsChanged(); } - if (m_type == Type::Content && m_markdownDirty) { - m_markdownTimer.stop(); - if (parseMarkdown()) m_markdownDirty = false; - } -} - -void LlmSegment::appendText(const QString& piece) { - if (piece.isEmpty()) return; - m_text += piece; - Q_EMIT textChanged(); - scheduleMarkdown(); -} - -void LlmSegment::setText(const QString& value) { - if (m_text == value) return; - m_text = value; - Q_EMIT textChanged(); - scheduleMarkdown(); -} - -void LlmSegment::setName(const QString& value) { - if (m_name == value) return; - m_name = value; - Q_EMIT nameChanged(); -} - -void LlmSegment::setToolCallId(const QString& value) { - if (m_toolCallId == value) return; - m_toolCallId = value; - Q_EMIT toolCallIdChanged(); -} - -void LlmSegment::appendArguments(const QString& piece) { - if (piece.isEmpty()) return; - m_arguments += piece; - Q_EMIT argumentsChanged(); -} - -void LlmSegment::setResult(const QString& value) { - if (m_result == value) return; - m_result = value; - Q_EMIT resultChanged(); -} - -void LlmSegment::setStatus(Status value) { - if (m_status == value) return; - m_status = value; - Q_EMIT statusChanged(); -} - -void LlmSegment::finishTool(const QString& resultText, bool success) { - setResult(resultText); - setStatus(success ? Status::Success : Status::Error); - close(); + closed(); } void LlmSegment::restore(qint64 elapsedMs) { @@ -106,32 +49,119 @@ void LlmSegment::restore(qint64 elapsedMs) { m_endedAt = m_timestamp + qMax(0, elapsedMs); } -void LlmSegment::scheduleMarkdown() { - if (m_type != Type::Content) return; +LlmTextSegment::LlmTextSegment(Type type, qint64 timestamp, QObject* parent) + : LlmSegment(type, timestamp, parent) { + m_markdownTimer.setInterval(kMarkdownRefreshMs); + connect(&m_markdownTimer, &QTimer::timeout, this, [this]() { + if (!m_markdownDirty) return; + if (parseMarkdown()) m_markdownDirty = false; + }); +} + +void LlmTextSegment::setText(const QString& value) { + if (m_text == value) return; + m_text = value; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmTextSegment::appendText(const QString& piece) { + if (piece.isEmpty()) return; + m_text += piece; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmTextSegment::closed() { + if (type() != Type::Content) return; + if (m_markdownDirty) { + m_markdownTimer.stop(); + if (parseMarkdown()) m_markdownDirty = false; + } +} + +void LlmTextSegment::scheduleMarkdown() { + if (type() != Type::Content) return; m_markdownDirty = true; if (!m_markdownTimer.isActive()) m_markdownTimer.start(); } -bool LlmSegment::parseMarkdown() { +bool LlmTextSegment::parseMarkdown() { if (m_parseInFlight) return false; m_parseInFlight = true; const QString text = m_text; QThreadPool::globalInstance()->start([this, text]() { - const QVariantList blocks = MarkdownParser::parse(text); - QPointer guard(this); + const QVector parsed = MarkdownParser::parse(text); + QPointer guard(this); QMetaObject::invokeMethod( QCoreApplication::instance(), - [guard, blocks]() { - LlmSegment* seg = guard; - if (!seg) return; - seg->m_parseInFlight = false; - seg->m_markdown = blocks; - Q_EMIT seg->markdownChanged(); - if (seg->m_markdownDirty) seg->parseMarkdown(); + [guard, parsed]() { + LlmTextSegment* segment = guard; + if (!segment) return; + segment->m_parseInFlight = false; + + QList blocks; + blocks.reserve(static_cast(parsed.size())); + for (int i = 0; i < static_cast(parsed.size()); ++i) { + const MarkdownBlockData& data = parsed.at(i); + const QString id = QStringLiteral("%1:%2").arg(i).arg( + static_cast(data.type)); + blocks.append(new MarkdownBlock( + data.type, + id, + data.text, + data.latex, + data.code, + data.language, + data.level, + segment)); + } + segment->m_blocks = blocks; + Q_EMIT segment->markdownChanged(); + if (segment->m_markdownDirty) segment->parseMarkdown(); }, Qt::QueuedConnection); }); return true; } +LlmToolCall::LlmToolCall(qint64 timestamp, QObject* parent) + : LlmSegment(Type::ToolCall, timestamp, parent) {} + +void LlmToolCall::setName(const QString& value) { + if (m_name == value) return; + m_name = value; + Q_EMIT nameChanged(); +} + +void LlmToolCall::setToolCallId(const QString& value) { + if (m_toolCallId == value) return; + m_toolCallId = value; + Q_EMIT toolCallIdChanged(); +} + +void LlmToolCall::appendArguments(const QString& piece) { + if (piece.isEmpty()) return; + m_arguments += piece; + Q_EMIT argumentsChanged(); +} + +void LlmToolCall::setResult(const QString& value) { + if (m_result == value) return; + m_result = value; + Q_EMIT resultChanged(); +} + +void LlmToolCall::setStatus(Status value) { + if (m_status == value) return; + m_status = value; + Q_EMIT statusChanged(); +} + +void LlmToolCall::finish(const QString& resultText, bool success) { + setResult(resultText); + setStatus(success ? Status::Success : Status::Error); + close(); +} + } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp index ecef553..148a57c 100644 --- a/Plugins/ZShell/Llm/segment.hpp +++ b/Plugins/ZShell/Llm/segment.hpp @@ -1,9 +1,11 @@ #pragma once +#include "markdownblock.hpp" + +#include #include #include #include -#include #include namespace ZShell::llm { @@ -15,79 +17,121 @@ class LlmSegment : public QObject { Q_PROPERTY(Type type READ type CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) - Q_PROPERTY(QString text READ text NOTIFY textChanged) - Q_PROPERTY(QString name READ name NOTIFY nameChanged) - Q_PROPERTY(QString toolCallId READ toolCallId NOTIFY toolCallIdChanged) - Q_PROPERTY(QString arguments READ arguments NOTIFY argumentsChanged) - Q_PROPERTY(QString result READ result NOTIFY resultChanged) - Q_PROPERTY(Status status READ status NOTIFY statusChanged) Q_PROPERTY(bool running READ running NOTIFY runningChanged) Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged) - Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged) public: - enum class Type : int { Reasoning = 0, ToolCall, Content }; + enum class Type : int { Reasoning = 0, Content, ToolCall }; Q_ENUM(Type) - enum class Status : int { None = 0, Running, Success, Error, Pending }; - Q_ENUM(Status) - - explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); + LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); + ~LlmSegment() override; [[nodiscard]] Type type() const { return m_type; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } - [[nodiscard]] QString text() const { return m_text; } - [[nodiscard]] QString name() const { return m_name; } - [[nodiscard]] QString toolCallId() const { return m_toolCallId; } - [[nodiscard]] QString arguments() const { return m_arguments; } - [[nodiscard]] QString result() const { return m_result; } - [[nodiscard]] Status status() const { return m_status; } [[nodiscard]] bool running() const { return m_running; } [[nodiscard]] qint64 elapsedMs() const; - [[nodiscard]] QVariantList markdown() const { return m_markdown; } void begin(); void close(); - void appendText(const QString& piece); - void setText(const QString& value); - void setName(const QString& value); - void setToolCallId(const QString& value); - void appendArguments(const QString& piece); - void setResult(const QString& value); - void setStatus(Status value); - void finishTool(const QString& resultText, bool success); + void restore(qint64 elapsedMs); + Q_SIGNALS: + void runningChanged(); + void elapsedMsChanged(); + + protected: + virtual void closed() {} + + qint64 m_startedAt = 0; + qint64 m_endedAt = 0; + + private: + Type m_type; + qint64 m_timestamp; + bool m_running = false; +}; + +class LlmTextSegment : public LlmSegment { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Text segments are managed by ChatGeneration") + + Q_PROPERTY(QString text READ text NOTIFY textChanged) + Q_PROPERTY( + QList markdown READ markdown NOTIFY + markdownChanged) + + public: + LlmTextSegment(Type type, qint64 timestamp, QObject* parent = nullptr); + + [[nodiscard]] QString text() const { return m_text; } + void setText(const QString& value); + void appendText(const QString& piece); + + [[nodiscard]] QList markdown() const { return m_blocks; } + Q_SIGNALS: void textChanged(); void markdownChanged(); - void nameChanged(); - void toolCallIdChanged(); - void argumentsChanged(); - void resultChanged(); - void statusChanged(); - void runningChanged(); - void elapsedMsChanged(); private: void scheduleMarkdown(); bool parseMarkdown(); + void closed() override; - Type m_type; - qint64 m_timestamp; QString m_text; - QString m_name; - QString m_toolCallId; - QString m_arguments; - QString m_result; - Status m_status = Status::None; - bool m_running = false; - qint64 m_startedAt = 0; - qint64 m_endedAt = 0; - QVariantList m_markdown; + QList m_blocks; QTimer m_markdownTimer; bool m_markdownDirty = false; bool m_parseInFlight = false; }; +class LlmToolCall : public LlmSegment { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Tool calls are managed by ChatGeneration") + + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString toolCallId READ toolCallId NOTIFY toolCallIdChanged) + Q_PROPERTY(QString arguments READ arguments NOTIFY argumentsChanged) + Q_PROPERTY(QString result READ result NOTIFY resultChanged) + Q_PROPERTY(Status status READ status NOTIFY statusChanged) + + public: + enum class Status : int { None = 0, Running, Success, Error, Pending }; + Q_ENUM(Status) + + LlmToolCall(qint64 timestamp, QObject* parent = nullptr); + + [[nodiscard]] QString name() const { return m_name; } + [[nodiscard]] QString toolCallId() const { return m_toolCallId; } + [[nodiscard]] QString arguments() const { return m_arguments; } + [[nodiscard]] QString result() const { return m_result; } + [[nodiscard]] Status status() const { return m_status; } + + void setName(const QString& value); + void setToolCallId(const QString& value); + void appendArguments(const QString& piece); + void setResult(const QString& value); + void setStatus(Status value); + + void finish(const QString& resultText, bool success); + + Q_SIGNALS: + void nameChanged(); + void toolCallIdChanged(); + void argumentsChanged(); + void resultChanged(); + void statusChanged(); + + private: + QString m_name; + QString m_toolCallId; + QString m_arguments; + QString m_result; + Status m_status = Status::None; +}; + } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp index ee7e4df..2a22a55 100644 --- a/Plugins/ZShell/Llm/session.cpp +++ b/Plugins/ZShell/Llm/session.cpp @@ -2,42 +2,29 @@ #include "attachment.hpp" #include "chatstore.hpp" +#include "clipboard.hpp" #include "llmclient.hpp" +#include "prompts.hpp" +#include "workspace.hpp" -#include #include #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include 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(); - if (flat.size() <= 48) return flat; - return flat.left(47) + QStringLiteral("…"); -} +constexpr int kMaxStoredMessages = 200; } // namespace -ChatSession::ChatSession(const QString& id, QObject* parent) - : QObject(parent), m_id(id), m_workspace(sandboxDirFor(id)) { +ChatSession::ChatSession(ChatStore* store, const QString& id, QObject* parent) + : QObject(parent), m_store(store), m_id(id) { m_model = new ChatMessageModel(this, this); + m_workspace = new Workspace(this, this); + connect( m_model, &QAbstractListModel::rowsInserted, @@ -70,18 +57,27 @@ void ChatSession::setIcon(const QString& value) { if (m_icon == value) return; m_icon = value; Q_EMIT iconChanged(); + if (m_store) m_store->saveFlags(this); } void ChatSession::setUpdatedAt(qint64 value) { if (m_updatedAt == value) return; m_updatedAt = value; Q_EMIT updatedAtChanged(); + if (m_store) m_store->reposition(this); } void ChatSession::setPinned(bool value) { if (m_pinned == value) return; m_pinned = value; Q_EMIT pinnedChanged(); + if (m_store) m_store->saveFlags(this); +} + +void ChatSession::setUsageTokens(int value) { + if (m_usageTokens == value) return; + m_usageTokens = value; + Q_EMIT usageTokensChanged(); } void ChatSession::setCount(int value) { @@ -90,29 +86,25 @@ void ChatSession::setCount(int value) { Q_EMIT messageCountChanged(); } -void ChatSession::setMeta( - const QString& title, qint64 createdAt, qint64 updatedAt, int messageCount) { +void ChatSession::hydrate( + const QString& title, + const QString& icon, + qint64 createdAt, + qint64 updatedAt, + int messageCount, + bool pinned) { m_title = title; + m_icon = icon; m_createdAt = createdAt; m_updatedAt = updatedAt; m_messageCount = messageCount; -} - -void ChatSession::setLastTokenCount(int value) { - m_lastTokenCount = value; -} - -LlmClient* ChatSession::client() const { - if (auto* store = qobject_cast(parent())) - return store->llmClient(); - return nullptr; + m_pinned = pinned; } void ChatSession::ensureLoaded() { if (m_loadRequested) return; m_loadRequested = true; - if (auto* store = qobject_cast(parent())) - store->loadMessagesInto(this); + if (m_store) m_store->loadMessagesInto(this); } ChatMessageModel* ChatSession::messagesModel() { @@ -126,18 +118,12 @@ void ChatSession::markLoaded() { Q_EMIT loaded(); } -bool ChatSession::takeClearPending() { - const bool pending = m_clearPending; - m_clearPending = false; - return pending; -} - void ChatSession::adoptMessages(QList messages) { m_model->loadMessages(std::move(messages)); } void ChatSession::persist() { - if (auto* store = qobject_cast(parent())) store->persist(this); + if (m_store) m_store->persist(this); } ChatMessage* ChatSession::appendNewest( @@ -154,32 +140,27 @@ void ChatSession::clearMessages() { } void ChatSession::startGeneration(ChatMessage* target) { - auto* generation = target->activeGeneration(); - auto* clientObject = client(); - if (generation && clientObject) { - if (isLoaded()) { - clientObject->startGeneration(this, generation); - } else { - connect( - this, - &ChatSession::loaded, - clientObject, - [this, generation, clientObject]() { - clientObject->startGeneration(this, generation); - }); - } + ChatGeneration* generation = target->activeGeneration(); + LlmClient* clientObject = client(); + if (!generation || !clientObject) return; + + if (isLoaded()) { + clientObject->startGeneration(this, generation); + return; } + + connect( + this, + &ChatSession::loaded, + clientObject, + [this, generation, clientObject]() { + clientObject->startGeneration(this, generation); + }); 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); +LlmClient* ChatSession::client() const { + return m_store ? m_store->client() : nullptr; } void ChatSession::setAttachmentError(const QString& value) { @@ -189,125 +170,34 @@ void ChatSession::setAttachmentError(const QString& value) { } QVariantList ChatSession::addAttachments(const QStringList& paths) { - ensureWorkspace(); + if (!m_workspace) return {}; 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); + QString error; + for (Attachment* attachment : m_workspace->importFiles(paths, error)) result.append(QVariant::fromValue(attachment)); - } + if (!error.isEmpty()) setAttachmentError(error); 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; + return clipboard::hasAttachment(); } 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()); - } - } + QString error; + const QStringList paths = clipboard::attachmentPaths(error); if (paths.isEmpty()) { - if (data->hasImage() || data->hasUrls()) - setAttachmentError(QStringLiteral( - "Nothing usable on the clipboard (expected an image " - "or copied files)")); + if (!error.isEmpty()) setAttachmentError(error); return {}; } + const QVariantList attachments = addAttachments(paths); - if (!tempImagePath.isEmpty()) QFile::remove(tempImagePath); - qInfo() << "ChatSession: pasteAttachments: result =" << attachments.size() - << "attachment(s)"; + + for (const QString& path : paths) + if (path.startsWith(QDir::tempPath())) QFile::remove(path); + return attachments; } @@ -324,55 +214,52 @@ void ChatSession::sendMessage( attachmentList.append(attachment); const QString trimmed = text.trimmed(); if (trimmed.isEmpty() && attachmentList.isEmpty()) return; - if (auto* clientObject = client()) { - if (clientObject->busy()) return; - } + if (client() && client()->busy()) return; + + if (!m_committed && m_store) m_store->commitDraft(this); const qint64 now = QDateTime::currentMSecsSinceEpoch(); - auto* userMessage = appendNewest(ChatMessage::Role::User, trimmed, now); + ChatMessage* userMessage = + appendNewest(ChatMessage::Role::User, trimmed, now); if (!attachmentList.isEmpty()) userMessage->setAttachments(attachmentList); - while (m_model->rowCount() > 200) + while (m_model->rowCount() > kMaxStoredMessages) m_model->removeMessage(m_model->at(m_model->rowCount() - 1)); + if (m_title.isEmpty()) { QString titleSource = trimmed; if (titleSource.isEmpty() && !attachmentList.isEmpty()) titleSource = attachmentList.first()->name(); - m_title = titleFrom(titleSource); + m_title = prompts::cleanTitle(titleSource); Q_EMIT titleChanged(); - qInfo() << "ChatSession:" << m_id << "new conversation," - << "fallback title" << m_title - << "- requesting generated title and icon"; - if (auto* clientObject = client()) { + persist(); + + if (LlmClient* clientObject = client()) { clientObject->requestTitle(this, titleSource); clientObject->requestIcon(this, titleSource); } } - auto* assistant = + + ChatMessage* assistant = appendNewest(ChatMessage::Role::Assistant, QString(), now); startGeneration(assistant); } void ChatSession::retry(ChatMessage* target) { if (!target || target->role() != ChatMessage::Role::Assistant) return; - if (auto* clientObject = client()) { - if (clientObject->busy()) return; - } + if (client() && client()->busy()) return; const int row = m_model->rowOf(target); if (row < 0) return; m_model->removeRange(0, row - 1); if (m_model->rowCount() < 2) return; - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - target->appendGeneration(now); + target->appendGeneration(QDateTime::currentMSecsSinceEpoch()); startGeneration(target); } void ChatSession::continueFrom(ChatMessage* message) { if (!message) return; - if (auto* clientObject = client()) { - if (clientObject->busy()) return; - } + if (client() && client()->busy()) return; const int row = m_model->rowOf(message); if (row < 0) return; @@ -384,26 +271,15 @@ void ChatSession::continueFrom(ChatMessage* message) { message->appendGeneration(now); startGeneration(message); } else { - auto* assistant = + ChatMessage* assistant = appendNewest(ChatMessage::Role::Assistant, QString(), now); startGeneration(assistant); } } void ChatSession::clear() { - if (auto* clientObject = client()) { - if (clientObject->busy() && clientObject->streamingSession() == this) { - clientObject->clearOnFinish(this); - clientObject->stop(); - return; - } - } - if (!isLoaded()) { - m_clearPending = true; - return; - } - m_model->clear(); - persist(); + if (!m_store) return; + m_store->requestClear(this); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index f96e70f..cab1f3d 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -1,7 +1,7 @@ #pragma once -#include "messagemodel.hpp" #include "message.hpp" +#include "messagemodel.hpp" #include #include @@ -12,30 +12,32 @@ namespace ZShell::llm { +class ChatStore; class LlmClient; +class Workspace; class ChatSession : public QObject { Q_OBJECT QML_ELEMENT - QML_UNCREATABLE("Chat sessions are managed by Chat.chats") + QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton") Q_PROPERTY(QString id READ id CONSTANT) Q_PROPERTY(QString title READ title WRITE setTitle NOTIFY titleChanged) - Q_PROPERTY(QString icon READ icon NOTIFY iconChanged) + Q_PROPERTY(QString icon READ icon WRITE setIcon NOTIFY iconChanged) Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) - Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged) + Q_PROPERTY(bool pinned READ pinned WRITE setPinned NOTIFY pinnedChanged) Q_PROPERTY(bool loaded READ isLoaded NOTIFY loaded) Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) + Q_PROPERTY(int usageTokens READ usageTokens NOTIFY usageTokensChanged) 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); + ChatSession(ChatStore* store, const QString& id, QObject* parent = nullptr); [[nodiscard]] QString id() const { return m_id; } [[nodiscard]] QString title() const { return m_title; } @@ -50,34 +52,33 @@ class ChatSession : public QObject { [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } [[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] int messageCount() const { return m_messageCount; } + [[nodiscard]] int usageTokens() const { return m_usageTokens; } [[nodiscard]] ChatMessageModel* messagesModel(); void ensureLoaded(); [[nodiscard]] bool isLoaded() const { return m_loaded; } - [[nodiscard]] ChatMessageModel* model() const { return m_model; } void markLoaded(); - [[nodiscard]] bool takeClearPending(); - - [[nodiscard]] LlmClient* client() const; - [[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(); + [[nodiscard]] ChatStore* store() const { return m_store; } + [[nodiscard]] LlmClient* client() const; + [[nodiscard]] Workspace* workspace() const { return m_workspace; } void setTitle(const QString& value); void setIcon(const QString& value); void setUpdatedAt(qint64 value); void setPinned(bool value); + void setUsageTokens(int value); - void setMeta( + void hydrate( const QString& title, + const QString& icon, qint64 createdAt, qint64 updatedAt, - int messageCount); + int messageCount, + bool pinned); + + [[nodiscard]] bool isCommitted() const { return m_committed; } + void setCommitted(bool value) { m_committed = value; } void adoptMessages(QList messages); void persist(); @@ -101,6 +102,7 @@ class ChatSession : public QObject { void pinnedChanged(); void messageCountChanged(); void loaded(); + void usageTokensChanged(); void attachmentErrorChanged(); private: @@ -109,20 +111,22 @@ class ChatSession : public QObject { ChatMessage::Role role, const QString& content, qint64 timestamp); void startGeneration(ChatMessage* target); void setCount(int value); + void setAttachmentError(const QString& value); + ChatStore* m_store; QString m_id; + bool m_committed = false; QString m_title; QString m_icon; qint64 m_createdAt = 0; qint64 m_updatedAt = 0; bool m_pinned = false; int m_messageCount = 0; + int m_usageTokens = 0; ChatMessageModel* m_model = nullptr; + Workspace* m_workspace = nullptr; bool m_loadRequested = false; bool m_loaded = false; - bool m_clearPending = false; - int m_lastTokenCount = 0; - QString m_workspace; QString m_attachmentError; }; diff --git a/Plugins/ZShell/Llm/shelltool.cpp b/Plugins/ZShell/Llm/shelltool.cpp index c18ceaa..e9ce098 100644 --- a/Plugins/ZShell/Llm/shelltool.cpp +++ b/Plugins/ZShell/Llm/shelltool.cpp @@ -1,8 +1,5 @@ #include "shelltool.hpp" -#include "llmclient.hpp" -#include "session.hpp" - #include #include #include @@ -13,24 +10,8 @@ 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(ToolRegistry* registry, QObject* parent) + : LlmTool(registry, parent) {} ShellTool::~ShellTool() { for (auto* job : m_jobs) { @@ -125,27 +106,29 @@ QStringList ShellTool::sandboxArguments(const QString& workspace) { return args; } +bool ShellTool::available(const ToolContext& context) const { + return registry()->sandboxEnabled() && !context.workspace.isEmpty(); +} + void ShellTool::execute( - const QString& toolCallId, - const QJsonObject& args, - int outputBudgetChars, - std::function done) { - Q_UNUSED(toolCallId); + const ToolContext& context, const QJsonObject& args, ToolDone done) { const QString command = args[QStringLiteral("command")].toString(); if (command.trimmed().isEmpty()) { - done(makeError(QStringLiteral("Missing required argument 'command'"))); + done({false, 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"))); + if (!registry()->sandboxEnabled()) { + done( + {false, + QStringLiteral( + "The file sandbox is disabled, so no commands can run")}); return; } - const QString workspace = client->activeWorkspace(); + const QString workspace = context.workspace; if (workspace.isEmpty()) { - done(makeError( - QStringLiteral("No sandbox available: no chat session is active"))); + done( + {false, + QStringLiteral("No sandbox available: no chat session is active")}); return; } QDir().mkpath(workspace); @@ -156,7 +139,7 @@ void ShellTool::execute( MaxTimeoutSeconds); auto* job = new Job; job->timeoutSeconds = timeoutSeconds; - job->outputBudgetChars = outputBudgetChars; + job->outputBudgetChars = context.outputBudgetChars; job->done = std::move(done); m_jobs.append(job); @@ -230,9 +213,8 @@ void ShellTool::finishJob(Job* job) { "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); + int budget = job->outputBudgetChars > 0 ? job->outputBudgetChars + : registry()->outputBudgetChars(1); if (output.size() > budget) output = output.left(budget) + QStringLiteral( "\n[output truncated: %1 characters " @@ -242,17 +224,12 @@ void ShellTool::finishJob(Job* job) { const QString result = note + QStringLiteral("exit code: %1\n").arg(exitCode) + (output.isEmpty() ? QStringLiteral("(no output)") : output); + const bool ok = !job->cancelled && !job->timedOut && exitCode == 0; - QJsonObject resultObject; - if (!job->cancelled && !job->timedOut && exitCode == 0) - resultObject = makeOutput(result); - else - resultObject = makeError(result); - - std::function done = std::move(job->done); + ToolDone done = std::move(job->done); if (process) process->deleteLater(); delete job; - if (done) done(resultObject); + if (done) done({ok, result}); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/shelltool.hpp b/Plugins/ZShell/Llm/shelltool.hpp index de1e205..c4f8f4a 100644 --- a/Plugins/ZShell/Llm/shelltool.hpp +++ b/Plugins/ZShell/Llm/shelltool.hpp @@ -11,23 +11,21 @@ class QTimer; namespace ZShell::llm { -class LlmClient; - class ShellTool : public LlmTool { Q_OBJECT public: - explicit ShellTool(LlmClient* client, QObject* parent = nullptr); + explicit ShellTool(ToolRegistry* registry, QObject* parent = nullptr); ~ShellTool() override; QString name() const override; QString description() const override; QJsonObject parameters() const override; + bool available(const ToolContext& context) const override; void execute( - const QString& toolCallId, + const ToolContext& context, const QJsonObject& args, - int outputBudgetChars, - std::function done) override; + ToolDone done) override; void cancel() override; static constexpr int DefaultTimeoutSeconds = 60; @@ -39,7 +37,7 @@ class ShellTool : public LlmTool { QPointer timer; int timeoutSeconds = DefaultTimeoutSeconds; int outputBudgetChars = 0; - std::function done; + ToolDone done; QString startError; bool timedOut = false; bool cancelled = false; @@ -50,7 +48,6 @@ class ShellTool : public LlmTool { void finishJob(Job* job); - QPointer m_client; QList m_jobs; }; diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp index 1bb8107..622ab00 100644 --- a/Plugins/ZShell/Llm/tool.cpp +++ b/Plugins/ZShell/Llm/tool.cpp @@ -2,20 +2,14 @@ namespace ZShell::llm { -LlmTool::LlmTool(QObject* parent) : QObject(parent) {} +LlmTool::LlmTool(ToolRegistry* registry, QObject* parent) + : QObject(parent), m_registry(registry) {} LlmTool::~LlmTool() = default; -void LlmTool::setContextSize(int value) { - if (m_contextSize == value) return; - m_contextSize = value; -} - -int LlmTool::inlineBudgetChars() const { - if (m_contextSize <= 0) return DefaultInlineChars; - const int tokens = - qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken); - return tokens * CharsPerToken; +bool LlmTool::available(const ToolContext& context) const { + Q_UNUSED(context); + return true; } void LlmTool::cancel() {} @@ -33,30 +27,22 @@ QJsonObject LlmTool::specification() const { ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {} -void ToolRegistry::setEnabled(bool value) { - if (m_enabled == value) return; - m_enabled = value; - Q_EMIT enabledChanged(); -} - void ToolRegistry::registerTool(LlmTool* tool) { if (!tool || m_tools.contains(tool)) return; tool->setParent(this); - tool->setContextSize(m_contextSize); m_tools.append(tool); } LlmTool* ToolRegistry::tool(const QString& name) const { - for (const auto* tool : m_tools) - if (tool->name() == name) return const_cast(tool); + for (auto* tool : m_tools) + if (tool->name() == name) return tool; return nullptr; } -QJsonArray ToolRegistry::specifications() const { - if (!m_enabled) return {}; +QJsonArray ToolRegistry::specifications(const ToolContext& context) const { QJsonArray specs; - for (const auto* tool : m_tools) - specs.append(tool->specification()); + for (auto* tool : m_tools) + if (tool->available(context)) specs.append(tool->specification()); return specs; } @@ -68,8 +54,25 @@ void ToolRegistry::cancelAll() { void ToolRegistry::setContextSize(int value) { if (m_contextSize == value) return; m_contextSize = value; - for (auto* tool : m_tools) - tool->setContextSize(value); +} + +void ToolRegistry::setEnabled(bool value) { + if (m_enabled == value) return; + m_enabled = value; +} + +void ToolRegistry::setSandboxEnabled(bool value) { + if (m_sandbox == value) return; + m_sandbox = value; +} + +int ToolRegistry::outputBudgetChars(int callCount) const { + if (m_contextSize <= 0) return DefaultInlineChars; + const int tokens = qBound( + MinBudgetTokens, + m_contextSize / qMax(1, callCount), + DefaultInlineChars / CharsPerToken); + return tokens * CharsPerToken; } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp index 3ce0d86..53a1d39 100644 --- a/Plugins/ZShell/Llm/tool.hpp +++ b/Plugins/ZShell/Llm/tool.hpp @@ -10,61 +10,76 @@ namespace ZShell::llm { +struct ToolResult { + bool ok = false; + QString text; +}; + +struct ToolContext { + QString toolCallId; + QString workspace; + int outputBudgetChars = 0; +}; + +using ToolDone = std::function; + +class ToolRegistry; + class LlmTool : public QObject { Q_OBJECT public: - explicit LlmTool(QObject* parent = nullptr); + explicit LlmTool(ToolRegistry* registry, QObject* parent = nullptr); ~LlmTool() override; [[nodiscard]] virtual QString name() const = 0; [[nodiscard]] virtual QString description() const = 0; [[nodiscard]] virtual QJsonObject parameters() const = 0; + [[nodiscard]] virtual bool available(const ToolContext& context) const; + virtual void execute( - const QString& toolCallId, - const QJsonObject& args, - int outputBudgetChars, - std::function done) = 0; + const ToolContext& context, const QJsonObject& args, ToolDone done) = 0; + virtual void cancel(); [[nodiscard]] QJsonObject specification() const; - [[nodiscard]] int contextSize() const { return m_contextSize; } - void setContextSize(int value); + [[nodiscard]] ToolRegistry* registry() const { return m_registry; } - [[nodiscard]] int inlineBudgetChars() const; - - static constexpr int CharsPerToken = 4; - static constexpr int DefaultInlineChars = 64 * 1024; - - protected: - int m_contextSize = 0; + private: + ToolRegistry* m_registry; }; class ToolRegistry : public QObject { Q_OBJECT - Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged) - public: explicit ToolRegistry(QObject* parent = nullptr); - [[nodiscard]] bool enabled() const { return m_enabled; } - void setEnabled(bool value); - void registerTool(LlmTool* tool); [[nodiscard]] LlmTool* tool(const QString& name) const; - [[nodiscard]] QJsonArray specifications() const; + [[nodiscard]] QList tools() const { return m_tools; } + [[nodiscard]] QJsonArray specifications(const ToolContext& context) const; void cancelAll(); + [[nodiscard]] bool enabled() const { return m_enabled; } + void setEnabled(bool value); + [[nodiscard]] bool sandboxEnabled() const { return m_sandbox; } + void setSandboxEnabled(bool value); + + [[nodiscard]] int contextSize() const { return m_contextSize; } void setContextSize(int value); - Q_SIGNALS: - void enabledChanged(); + [[nodiscard]] int outputBudgetChars(int callCount) const; + + static constexpr int CharsPerToken = 4; + static constexpr int DefaultInlineChars = 64 * 1024; + static constexpr int MinBudgetTokens = 512; private: bool m_enabled = true; + bool m_sandbox = true; int m_contextSize = 0; QList m_tools; }; diff --git a/Plugins/ZShell/Llm/turn.cpp b/Plugins/ZShell/Llm/turn.cpp new file mode 100644 index 0000000..fe39d74 --- /dev/null +++ b/Plugins/ZShell/Llm/turn.cpp @@ -0,0 +1,391 @@ +#include "turn.hpp" + +#include "attachment.hpp" +#include "message.hpp" +#include "messagemodel.hpp" +#include "prompts.hpp" +#include "session.hpp" +#include "workspace.hpp" + +#include +#include + +namespace ZShell::llm { + +namespace { + +constexpr int kMinContextTokens = 512; +constexpr int kToolResultStubChars = 512; +constexpr double kPromptEstimateDiscount = 10.0 / 12.0; + +qsizetype jsonValueChars(const QJsonValue& value) { + switch (value.type()) { + case QJsonValue::String: + return value.toString().size(); + case QJsonValue::Array: { + qsizetype total = 0; + for (const QJsonValue& item : value.toArray()) + total += jsonValueChars(item); + return total; + } + case QJsonValue::Object: { + qsizetype total = 0; + const QJsonObject obj = value.toObject(); + for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) + total += jsonValueChars(it.value()); + return total; + } + default: + return 2; + } +} + +int estimateMessageTokens(const QJsonObject& message) { + return int(jsonValueChars(message) / ToolRegistry::CharsPerToken) + 4; +} + +int estimateArrayTokens(const QJsonArray& messages) { + int total = 0; + for (const QJsonValue& value : messages) + total += estimateMessageTokens(value.toObject()); + return total; +} + +int completionReserve(int contextSize) { + return qBound(2048, contextSize / 8, 8192); +} + +void shrinkUnitToBudget(QList& unit, int budgetTokens) { + static const QStringList kTextKeys = { + QStringLiteral("content"), QStringLiteral("reasoning_content")}; + for (int pass = 0; pass < 8; ++pass) { + int tokens = 0; + for (const QJsonObject& message : unit) + tokens += estimateMessageTokens(message); + if (tokens <= budgetTokens) return; + qsizetype bestMessage = -1; + QString bestKey; + qsizetype bestLength = 0; + for (qsizetype i = 0; i < unit.size(); ++i) { + const QJsonObject message = unit.at(i); + for (const QString& key : kTextKeys) { + const QJsonValue value = message.value(key); + if (value.isString() && value.toString().size() > bestLength) { + bestMessage = i; + bestKey = key; + bestLength = value.toString().size(); + } + } + } + if (bestMessage < 0) return; + QString text = unit.at(bestMessage).value(bestKey).toString(); + text.truncate(qMax(32, text.size() * 3 / 4)); + const QString marker = QStringLiteral("\n[…]"); + if (!text.endsWith(marker)) text += marker; + unit[bestMessage][bestKey] = text; + } +} + +} // namespace + +Turn::Turn( + ChatSession* session, + ChatGeneration* generation, + const ToolRegistry& registry) + : m_session(session), m_generation(generation), m_registry(registry) {} + +int Turn::targetRow() const { + if (!m_session) return -1; + const ChatMessage* message = m_generation ? m_generation->message() + : nullptr; + return message ? m_session->messagesModel()->rowOf(message) : -1; +} + +int Turn::transcriptTokens() const { + return estimateArrayTokens(m_transcript); +} + +int Turn::promptBudgetTokens() const { + const int contextSize = m_registry.contextSize(); + if (contextSize <= 0) return 0; + return int( + (contextSize - completionReserve(contextSize)) * + kPromptEstimateDiscount); +} + +int Turn::batchSize() const { + int size = 0; + for (const Call& call : m_calls) + if (call.seen) ++size; + return size; +} + +int Turn::outputBudgetChars() const { + const int batch = qMax(1, batchSize()); + const int promptLimit = promptBudgetTokens(); + if (promptLimit <= 0) return m_registry.outputBudgetChars(batch); + const int available = promptLimit - transcriptTokens(); + const int tokens = qMax(available / batch, ToolRegistry::MinBudgetTokens); + return tokens * ToolRegistry::CharsPerToken; +} + +void Turn::resetRound() { + roundDone = false; + streamDone = false; + toolPhase = false; + pendingCalls = 0; + finishReason.clear(); + m_calls.clear(); + m_results.clear(); +} + +void Turn::appendToTranscript(const QJsonObject& message) { + m_transcript.append(message); +} + +QJsonObject Turn::roundAssistantMessage() { + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + + const QString content = m_generation->content(); + const QString reasoning = m_generation->reasoning(); + if (content.size() > m_contentMark) { + assistant[QStringLiteral("content")] = content.mid(m_contentMark); + m_contentMark = content.size(); + } + if (reasoning.size() > m_reasoningMark) { + assistant[QStringLiteral("reasoning_content")] = + reasoning.mid(m_reasoningMark); + m_reasoningMark = reasoning.size(); + } + + QJsonArray calls; + for (const Call& call : m_calls) { + if (!call.seen) continue; + QJsonObject function; + function[QStringLiteral("name")] = call.name; + function[QStringLiteral("arguments")] = call.arguments; + QJsonObject entry; + entry[QStringLiteral("id")] = call.id; + entry[QStringLiteral("type")] = QStringLiteral("function"); + entry[QStringLiteral("function")] = function; + calls.append(entry); + } + assistant[QStringLiteral("tool_calls")] = calls; + return assistant; +} + +QJsonArray Turn::toolMessages() const { + QJsonArray messages; + for (int i = 0; i < m_calls.size(); ++i) { + const Call& call = m_calls.at(i); + if (!call.seen) continue; + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = call.id; + toolMessage[QStringLiteral("content")] = m_results.at(i).text; + messages.append(toolMessage); + } + return messages; +} + +bool Turn::roundHasCalls() const { + for (const Call& call : m_calls) + if (call.seen) return true; + return false; +} + +void Turn::appendSandboxPreamble(QJsonArray& messages) const { + if (!m_registry.sandboxEnabled()) return; + const QString workspace = m_session->workspace()->path(); + if (workspace.isEmpty()) return; + + const auto* model = m_session->messagesModel(); + QStringList files; + QSet 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")] = + prompts::sandboxPreamble(workspace, files); + messages.prepend(preamble); +} + +QJsonArray Turn::contextMessages() const { + const auto* model = m_session->messagesModel(); + const int stopBeforeRow = targetRow(); + + QList> units; + for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { + const auto* message = model->at(row); + if (message->role() == ChatMessage::Role::User) { + if (message->content().isEmpty() && + message->attachments().isEmpty()) + continue; + + QList unit; + + QJsonObject user; + user[QStringLiteral("role")] = QStringLiteral("user"); + const QString text = message->content(); + const QList 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.append(user); + units.append(unit); + continue; + } + + const ChatGeneration* generation = message->activeGeneration(); + if (!generation) continue; + const QList calls = generation->toolCalls(); + const bool hasAnyContent = !generation->content().isEmpty() || + !generation->reasoning().isEmpty() || + !calls.isEmpty(); + if (!hasAnyContent) continue; + + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + if (!calls.isEmpty() || !generation->content().isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + if (!generation->reasoning().isEmpty()) + assistant[QStringLiteral("reasoning_content")] = + generation->reasoning(); + if (!calls.isEmpty()) { + QJsonArray callArray; + for (const auto* call : calls) { + QJsonObject function; + function[QStringLiteral("name")] = call->name(); + function[QStringLiteral("arguments")] = call->arguments(); + QJsonObject entry; + entry[QStringLiteral("id")] = call->toolCallId(); + entry[QStringLiteral("type")] = QStringLiteral("function"); + entry[QStringLiteral("function")] = function; + callArray.append(entry); + } + assistant[QStringLiteral("tool_calls")] = callArray; + } + + QList unit; + unit.append(assistant); + for (const auto* call : calls) { + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = call->toolCallId(); + toolMessage[QStringLiteral("content")] = call->result(); + unit.append(toolMessage); + } + units.append(unit); + } + + if (m_registry.contextSize() > 0) { + const int reserve = transcriptTokens(); + int budget = m_registry.contextSize() - + completionReserve(m_registry.contextSize()) - reserve; + budget = qMax(budget, kMinContextTokens); + + int total = 0; + for (const QList& unit : units) + for (const QJsonObject& message : unit) + total += estimateMessageTokens(message); + while (units.size() > 1 && total > budget) { + for (const QJsonObject& message : units.first()) + total -= estimateMessageTokens(message); + units.removeFirst(); + } + while (units.size() > 1 && + units.first().first()[QStringLiteral("role")].toString() == + QLatin1String("assistant")) { + for (const QJsonObject& message : units.first()) + total -= estimateMessageTokens(message); + units.removeFirst(); + } + if (units.size() == 1) { + QList single = units.first(); + shrinkUnitToBudget(single, budget); + units[0] = single; + } + } + + QJsonArray messages; + for (const QList& unit : units) + for (const QJsonObject& message : unit) + messages.append(message); + + for (const QJsonValue& value : m_transcript) + messages.append(value); + + if (m_registry.contextSize() > 0) { + const int promptLimit = promptBudgetTokens(); + while (estimateArrayTokens(messages) > promptLimit) { + bool stubbed = false; + for (qsizetype i = 0; i < messages.size(); ++i) { + QJsonObject message = messages.at(i).toObject(); + if (message.value("role").toString() != QLatin1String("tool")) + continue; + const QString content = message.value("content").toString(); + if (content.size() <= kToolResultStubChars) continue; + QString stub = content.left(kToolResultStubChars); + stub += QStringLiteral( + "\n[truncated to fit the context window; was " + "%1 characters]") + .arg(content.size()); + message["content"] = stub; + messages[i] = message; + stubbed = true; + break; + } + if (!stubbed) break; + } + } + + appendSandboxPreamble(messages); + return messages; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/turn.hpp b/Plugins/ZShell/Llm/turn.hpp new file mode 100644 index 0000000..7669a57 --- /dev/null +++ b/Plugins/ZShell/Llm/turn.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "generation.hpp" +#include "segment.hpp" +#include "tool.hpp" + +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatSession; + +class Turn { + public: + Turn( + ChatSession* session, + ChatGeneration* generation, + const ToolRegistry& registry); + + [[nodiscard]] ChatSession* session() const { return m_session; } + [[nodiscard]] ChatGeneration* generation() const { return m_generation; } + [[nodiscard]] const ToolRegistry& registry() const { return m_registry; } + + [[nodiscard]] QJsonArray contextMessages() const; + [[nodiscard]] int transcriptTokens() const; + [[nodiscard]] int promptBudgetTokens() const; + + [[nodiscard]] int targetRow() const; + + void resetRound(); + + [[nodiscard]] QJsonObject roundAssistantMessage(); + [[nodiscard]] QJsonArray toolMessages() const; + [[nodiscard]] bool roundHasCalls() const; + + struct Call { + QString id; + QString name; + QString arguments; + QPointer segment; + bool seen = false; + }; + + QList& calls() { return m_calls; } + [[nodiscard]] const QList& calls() const { return m_calls; } + + QList& results() { return m_results; } + [[nodiscard]] const QList& results() const { return m_results; } + + void appendToTranscript(const QJsonObject& message); + [[nodiscard]] const QJsonArray& transcript() const { return m_transcript; } + + [[nodiscard]] int batchSize() const; + [[nodiscard]] int outputBudgetChars() const; + + int round = 0; + int pendingCalls = 0; + bool roundDone = false; + bool streamDone = false; + bool toolPhase = false; + bool approvalPending = false; + bool contextRetryUsed = false; + QString finishReason; + + private: + void appendSandboxPreamble(QJsonArray& messages) const; + + ChatSession* m_session; + ChatGeneration* m_generation; + const ToolRegistry& m_registry; + QJsonArray m_transcript; + QList m_calls; + QList m_results; + qsizetype m_contentMark = 0; + qsizetype m_reasoningMark = 0; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp index 1d5e76f..71c859f 100644 --- a/Plugins/ZShell/Llm/webfetchtool.cpp +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -10,30 +10,16 @@ namespace ZShell::llm { -const QString WebFetchTool::StoragePath = - QStringLiteral("/tmp/zshell-llm/webfetch"); - namespace { const char* kUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; -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 -WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {} +WebFetchTool::WebFetchTool(ToolRegistry* registry, QObject* parent) + : LlmTool(registry, parent) {} WebFetchTool::~WebFetchTool() { for (auto* job : m_jobs) { @@ -52,9 +38,9 @@ QString WebFetchTool::description() const { "Fetch content from an HTTP or HTTPS URL and return it as plain " "text or raw HTML. HTML pages are reduced to their visible text " "by default. Only a limited amount of content is returned inline; " - "the full output of every call is saved under " - "/tmp/zshell-llm/webfetch/, where it can be paged through with " - "the readfile tool. This tool is read-only."); + "the full output of every call is saved in the sandbox directory " + "of the conversation, where it can be paged through with the " + "readfile tool. This tool is read-only."); } QJsonObject WebFetchTool::parameters() const { @@ -94,7 +80,7 @@ QJsonObject WebFetchTool::parameters() const { return schema; } -void WebFetchTool::completeJob(Job* job, QJsonObject result) { +void WebFetchTool::completeJob(Job* job, ToolResult result) { QMetaObject::invokeMethod( this, [this, job, result = std::move(result)]() mutable { @@ -110,19 +96,23 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) { } void WebFetchTool::execute( - const QString& toolCallId, - const QJsonObject& args, - int outputBudgetChars, - std::function done) { + const ToolContext& context, const QJsonObject& args, ToolDone done) { auto* job = new Job; job->done = std::move(done); - job->toolCallId = toolCallId; - job->budgetChars = outputBudgetChars; + job->toolCallId = context.toolCallId; + job->budgetChars = context.outputBudgetChars > 0 + ? context.outputBudgetChars + : registry()->outputBudgetChars(1); + job->storagePath = + context.workspace.isEmpty() + ? QDir(QDir::tempPath()) + .filePath(QStringLiteral("zshell-llm/webfetch")) + : context.workspace + QStringLiteral("/webfetch"); m_jobs.append(job); auto fail = [this, job](const QString& message) { job->timer->stop(); - completeJob(job, makeError(message)); + completeJob(job, {false, message}); }; const QString urlText = args[QStringLiteral("url")].toString().trimmed(); @@ -194,23 +184,27 @@ void WebFetchTool::execute( if (job->tooLarge) { completeJob( job, - makeError(QStringLiteral( - "Response too large (exceeds the 5 MB " - "limit"))); + ToolResult{ + false, + QStringLiteral( + "Response too large (exceeds the 5 MB " + "limit")}); return; } if (error != QNetworkReply::NoError) { completeJob( job, - makeError( - QStringLiteral("Request failed: %1").arg(errorString))); + ToolResult{ + false, + QStringLiteral("Request failed: %1").arg(errorString)}); return; } if (status < 200 || status >= 300) { completeJob( job, - makeError( - QStringLiteral("Server returned status %1").arg(status))); + ToolResult{ + false, + QStringLiteral("Server returned status %1").arg(status)}); return; } @@ -220,9 +214,10 @@ void WebFetchTool::execute( if (mime.startsWith(QLatin1String("image/"))) { completeJob( job, - makeError( + ToolResult{ + false, QStringLiteral("Unsupported fetched image content type: %1") - .arg(mime))); + .arg(mime)}); return; } const bool textual = @@ -236,9 +231,10 @@ void WebFetchTool::execute( if (!textual) { completeJob( job, - makeError( + ToolResult{ + false, QStringLiteral("Unsupported fetched file content type: %1") - .arg(mime))); + .arg(mime)}); return; } @@ -249,9 +245,8 @@ void WebFetchTool::execute( const QString savedPath = saveToFile(*job, content, mime); + const int budget = job->budgetChars; QString output = content; - int budget = inlineBudgetChars(); - if (job->budgetChars > 0) budget = qMin(budget, job->budgetChars); if (content.size() > budget) { output = content.left(budget); const QString note = @@ -269,13 +264,13 @@ void WebFetchTool::execute( ". The remaining content is not " "available."); } - completeJob(job, makeOutput(output)); + completeJob(job, ToolResult{true, output}); }); } QString WebFetchTool::saveToFile( const Job& job, const QString& content, const QString& mime) const { - QDir dir(StoragePath); + QDir dir(job.storagePath); if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) return QString(); QString fileName; diff --git a/Plugins/ZShell/Llm/webfetchtool.hpp b/Plugins/ZShell/Llm/webfetchtool.hpp index c992c77..0a47ac3 100644 --- a/Plugins/ZShell/Llm/webfetchtool.hpp +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -20,19 +20,17 @@ class WebFetchTool : public LlmTool { static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int DefaultTimeoutSeconds = 30; static constexpr int MaxTimeoutSeconds = 120; - static const QString StoragePath; - explicit WebFetchTool(QObject* parent = nullptr); + explicit WebFetchTool(ToolRegistry* registry, QObject* parent = nullptr); ~WebFetchTool() override; QString name() const override; QString description() const override; QJsonObject parameters() const override; void execute( - const QString& toolCallId, + const ToolContext& context, const QJsonObject& args, - int outputBudgetChars, - std::function done) override; + ToolDone done) override; void cancel() override; static QString extractTextFromHtml(const QString& html); @@ -46,11 +44,12 @@ class WebFetchTool : public LlmTool { bool tooLarge = false; QString format; QString toolCallId; + QString storagePath; int budgetChars = 0; - std::function done; + ToolDone done; }; - void completeJob(Job* job, QJsonObject result); + void completeJob(Job* job, ToolResult result); QString saveToFile( const Job& job, const QString& content, const QString& mime) const; diff --git a/Plugins/ZShell/Llm/workspace.cpp b/Plugins/ZShell/Llm/workspace.cpp new file mode 100644 index 0000000..6dc22df --- /dev/null +++ b/Plugins/ZShell/Llm/workspace.cpp @@ -0,0 +1,89 @@ +#include "workspace.hpp" + +#include "session.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +Workspace::Workspace(ChatSession* session, QObject* parent) + : QObject(parent), m_session(session) { + m_path = pathFor(session->id()); +} + +QString Workspace::pathFor(const QString& sessionId) { + const QString state = + QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation); + return state + QStringLiteral("/zshell/sandboxes/") + sessionId; +} + +bool Workspace::ensure() { + return QDir().mkpath(m_path); +} + +QList Workspace::importFiles( + const QStringList& paths, QString& error) { + error.clear(); + if (!ensure()) { + error = QStringLiteral("Could not create the sandbox directory"); + return {}; + } + + QMimeDatabase mimeDatabase; + QList result; + for (const QString& rawPath : paths) { + const QFileInfo info(rawPath); + if (!info.exists() || !info.isFile()) { + qWarning() << "Workspace: skipping" << rawPath + << ": file not found or not a file"; + error = QStringLiteral("File not found: %1").arg(info.fileName()); + continue; + } + + const QString mimeType = mimeDatabase.mimeTypeForFile(info).name(); + const bool isImage = + mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive); + const qint64 cap = isImage ? MaxImageBytes : MaxFileBytes; + if (info.size() > cap) { + qWarning() << "Workspace: skipping" << rawPath << ": too large (" + << info.size() << "bytes, cap" << cap << ")"; + error = QStringLiteral("File too large: %1").arg(info.fileName()); + continue; + } + + QString destination = m_path + QLatin1Char('/') + info.fileName(); + int suffix = 1; + while (QFile::exists(destination)) { + const QString extension = info.suffix().isEmpty() + ? QString() + : QLatin1Char('.') + info.suffix(); + destination = m_path + QLatin1Char('/') + info.completeBaseName() + + QStringLiteral("_%1%2").arg(suffix).arg(extension); + ++suffix; + } + + if (!QFile::copy(info.absoluteFilePath(), destination)) { + qWarning() << "Workspace: skipping" << rawPath << ": copy to" + << destination << "failed"; + error = + QStringLiteral("Could not copy file: %1").arg(info.fileName()); + continue; + } + + result.append(new Attachment( + info.fileName(), + destination, + isImage ? Attachment::Kind::Image : Attachment::Kind::File, + mimeType, + info.size(), + m_session)); + } + return result; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/workspace.hpp b/Plugins/ZShell/Llm/workspace.hpp new file mode 100644 index 0000000..e5739e9 --- /dev/null +++ b/Plugins/ZShell/Llm/workspace.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "attachment.hpp" + +#include +#include +#include + +namespace ZShell::llm { + +class ChatSession; + +class Workspace : public QObject { + Q_OBJECT + + public: + explicit Workspace(ChatSession* session, QObject* parent = nullptr); + + [[nodiscard]] QString path() const { return m_path; } + [[nodiscard]] static QString pathFor(const QString& sessionId); + + [[nodiscard]] bool ensure(); + + [[nodiscard]] QList importFiles( + const QStringList& paths, QString& error); + + static constexpr qint64 MaxImageBytes = 20 * 1024 * 1024; + static constexpr qint64 MaxFileBytes = 100 * 1024 * 1024; + + private: + ChatSession* m_session; + QString m_path; +}; + +} // namespace ZShell::llm