chore: restructure llm plugin to be more coherent & fix various bugs in llm ui
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 36s
Rust / clippy (pull_request) Successful in 54s

This commit is contained in:
2026-10-18 12:58:38 +02:00
parent fc81be922f
commit 1fe8a3a959
54 changed files with 3022 additions and 2559 deletions
@@ -171,7 +171,7 @@ Item {
Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter
elide: Text.ElideRight elide: Text.ElideRight
font: Tokens.font.title.medium 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 anchors.margins: Tokens.padding.medium
bg.border.color: Colors.palette.m3outlineVariant bg.border.color: Colors.palette.m3outlineVariant
bg.color: Colors.tPalette.m3surfaceContainerLowest bg.color: Colors.tPalette.m3surfaceContainerLowest
fastGlow: Chat.busy fastGlow: LlmClient.busy
glow.opacity: input.activeFocus && (!Chat.busy || ChatState.isWindow) glow.opacity: input.activeFocus && (!LlmClient.busy || ChatState.isWindow)
font: Tokens.font.body.small font: Tokens.font.body.small
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
focus: true focus: true
placeholderText: qsTr("Send a message") placeholderText: qsTr("Send a message")
sendIcon.font: Tokens.font.icon.medium 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.padding: Tokens.padding.extraSmall
sendIcon.opacity: text || Chat.busy ? 1.0 : 0.5 sendIcon.opacity: text || LlmClient.busy ? 1.0 : 0.5
sendIcon.enabled: text || Chat.busy sendIcon.enabled: text || LlmClient.busy
Component.onCompleted: { Component.onCompleted: {
root.focusInput(); root.focusInput();
@@ -572,8 +572,8 @@ Item {
} }
} }
onButtonPressed: { onButtonPressed: {
if (Chat.busy) if (LlmClient.busy)
Chat.stop(); LlmClient.stop();
else else
root.send(text); root.send(text);
} }
@@ -161,10 +161,6 @@ CustomClippingRect {
CustomRect { CustomRect {
id: fadeMask id: fadeMask
// anchors.top: titleText.top
// anchors.bottom: titleText.bottom
// anchors.left: titleText.left
// anchors.right: parent.right
anchors.fill: titleWrapper anchors.fill: titleWrapper
layer.enabled: true layer.enabled: true
@@ -259,7 +255,7 @@ CustomClippingRect {
font: Tokens.font.body.small font: Tokens.font.body.small
maximumLineCount: 1 maximumLineCount: 1
opacity: !root.expanded && !root.slim ? 1 : 0 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 { Behavior on y {
Anim {} Anim {}
@@ -284,7 +280,7 @@ CustomClippingRect {
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
maximumLineCount: 5 maximumLineCount: 5
opacity: root.expanded ? 1 : 0 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 wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on y { Behavior on y {
+17 -152
View File
@@ -5,6 +5,7 @@ import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import ZShell.Llm import ZShell.Llm
import ZShell.Config import ZShell.Config
import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Services import qs.Services
import qs.Components import qs.Components
@@ -21,8 +22,13 @@ Item {
signal loadChatRequest(content: ChatSession, index: int) signal loadChatRequest(content: ChatSession, index: int)
signal newChatRequest signal newChatRequest
onSlimChanged: {
if (slim)
modelsContainer.open = false;
}
CustomText { CustomText {
id: header id: chatHeader
anchors.left: parent.left anchors.left: parent.left
anchors.margins: Tokens.padding.extraSmall anchors.margins: Tokens.padding.extraSmall
@@ -41,7 +47,7 @@ Item {
anchors.bottom: modelsContainer.top anchors.bottom: modelsContainer.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.top: header.bottom anchors.top: chatHeader.bottom
anchors.topMargin: Tokens.spacing.medium anchors.topMargin: Tokens.spacing.medium
clip: true clip: true
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
@@ -98,156 +104,24 @@ Item {
} }
} }
CustomClippingRect { ModelsContainer {
id: modelsContainer id: modelsContainer
property bool expanded: false
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.left: parent.left anchors.left: parent.left
anchors.right: fabRoot.left anchors.right: fabRoot.left
anchors.margins: Tokens.padding.small anchors.margins: Tokens.padding.small
anchors.rightMargin: Tokens.spacing.small anchors.rightMargin: Tokens.spacing.small
implicitHeight: expanded ? 400 : fabRoot.implicitHeight rootParent: root
radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2 fabRoot: fabRoot
function prettyModelName(): string { opacity: root.slim ? 0 : 1
if (modelsList.count < 1) visible: opacity > 0
return qsTr("No models found");
const pathList = Chat.model.split("\/"); Behavior on opacity {
const name = pathList[pathList.length - 1]; Anim {
type: Anim.DefaultEffects
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);
}
}
} }
} }
} }
@@ -262,19 +136,11 @@ Item {
font.pointSize: Math.round(18 * 1.2) font.pointSize: Math.round(18 * 1.2)
icon: root.slim ? "left_panel_open" : "add" icon: root.slim ? "left_panel_open" : "add"
isRound: ChatState.fabExpanded isRound: ChatState.fabExpanded
// opacity: root.slim ? 0 : 1
// visible: opacity > 0
//
// Behavior on opacity {
// Anim {
// type: Anim.DefaultEffects
// }
// }
label.transform: Rotation { label.transform: Rotation {
origin.y: fabRoot.label.height / 2 origin.y: fabRoot.label.height / 2
origin.x: fabRoot.label.width / 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 { Behavior on angle {
Anim {} Anim {}
@@ -287,7 +153,6 @@ Item {
return; return;
} }
modelsContainer.expanded = false;
ChatState.fabExpanded = !ChatState.fabExpanded; ChatState.fabExpanded = !ChatState.fabExpanded;
} }
@@ -17,15 +17,26 @@ Item {
anchors.margins: Tokens.padding.small anchors.margins: Tokens.padding.small
Component.onCompleted: { Component.onCompleted: {
if (ChatState.firstOpen) {
ChatState.openDraft();
stack.push(chatList);
stack.push(chatContent);
ChatState.firstOpen = false;
return;
}
if (ChatState.inChat) { if (ChatState.inChat) {
stack.push(chatList); stack.push(chatList);
stack.push(chatContent, { stack.push(chatContent);
"chatData": ChatState.chatSession
});
} else } else
stack.push(chatList); stack.push(chatList);
} }
function openChat(): void {
if (stack.depth < 2)
stack.push(chatContent);
}
StackView { StackView {
id: stack id: stack
anchors.fill: parent anchors.fill: parent
@@ -35,27 +46,23 @@ Item {
id: chatList id: chatList
ChatList { ChatList {
model: ScriptModel { model: LlmClient.chats
values: Chat.chats.values
}
onDeleteChatRequest: chat => { onDeleteChatRequest: chat => {
Chat.chats.remove(chat); const wasCurrent = chat === ChatState.chatSession;
LlmClient.chats.remove(chat);
if (wasCurrent)
ChatState.openDraft();
} }
onLoadChatRequest: (chat, index) => { onLoadChatRequest: (chat, index) => {
stack.push(chatContent, {
"chatData": chat
});
ChatState.inChat = true;
ChatState.chatSession = chat; ChatState.chatSession = chat;
openChat();
ChatState.inChat = true;
} }
onNewChatRequest: { onNewChatRequest: {
const data = Chat.chats.insert(); ChatState.openDraft();
stack.push(chatContent, { openChat();
"chatData": data
});
ChatState.inChat = true; ChatState.inChat = true;
ChatState.chatSession = data;
} }
} }
} }
@@ -64,9 +71,12 @@ Item {
id: chatContent id: chatContent
ChatContent { ChatContent {
chatData: ChatState.chatSession
onRequestClose: { onRequestClose: {
stack.pop(); stack.pop();
ChatState.inChat = false; ChatState.inChat = false;
ChatState.openDraft();
} }
} }
} }
@@ -8,9 +8,10 @@ import qs.Services
Singleton { Singleton {
id: root id: root
property ChatSession chatSession property ChatSession chatSession: LlmClient.chats.draft()
readonly property string title: chatSession ? chatSession.title : "Chat" readonly property string title: chatSession?.title || "Chat"
property int currentIdx: -1 property int currentIdx: -1
property bool firstOpen: true
property bool inChat: false property bool inChat: false
property bool fabExpanded: false property bool fabExpanded: false
property bool isWindow: false property bool isWindow: false
@@ -22,6 +23,11 @@ Singleton {
property ShellScreen screen property ShellScreen screen
property bool sidebarVisible: ShellState.forActive().sidebar property bool sidebarVisible: ShellState.forActive().sidebar
function openDraft(): void {
root.chatSession = LlmClient.chats.draft();
root.currentIdx = -1;
}
onSidebarVisibleChanged: if (!sidebarVisible) onSidebarVisibleChanged: if (!sidebarVisible)
fabExpanded = false fabExpanded = false
} }
@@ -9,12 +9,11 @@ import qs.Services
Item { Item {
id: root id: root
required property ChatGeneration current
required property TextEdit edit required property TextEdit edit
required property bool hovered required property bool hovered
required property bool isUser required property bool isUser
required property ChatMessage message required property ChatMessage message
required property LlmSegment segment required property bool running
implicitHeight: editButton.implicitHeight implicitHeight: editButton.implicitHeight
implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small
@@ -63,7 +62,7 @@ Item {
id: editTools id: editTools
anchors.right: parent.right anchors.right: parent.right
opacity: !root.current.streaming && root.hovered ? 1 : 0 opacity: !root.running && root.hovered ? 1 : 0
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
visible: opacity > 0 visible: opacity > 0
@@ -99,7 +98,7 @@ Item {
Anim {} Anim {}
} }
onClicked: Quickshell.clipboardText = root.current.content onClicked: Quickshell.clipboardText = root.message.content
} }
IconButton { IconButton {
@@ -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()
}
}
}
}
}
@@ -10,12 +10,13 @@ Item {
id: root id: root
required property bool isUser 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 bool hovered
required property Repeater repeater required property Repeater repeater
required property int index required property int index
required property ChatMessage message required property ChatMessage message
required property ChatGeneration current
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
@@ -34,7 +35,7 @@ Item {
radius: Tokens.rounding.medium radius: Tokens.rounding.medium
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitWidth: layoutFrozen ? frozenWidth : layoutWidth 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 anchors.right: root.isUser ? parent.right : undefined
layer.enabled: false layer.enabled: false
layer.smooth: true layer.smooth: true
@@ -74,7 +75,7 @@ Item {
TextEditBase { TextEditBase {
id: msgText id: msgText
property string cachedText: root.segment.text property string cachedText: root.text
property bool cancelled: false property bool cancelled: false
visible: root.isUser visible: root.isUser
@@ -88,7 +89,7 @@ Item {
font: Tokens.font.body.small font: Tokens.font.body.small
readOnly: true readOnly: true
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4) 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 textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
@@ -129,23 +130,22 @@ Item {
} }
} }
// Assistant content: parsed markdown blocks.
MarkdownBlocks { MarkdownBlocks {
id: blocks id: markdown
visible: !root.isUser visible: !root.isUser
anchors.topMargin: Tokens.padding.medium anchors.topMargin: Tokens.padding.medium
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
blocks: root.segment.markdown blocks: root.blocks
} }
} }
Actions { Actions {
id: actionsRow 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.left: parent.left
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
@@ -156,8 +156,7 @@ Item {
edit: msgText edit: msgText
hovered: root.hovered hovered: root.hovered
isUser: root.isUser isUser: root.isUser
segment: root.segment running: root.running
current: root.current
message: root.message message: root.message
Behavior on opacity { Behavior on opacity {
@@ -5,28 +5,28 @@ import qs.Components
import qs.Services import qs.Services
ColumnLayout { ColumnLayout {
id: root id: root
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
Item { Item {
Layout.fillHeight: true Layout.fillHeight: true
} }
MaterialIcon { MaterialIcon {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant color: Colors.tPalette.m3outlineVariant
font.pointSize: 36 fontStyle: Tokens.font.icon.extraLarge
text: "chat" text: "chat"
} }
CustomText { CustomText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("Send a message to get started") text: qsTr("Send a message to get started")
} }
Item { Item {
Layout.fillHeight: true Layout.fillHeight: true
} }
} }
@@ -108,7 +108,7 @@ Item {
wrapMode: CustomText.WordWrap wrapMode: CustomText.WordWrap
color: Colors.palette.m3onErrorContainer color: Colors.palette.m3onErrorContainer
text: root.current.errorMessage || Chat.serverError text: root.current.errorMessage || LlmClient.serverError
} }
} }
@@ -23,10 +23,10 @@ Column {
role: "type" role: "type"
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Code roleValue: MarkdownBlock.Type.Code
delegate: CodeBlockView { delegate: CodeBlockView {
required property var modelData required property MarkdownBlock modelData
language: modelData.language language: modelData.language
code: modelData.code code: modelData.code
@@ -37,10 +37,10 @@ Column {
} }
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Math roleValue: MarkdownBlock.Type.Math
delegate: MathBlockView { delegate: MathBlockView {
required property var modelData required property MarkdownBlock modelData
latex: modelData.latex latex: modelData.latex
anchors.right: parent.right anchors.right: parent.right
@@ -50,10 +50,10 @@ Column {
} }
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Heading roleValue: MarkdownBlock.Type.Heading
delegate: TextEditBase { delegate: TextEditBase {
required property var modelData required property MarkdownBlock modelData
color: Colors.palette.m3onSurface color: Colors.palette.m3onSurface
anchors.right: parent.right anchors.right: parent.right
@@ -74,10 +74,10 @@ Column {
} }
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Text roleValue: MarkdownBlock.Type.Text
delegate: BubbleEdit { delegate: BubbleEdit {
required property var modelData required property MarkdownBlock modelData
anchors.right: parent.right anchors.right: parent.right
anchors.left: parent.left anchors.left: parent.left
@@ -19,7 +19,16 @@ MouseArea {
required property ChatListView chatList required property ChatListView chatList
readonly property bool isUser: modelData.role === ChatMessage.Role.User 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 ChatGeneration current: modelData.activeGeneration
property bool reasoningExpanded: false property bool reasoningExpanded: false
@@ -54,7 +63,9 @@ MouseArea {
blocks.push({ blocks.push({
kind: "content", kind: "content",
id: i, id: i,
segments: [seg] text: seg.text,
markdown: seg.markdown,
running: seg.running
}); });
} else { } else {
const last = blocks.length ? blocks[blocks.length - 1] : null; const last = blocks.length ? blocks[blocks.length - 1] : null;
@@ -330,11 +341,12 @@ MouseArea {
required property var modelData required property var modelData
isUser: root.isUser isUser: root.isUser
segment: modelData.segments[0] text: modelData.text
blocks: modelData.markdown
running: modelData.running
width: root.width width: root.width
repeater: segmentRep repeater: segmentRep
message: root.modelData message: root.modelData
current: root.current
hovered: root.containsMouse hovered: root.containsMouse
onEdit: text => { onEdit: text => {
@@ -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;
}
}
}
}
}
}
}
}
@@ -18,9 +18,9 @@ Item {
required property ChatGeneration current required property ChatGeneration current
readonly property bool isActive: index === blocks.length - 1 readonly property bool isActive: index === blocks.length - 1
property bool expanded: false property bool expanded: false
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] readonly property var lastSegment: root.segments[root.segments.length - 1]
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).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: { implicitHeight: {
const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin; const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin;
@@ -112,396 +112,17 @@ Item {
} }
} }
Item { ApprovalPrompt {
id: approvalPrompt id: approvalPrompt
anchors.top: layout.bottom anchors.top: layout.bottom
anchors.topMargin: Tokens.spacing.small anchors.topMargin: Tokens.spacing.small
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2
visible: root.pendingApproval visible: root.pendingApproval
CustomRect { rootParent: root.rootParent
id: approvalBg current: root.current
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()
}
}
}
}
} }
CustomClippingRect { CustomClippingRect {
@@ -533,7 +154,7 @@ Item {
delegate: Item { delegate: Item {
id: segment id: segment
required property LlmSegment modelData required property var modelData
required property int index required property int index
implicitWidth: ListView.view.width implicitWidth: ListView.view.width
@@ -560,7 +181,7 @@ Item {
CustomText { CustomText {
id: content id: content
function toolTarget(seg: LlmSegment): string { function toolTarget(seg: var): string {
var a = null; var a = null;
try { try {
a = JSON.parse(seg.arguments); a = JSON.parse(seg.arguments);
@@ -573,29 +194,29 @@ Item {
return String(seg.arguments); return String(seg.arguments);
} }
function prettyTool(seg: LlmSegment): string { function prettyTool(seg: var): string {
if (seg.name === "webfetch") { if (seg.name === "webfetch") {
switch (seg.status) { switch (seg.status) {
case LlmSegment.Status.Running: case LlmToolCall.Status.Running:
return qsTr("Fetching"); return qsTr("Fetching");
case LlmSegment.Status.Pending: case LlmToolCall.Status.Pending:
return qsTr("Pending fetch"); return qsTr("Pending fetch");
case LlmSegment.Status.Success: case LlmToolCall.Status.Success:
return qsTr("Fetched"); return qsTr("Fetched");
case LlmSegment.Status.Error: case LlmToolCall.Status.Error:
return qsTr("Failed to fetch"); return qsTr("Failed to fetch");
default: default:
return qsTr("Fetched website"); return qsTr("Fetched website");
} }
} else if (seg.name === "readfile") { } else if (seg.name === "readfile") {
switch (seg.status) { switch (seg.status) {
case LlmSegment.Status.Running: case LlmToolCall.Status.Running:
return qsTr("Reading"); return qsTr("Reading");
case LlmSegment.Status.Pending: case LlmToolCall.Status.Pending:
return qsTr("Pending read"); return qsTr("Pending read");
case LlmSegment.Status.Success: case LlmToolCall.Status.Success:
return qsTr("Read"); return qsTr("Read");
case LlmSegment.Status.Error: case LlmToolCall.Status.Error:
return qsTr("Failed to read"); return qsTr("Failed to read");
default: default:
return qsTr("Read file"); return qsTr("Read file");
@@ -65,9 +65,7 @@ Item {
} }
} }
model: ScriptModel { model: LlmClient.chats
values: Chat.chats.values
}
onLoadChatRequest: (chat, index) => { onLoadChatRequest: (chat, index) => {
ChatState.currentIdx = index; ChatState.currentIdx = index;
@@ -75,17 +73,13 @@ Item {
} }
onDeleteChatRequest: chat => { onDeleteChatRequest: chat => {
if (chat === ChatState.chatSession) { const wasCurrent = chat === ChatState.chatSession;
ChatState.chatSession = null; LlmClient.chats.remove(chat);
ChatState.currentIdx = -1;
} if (wasCurrent)
Chat.chats.remove(chat); ChatState.openDraft();
}
onNewChatRequest: {
const data = Chat.chats.insert();
ChatState.currentIdx = 0;
ChatState.chatSession = data;
} }
onNewChatRequest: ChatState.openDraft()
onRequestExpand: { onRequestExpand: {
ChatState.narrowSidebarExpanded = true; ChatState.narrowSidebarExpanded = true;
@@ -157,30 +151,6 @@ Item {
root.chatOpen = true 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 { MouseArea {
id: dimArea id: dimArea
anchors.fill: parent anchors.fill: parent
@@ -192,8 +162,10 @@ Item {
const x = mouseX; const x = mouseX;
const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2; const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2;
if (x > invertedRect.borderLeft - margin) if (x > invertedRect.borderLeft - margin) {
ChatState.narrowSidebarExpanded = false; ChatState.narrowSidebarExpanded = false;
ChatState.fabExpanded = false;
}
} }
HoverHandler { HoverHandler {
+1 -1
View File
@@ -107,7 +107,7 @@ Item {
GeminiGlow { GeminiGlow {
innerGlow: 50 innerGlow: 50
z: 5 z: 5
opacity: Chat.busy ? 1.0 : 0.0 opacity: LlmClient.busy ? 1.0 : 0.0
glowSize: 0 glowSize: 0
visible: opacity > 0 && !ChatState.isWindow visible: opacity > 0 && !ChatState.isWindow
+2 -2
View File
@@ -30,9 +30,9 @@ Item {
} }
Connections { Connections {
target: Chat target: LlmClient
function onErrorOccurred(message: string): void { function onGenerationFailed(session: ChatSession, message: string): void {
if (root.shouldBeActive && root.chatActive) if (root.shouldBeActive && root.chatActive)
return; return;
@@ -88,6 +88,7 @@ PageBase {
id: selectScheme id: selectScheme
acceptLabel: qsTr("Confirm") acceptLabel: qsTr("Confirm")
settingAnchor: "panels-sidebar-llm-scheme"
enabled: Object.keys(model).length > 0 enabled: Object.keys(model).length > 0
header: qsTr("Select scheme") header: qsTr("Select scheme")
first: true first: true
+5 -2
View File
@@ -232,22 +232,25 @@ qml_module(ZShell-llm
URI ZShell.Llm URI ZShell.Llm
SOURCES SOURCES
attachment.hpp attachment.cpp attachment.hpp attachment.cpp
chat.hpp chat.cpp
chatstore.hpp chatstore.cpp chatstore.hpp chatstore.cpp
clipboard.hpp clipboard.cpp
codehighlighter.hpp codehighlighter.cpp codehighlighter.hpp codehighlighter.cpp
filetool.hpp filetool.cpp filetool.hpp filetool.cpp
generation.hpp generation.cpp generation.hpp generation.cpp
llmclient.hpp llmclient.cpp llmclient.hpp llmclient.cpp
markdownblock.hpp markdownblock.hpp markdownblock.cpp
markdownparser.hpp markdownparser.cpp markdownparser.hpp markdownparser.cpp
mathtext.hpp mathtext.cpp mathtext.hpp mathtext.cpp
message.hpp message.cpp message.hpp message.cpp
messagemodel.hpp messagemodel.cpp messagemodel.hpp messagemodel.cpp
prompts.hpp prompts.cpp
segment.hpp segment.cpp segment.hpp segment.cpp
session.hpp session.cpp session.hpp session.cpp
shelltool.hpp shelltool.cpp shelltool.hpp shelltool.cpp
tool.hpp tool.cpp tool.hpp tool.cpp
turn.hpp turn.cpp
webfetchtool.hpp webfetchtool.cpp webfetchtool.hpp webfetchtool.cpp
workspace.hpp workspace.cpp
LIBRARIES LIBRARIES
Qt::Network Qt::Network
Qt::Sql Qt::Sql
-201
View File
@@ -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 <QDebug>
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
-91
View File
@@ -1,91 +0,0 @@
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include <QtQml>
#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
+240 -146
View File
@@ -1,6 +1,8 @@
#include "chatstore.hpp" #include "chatstore.hpp"
#include "attachment.hpp"
#include "llmclient.hpp" #include "llmclient.hpp"
#include "markdownblock.hpp"
#include "message.hpp" #include "message.hpp"
#include "segment.hpp" #include "segment.hpp"
@@ -11,15 +13,12 @@
#include <QFileInfo> #include <QFileInfo>
#include <QPointer> #include <QPointer>
#include <QStandardPaths> #include <QStandardPaths>
#include <QSqlDatabase>
#include <QSqlError> #include <QSqlError>
#include <QSqlQuery> #include <QSqlQuery>
#include <QThreadPool> #include <QThreadPool>
#include <QVector> #include <QVector>
#include <QUuid> #include <QUuid>
#include <algorithm>
namespace ZShell::llm { namespace ZShell::llm {
namespace { namespace {
@@ -76,22 +75,25 @@ struct AttachmentRow {
struct MessageRow { struct MessageRow {
bool user = false; bool user = false;
qint64 timestamp = 0; qint64 timestamp = 0;
QString content;
QVector<GenerationRow> generations; QVector<GenerationRow> generations;
QVector<AttachmentRow> attachments; QVector<AttachmentRow> attachments;
}; };
} // namespace } // namespace
ChatStore::ChatStore(QObject* parent) ChatStore::ChatStore(LlmClient* client, QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) { : QAbstractListModel(parent)
, m_client(client)
, m_connectionName(QUuid::createUuid().toString()) {
openDb(); openDb();
load(); load();
} }
ChatStore::~ChatStore() { ChatStore::~ChatStore() {
if (m_connectionName.isEmpty()) return; if (m_connectionName.isEmpty()) return;
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); QSqlDatabase handle = QSqlDatabase::database(m_connectionName, false);
db.close(); handle.close();
QSqlDatabase::removeDatabase(m_connectionName); QSqlDatabase::removeDatabase(m_connectionName);
} }
@@ -105,20 +107,20 @@ void ChatStore::openDb() {
QStringLiteral("/zshell/chats.sqlite"); QStringLiteral("/zshell/chats.sqlite");
QDir().mkpath(QFileInfo(m_dbPath).absolutePath()); QDir().mkpath(QFileInfo(m_dbPath).absolutePath());
QSqlDatabase db = QSqlDatabase handle =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
db.setDatabaseName(m_dbPath); handle.setDatabaseName(m_dbPath);
if (!db.open()) { if (!handle.open()) {
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":" qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
<< db.lastError().text(); << handle.lastError().text();
return; return;
} }
{ {
QSqlQuery pragma(db); QSqlQuery pragma(handle);
pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON")); pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(handle);
query.exec(QStringLiteral( query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS sessions (\n" "CREATE TABLE IF NOT EXISTS sessions (\n"
" id TEXT PRIMARY KEY,\n" " id TEXT PRIMARY KEY,\n"
@@ -130,18 +132,23 @@ void ChatStore::openDb() {
")")); ")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(handle);
query.exec(QStringLiteral( query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS messages (\n" "CREATE TABLE IF NOT EXISTS messages (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" session_id TEXT NOT NULL REFERENCES sessions (id) " " session_id TEXT NOT NULL REFERENCES sessions (id) "
"ON DELETE CASCADE,\n" "ON DELETE CASCADE,\n"
" role TEXT NOT NULL,\n" " role TEXT NOT NULL,\n"
" content TEXT NOT NULL DEFAULT '',\n"
" timestamp INTEGER NOT NULL\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( query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS generations (\n" "CREATE TABLE IF NOT EXISTS generations (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
@@ -152,7 +159,7 @@ void ChatStore::openDb() {
")")); ")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(handle);
query.exec(QStringLiteral( query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS segments (\n" "CREATE TABLE IF NOT EXISTS segments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
@@ -170,7 +177,7 @@ void ChatStore::openDb() {
")")); ")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(handle);
query.exec(QStringLiteral( query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS attachments (\n" "CREATE TABLE IF NOT EXISTS attachments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
@@ -184,7 +191,7 @@ void ChatStore::openDb() {
")")); ")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(handle);
query.exec(QStringLiteral( query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_messages_session " "CREATE INDEX IF NOT EXISTS idx_messages_session "
"ON messages (session_id)")); "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<int>(m_sessions.size()); return static_cast<int>(m_sessions.size());
} }
QVariantList ChatStore::values() const { QVariant ChatStore::data(const QModelIndex& index, int role) const {
QVariantList vals; if (role != Qt::UserRole || !index.isValid() || index.row() < 0 ||
vals.reserve(m_sessions.size()); index.row() >= m_sessions.size())
for (const auto* session : m_sessions) return {};
vals.append(QVariant::fromValue(session)); return QVariant::fromValue(m_sessions.at(index.row()));
return vals;
} }
ChatSession* ChatStore::at(int index) const { QHash<int, QByteArray> ChatStore::roleNames() const {
if (index < 0 || index >= m_sessions.size()) return nullptr; return {{Qt::UserRole, "modelData"}};
return m_sessions.at(index);
} }
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 qint64 now = QDateTime::currentMSecsSinceEpoch();
const QString id = QUuid::createUuid().toString(); m_draft = new ChatSession(this, QUuid::createUuid().toString(), this);
{ m_draft->hydrate(QString(), QString(), now, now, 0, false);
QSqlQuery query(db()); return m_draft;
query.prepare( }
"INSERT INTO sessions (id, title, created_at, updated_at) "
"VALUES (:id, '', :created_at, :updated_at)"); void ChatStore::commitDraft(ChatSession* session) {
query.bindValue(":id", id); if (!session || session->isCommitted()) return;
query.bindValue(":created_at", now); if (m_draft == session) m_draft = nullptr;
query.bindValue(":updated_at", now);
if (!query.exec()) if (!writeSessionRow(session)) return;
qWarning() << "ChatStore: failed to insert session" << id << ":" session->setCommitted(true);
<< query.lastError().text(); 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); return true;
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;
} }
void ChatStore::remove(int index) { void ChatStore::removeRow(ChatSession* session) {
removeSession(at(index)); 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<ChatSession*> before = m_sessions;
Q_EMIT sessionRemoved(session); Q_EMIT sessionRemoved(session);
{ {
QSqlQuery query(db()); QSqlQuery query(db());
query.prepare("DELETE FROM sessions WHERE id = :id"); query.prepare("DELETE FROM sessions WHERE id = :id");
query.bindValue(":id", session->id()); query.bindValue(":id", session->id());
query.exec(); 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(); session->deleteLater();
notify(before);
} }
void ChatStore::move(int from, int to) { void ChatStore::remove(ChatSession* chat) {
if (from < 0 || from >= m_sessions.size() || to < 0 || if (!chat) return;
to >= m_sessions.size() || from == to) 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; return;
m_sessions.move(from, to); }
Q_EMIT valuesChanged();
removeRow(chat);
} }
void ChatStore::clear() { void ChatStore::clear() {
const QList<ChatSession*> sessions = m_sessions; const QList<ChatSession*> sessions = m_sessions;
for (ChatSession* session : sessions) for (ChatSession* session : sessions)
removeSession(session); remove(session);
} }
ChatSession* ChatStore::sessionById(const QString& id) { void ChatStore::requestClear(ChatSession* session) {
for (auto* session : m_sessions) if (!session) return;
if (session->id() == id) return session; m_pendingClears.insert(session);
return nullptr; if (session->isLoaded() && m_client->streamingSession() != session)
applyPendingClear(session);
} }
void ChatStore::setLlmClient(LlmClient* client) { void ChatStore::applyPendingClear(ChatSession* session) {
m_llmClient = client; if (!session || !m_pendingClears.remove(session)) return;
if (!session->isLoaded()) return;
session->clearMessages();
persist(session);
} }
void ChatStore::persist(ChatSession* session) { void ChatStore::persist(ChatSession* session) {
@@ -297,21 +367,23 @@ void ChatStore::persist(ChatSession* session) {
} }
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
if (!saveSession(session)) return; if (!saveSession(session)) return;
sortAndNotify(); reposition(session);
} }
void ChatStore::saveMeta(ChatSession* session) { void ChatStore::saveFlags(ChatSession* session) {
if (!session || !m_sessions.contains(session)) return; if (!session || !m_sessions.contains(session)) return;
QSqlQuery query(db()); QSqlQuery query(db());
query.prepare( query.prepare(
"UPDATE sessions SET title = :title, icon = :icon " "UPDATE sessions SET title = :title, icon = :icon, "
"WHERE id = :id"); "pinned = :pinned WHERE id = :id");
query.bindValue(":title", sqlText(session->title())); query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon())); query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":pinned", session->pinned() ? 1 : 0);
query.bindValue(":id", session->id()); query.bindValue(":id", session->id());
if (!query.exec()) if (!query.exec())
qWarning() << "ChatStore: failed to save meta for" << session->id() qWarning() << "ChatStore: failed to save flags for" << session->id()
<< ":" << query.lastError().text(); << ":" << query.lastError().text();
reposition(session);
} }
bool ChatStore::saveSession(ChatSession* session) { bool ChatStore::saveSession(ChatSession* session) {
@@ -326,10 +398,12 @@ bool ChatStore::saveSession(ChatSession* session) {
{ {
QSqlQuery query(handle); QSqlQuery query(handle);
query.prepare( query.prepare(
"UPDATE sessions SET title = :title, updated_at = :updated_at " "UPDATE sessions SET title = :title, icon = :icon, "
"WHERE id = :id"); "updated_at = :updated_at, pinned = :pinned WHERE id = :id");
query.bindValue(":title", sqlText(session->title())); query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":updated_at", session->updatedAtMs()); query.bindValue(":updated_at", session->updatedAtMs());
query.bindValue(":pinned", session->pinned() ? 1 : 0);
query.bindValue(":id", session->id()); query.bindValue(":id", session->id());
ok = query.exec(); ok = query.exec();
} }
@@ -342,8 +416,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (ok) { if (ok) {
QSqlQuery messageInsert(handle); QSqlQuery messageInsert(handle);
ok = messageInsert.prepare( ok = messageInsert.prepare(
"INSERT INTO messages (session_id, role, timestamp) " "INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (:id, :role, :timestamp)"); "VALUES (:id, :role, :content, :timestamp)");
QSqlQuery generationInsert(handle); QSqlQuery generationInsert(handle);
ok = ok && ok = ok &&
generationInsert.prepare( generationInsert.prepare(
@@ -371,6 +445,7 @@ bool ChatStore::saveSession(ChatSession* session) {
message->role() == ChatMessage::Role::User message->role() == ChatMessage::Role::User
? QStringLiteral("user") ? QStringLiteral("user")
: QStringLiteral("assistant")); : QStringLiteral("assistant"));
messageInsert.bindValue(":content", sqlText(message->content()));
messageInsert.bindValue(":timestamp", message->timestamp()); messageInsert.bindValue(":timestamp", message->timestamp());
if (!messageInsert.exec()) { if (!messageInsert.exec()) {
ok = false; ok = false;
@@ -401,16 +476,24 @@ bool ChatStore::saveSession(ChatSession* session) {
segmentInsert.bindValue(":gid", generationId); segmentInsert.bindValue(":gid", generationId);
segmentInsert.bindValue( segmentInsert.bindValue(
":type", segmentTypeName(segment->type())); ":type", segmentTypeName(segment->type()));
segmentInsert.bindValue(":text", sqlText(segment->text())); const auto* text =
segmentInsert.bindValue(":name", sqlText(segment->name())); qobject_cast<const LlmTextSegment*>(segment);
segmentInsert.bindValue( segmentInsert.bindValue(
":tool_call_id", sqlText(segment->toolCallId())); ":text", sqlText(text ? text->text() : QString()));
const auto* call =
qobject_cast<const LlmToolCall*>(segment);
segmentInsert.bindValue( segmentInsert.bindValue(
":arguments", sqlText(segment->arguments())); ":name", sqlText(call ? call->name() : QString()));
segmentInsert.bindValue( segmentInsert.bindValue(
":result", sqlText(segment->result())); ":tool_call_id",
sqlText(call ? call->toolCallId() : QString()));
segmentInsert.bindValue( segmentInsert.bindValue(
":status", static_cast<int>(segment->status())); ":arguments",
sqlText(call ? call->arguments() : QString()));
segmentInsert.bindValue(
":result", sqlText(call ? call->result() : QString()));
segmentInsert.bindValue(
":status", call ? static_cast<int>(call->status()) : 0);
segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs()); segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
segmentInsert.bindValue(":timestamp", segment->timestamp()); segmentInsert.bindValue(":timestamp", segment->timestamp());
if (!segmentInsert.exec()) { if (!segmentInsert.exec()) {
@@ -448,12 +531,13 @@ bool ChatStore::saveSession(ChatSession* session) {
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
<< "commit failed, rolling back"; << "commit failed, rolling back";
handle.rollback(); handle.rollback();
ok = false;
qWarning() << "ChatStore: failed to save session" << session->id() qWarning() << "ChatStore: failed to save session" << session->id()
<< ":" << handle.lastError().text(); << ":" << handle.lastError().text();
return false;
} }
return ok; return true;
} }
void ChatStore::loadMessagesInto(ChatSession* session) { void ChatStore::loadMessagesInto(ChatSession* session) {
if (!session) return; if (!session) return;
const QString sessionId = session->id(); const QString sessionId = session->id();
@@ -467,15 +551,15 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
QList<MessageRow> rows; QList<MessageRow> rows;
const QString connName = QUuid::createUuid().toString(); const QString connName = QUuid::createUuid().toString();
{ {
QSqlDatabase db = QSqlDatabase handle =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName); QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
db.setDatabaseName(path); handle.setDatabaseName(path);
if (db.open()) { if (handle.open()) {
QSqlQuery busy(db); QSqlQuery busy(handle);
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
QSqlQuery query(db); QSqlQuery query(handle);
query.prepare( query.prepare(
"SELECT id, role, timestamp FROM messages " "SELECT id, role, content, timestamp FROM messages "
"WHERE session_id = :id ORDER BY rowid DESC"); "WHERE session_id = :id ORDER BY rowid DESC");
query.bindValue(":id", sessionId); query.bindValue(":id", sessionId);
if (!query.exec()) { if (!query.exec()) {
@@ -487,8 +571,9 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
MessageRow message; MessageRow message;
message.user = query.value(1).toString() == message.user = query.value(1).toString() ==
QLatin1String("user"); QLatin1String("user");
message.timestamp = query.value(2).toLongLong(); message.content = query.value(2).toString();
QSqlQuery generationQuery(db); message.timestamp = query.value(3).toLongLong();
QSqlQuery generationQuery(handle);
generationQuery.prepare( generationQuery.prepare(
"SELECT id, timestamp, is_active FROM " "SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid " "generations WHERE message_id = :mid "
@@ -501,7 +586,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
generationQuery.value(1).toLongLong(); generationQuery.value(1).toLongLong();
generation.active = generation.active =
generationQuery.value(2).toInt() != 0; generationQuery.value(2).toInt() != 0;
QSqlQuery segmentQuery(db); QSqlQuery segmentQuery(handle);
segmentQuery.prepare( segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, " "SELECT type, text, name, tool_call_id, "
"arguments, result, status, elapsed_ms, " "arguments, result, status, elapsed_ms, "
@@ -549,7 +634,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
<< messageId << ":" << messageId << ":"
<< generationQuery.lastError().text(); << generationQuery.lastError().text();
} }
QSqlQuery attachmentQuery(db); QSqlQuery attachmentQuery(handle);
attachmentQuery.prepare( attachmentQuery.prepare(
"SELECT name, path, kind, mime, size FROM " "SELECT name, path, kind, mime, size FROM "
"attachments WHERE message_id = :mid " "attachments WHERE message_id = :mid "
@@ -580,10 +665,10 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
rows.append(message); rows.append(message);
} }
} }
db.close(); handle.close();
} else { } else {
qWarning() << "ChatStore: failed to open database for load:" qWarning() << "ChatStore: failed to open database for load:"
<< db.lastError().text(); << handle.lastError().text();
} }
} }
QSqlDatabase::removeDatabase(connName); QSqlDatabase::removeDatabase(connName);
@@ -595,7 +680,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
ChatSession* s = session; ChatSession* s = session;
if (!st || !s) return; if (!st || !s) return;
auto* model = s->model(); auto* model = s->messagesModel();
if (!model) return; if (!model) return;
QList<ChatMessage*> messages; QList<ChatMessage*> messages;
for (const MessageRow& row : rows) { for (const MessageRow& row : rows) {
@@ -603,6 +688,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
row.user ? ChatMessage::Role::User row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant, : ChatMessage::Role::Assistant,
row.timestamp); row.timestamp);
if (!row.attachments.isEmpty()) { if (!row.attachments.isEmpty()) {
QList<Attachment*> attachments; QList<Attachment*> attachments;
for (const AttachmentRow& attachmentRow : for (const AttachmentRow& attachmentRow :
@@ -619,27 +705,54 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
} }
message->setAttachments(attachments); 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; int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) { for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow = const GenerationRow& generationRow =
row.generations.at(i); row.generations.at(i);
auto* generation = auto* generation =
message->addGeneration(generationRow.timestamp); message->addGeneration(generationRow.timestamp);
if (!generation) continue;
for (const SegmentRow& segmentRow : for (const SegmentRow& segmentRow :
generationRow.segments) { generationRow.segments) {
auto* segment = new LlmSegment( const LlmSegment::Type type =
segmentTypeFromName(segmentRow.type), segmentTypeFromName(segmentRow.type);
segmentRow.timestamp, LlmSegment* segment = nullptr;
generation); if (type == LlmSegment::Type::ToolCall) {
segment->setText(segmentRow.text); auto* call = new LlmToolCall(
segment->setName(segmentRow.name); segmentRow.timestamp, generation);
segment->setToolCallId(segmentRow.toolCallId); call->setName(segmentRow.name);
segment->appendArguments(segmentRow.arguments); call->setToolCallId(segmentRow.toolCallId);
segment->setResult(segmentRow.result); call->appendArguments(segmentRow.arguments);
segment->setStatus( call->setResult(segmentRow.result);
static_cast<LlmSegment::Status>( call->setStatus(
segmentRow.status)); static_cast<LlmToolCall::Status>(
segment->restore(segmentRow.elapsedMs); 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); generation->addSegment(segment);
} }
if (generationRow.active) activeIndex = i; if (generationRow.active) activeIndex = i;
@@ -658,54 +771,35 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
s->markLoaded(); s->markLoaded();
if (s->takeClearPending()) { st->applyPendingClear(s);
s->clear(); if (st->m_pendingPersists.remove(s)) st->persist(s);
} else if (st->m_pendingPersists.remove(s)) {
st->persist(s);
}
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
} }
void ChatStore::load() { void ChatStore::load() {
m_loading = true;
QSqlQuery query(db()); QSqlQuery query(db());
query.exec( query.exec(
"SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, " "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 " "(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"); "FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC");
while (query.next()) { while (query.next()) {
auto* session = new ChatSession(query.value(0).toString(), this); auto* session = new ChatSession(this, query.value(0).toString(), this);
session->setMeta( session->hydrate(
query.value(1).toString(), query.value(1).toString(),
query.value(2).toString(),
query.value(3).toLongLong(), query.value(3).toLongLong(),
query.value(4).toLongLong(), query.value(4).toLongLong(),
query.value(6).toInt()); query.value(6).toInt(),
session->setIcon(query.value(2).toString()); query.value(5).toInt() != 0);
session->setPinned(query.value(5).toInt() != 0);
m_sessions.append(session); m_sessions.append(session);
} }
sortAndNotify(); m_loading = false;
}
void ChatStore::sortAndNotify() { beginResetModel();
const QList<ChatSession*> before = m_sessions; endResetModel();
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<ChatSession*>& 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();
} }
} // namespace ZShell::llm } // namespace ZShell::llm
+40 -29
View File
@@ -2,65 +2,76 @@
#include "session.hpp" #include "session.hpp"
#include <QObject> #include <QAbstractListModel>
#include <QHash>
#include <QList>
#include <QSet> #include <QSet>
#include <QSqlDatabase> #include <QSqlDatabase>
#include <QString> #include <QString>
#include <QVariantList> #include <QtQml>
namespace ZShell::llm { namespace ZShell::llm {
class LlmClient; class LlmClient;
class ChatStore : public QObject { class ChatStore : public QAbstractListModel {
Q_OBJECT Q_OBJECT
QML_ANONYMOUS QML_ELEMENT
QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton")
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged)
public: public:
explicit ChatStore(QObject* parent = nullptr); explicit ChatStore(LlmClient* client, QObject* parent = nullptr);
~ChatStore() override; ~ChatStore() override;
[[nodiscard]] int count() const; [[nodiscard]] int rowCount(
[[nodiscard]] QVariantList values() const; const QModelIndex& parent = QModelIndex()) const override;
[[nodiscard]] ChatSession* at(int index) const; [[nodiscard]] QVariant data(
const QModelIndex& index, int role = Qt::DisplayRole) const override;
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1); [[nodiscard]] LlmClient* client() const { return m_client; }
Q_INVOKABLE void remove(int index);
[[nodiscard]] int rowOf(const ChatSession* session) const;
Q_INVOKABLE ZShell::llm::ChatSession* draft();
Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat); Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat);
Q_INVOKABLE void move(int from, int to);
Q_INVOKABLE void clear(); Q_INVOKABLE void clear();
[[nodiscard]] ChatSession* sessionById(const QString& id); void commitDraft(ZShell::llm::ChatSession* session);
[[nodiscard]] LlmClient* llmClient() const { return m_llmClient; }
void setLlmClient(LlmClient* client);
void persist(ChatSession* session); void persist(ZShell::llm::ChatSession* session);
void saveMeta(ChatSession* session); void saveFlags(ZShell::llm::ChatSession* session);
void loadMessagesInto(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: Q_SIGNALS:
void countChanged();
void valuesChanged();
void sessionRemoved(ZShell::llm::ChatSession* session); void sessionRemoved(ZShell::llm::ChatSession* session);
private: private:
void openDb(); void openDb();
void load(); void load();
bool saveSession(ChatSession* session); [[nodiscard]] int sortedRow(const ChatSession* session) const;
void sortAndNotify(); [[nodiscard]] static bool firstAfter(
void removeSession(ChatSession* session); const ChatSession* a, const ChatSession* b);
void notify(const QList<ChatSession*>& before); 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<ChatSession*> m_sessions; QList<ChatSession*> m_sessions;
LlmClient* m_llmClient = nullptr; ChatSession* m_draft = nullptr;
QString m_connectionName; QString m_connectionName;
QString m_dbPath; QString m_dbPath;
QSet<ChatSession*> m_pendingPersists; QSet<ChatSession*> m_pendingPersists;
QSet<ChatSession*> m_pendingClears;
[[nodiscard]] QSqlDatabase db() const; bool m_loading = false;
}; };
} // namespace ZShell::llm } // namespace ZShell::llm
+61
View File
@@ -0,0 +1,61 @@
#include "clipboard.hpp"
#include <QDebug>
#include <QClipboard>
#include <QDateTime>
#include <QDir>
#include <QGuiApplication>
#include <QImage>
#include <QMimeData>
#include <QUrl>
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
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <QString>
#include <QStringList>
namespace ZShell::llm::clipboard {
[[nodiscard]] bool hasAttachment();
[[nodiscard]] QStringList attachmentPaths(QString& error);
} // namespace ZShell::llm::clipboard
+26 -43
View File
@@ -6,23 +6,8 @@
namespace ZShell::llm { namespace ZShell::llm {
namespace { FileReadTool::FileReadTool(ToolRegistry* registry, QObject* parent)
: LlmTool(registry, parent) {}
QJsonObject makeOutput(const QString& text) {
QJsonObject obj;
obj[QStringLiteral("output")] = text;
return obj;
}
QJsonObject makeError(const QString& message) {
QJsonObject obj;
obj[QStringLiteral("error")] = message;
return obj;
}
} // namespace
FileReadTool::FileReadTool(QObject* parent) : LlmTool(parent) {}
QString FileReadTool::name() const { QString FileReadTool::name() const {
return QStringLiteral("readfile"); return QStringLiteral("readfile");
@@ -34,8 +19,8 @@ QString FileReadTool::description() const {
"its content. At most a limited number of characters is returned " "its content. At most a limited number of characters is returned "
"per call; use offset and limit to page through larger files. " "per call; use offset and limit to page through larger files. "
"Binary files are not supported. Full output of webfetch calls " "Binary files are not supported. Full output of webfetch calls "
"is stored under /tmp/zshell-llm/webfetch/ and can be read this " "is saved in the sandbox directory and can be read this way. "
"way. This tool is read-only."); "This tool is read-only.");
} }
QJsonObject FileReadTool::parameters() const { QJsonObject FileReadTool::parameters() const {
@@ -72,19 +57,16 @@ QJsonObject FileReadTool::parameters() const {
} }
void FileReadTool::execute( void FileReadTool::execute(
const QString& toolCallId, const ToolContext& context, const QJsonObject& args, ToolDone done) {
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
Q_UNUSED(toolCallId);
const QString path = args[QStringLiteral("path")].toString().trimmed(); const QString path = args[QStringLiteral("path")].toString().trimmed();
if (path.isEmpty()) { if (path.isEmpty()) {
done(makeError(QStringLiteral("Missing required argument 'path'"))); done({false, QStringLiteral("Missing required argument 'path'")});
return; return;
} }
const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0)); const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0));
int budget = inlineBudgetChars(); const int budget = context.outputBudgetChars > 0
if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars); ? context.outputBudgetChars
: registry()->outputBudgetChars(1);
const int limit = const int limit =
qBound(1, args[QStringLiteral("limit")].toInt(budget), budget); qBound(1, args[QStringLiteral("limit")].toInt(budget), budget);
@@ -92,33 +74,33 @@ void FileReadTool::execute(
if (!file.open(QIODevice::ReadOnly)) { if (!file.open(QIODevice::ReadOnly)) {
const QFileInfo info(path); const QFileInfo info(path);
if (info.exists() && info.isDir()) { if (info.exists() && info.isDir()) {
done(makeError(QStringLiteral( done(
"Path is a directory, not a " {false,
"file: %1") QStringLiteral("Path is a directory, not a file: %1")
.arg(path))); .arg(path)});
} else { } else {
done(makeError(QStringLiteral("Cannot open file: %1").arg(path))); done({false, QStringLiteral("Cannot open file: %1").arg(path)});
} }
return; return;
} }
const qint64 fileSize = file.size(); const qint64 fileSize = file.size();
if (fileSize == 0) { if (fileSize == 0) {
file.close(); file.close();
done(makeOutput(QString())); done({true, QString()});
return; return;
} }
if (offset >= fileSize) { if (offset >= fileSize) {
file.close(); file.close();
done(makeError(QStringLiteral( done(
"Offset %1 is beyond the end of the " {false,
"file (%2 bytes)") QStringLiteral("Offset %1 is beyond the end of the file (%2 bytes)")
.arg(offset) .arg(offset)
.arg(fileSize))); .arg(fileSize)});
return; return;
} }
if (!file.seek(offset)) { if (!file.seek(offset)) {
file.close(); file.close();
done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path))); done({false, QStringLiteral("Cannot seek in file: %1").arg(path)});
return; return;
} }
const qint64 wanted = qint64(limit) * 4 + 16; const qint64 wanted = qint64(limit) * 4 + 16;
@@ -126,13 +108,14 @@ void FileReadTool::execute(
file.close(); file.close();
if (chunk.contains('\0')) { if (chunk.contains('\0')) {
done(makeError( done(
QStringLiteral("Binary files are not supported: %1").arg(path))); {false,
QStringLiteral("Binary files are not supported: %1").arg(path)});
return; return;
} }
const QString content = QString::fromUtf8(chunk); const QString content = QString::fromUtf8(chunk);
if (content.size() <= limit) { if (content.size() <= limit) {
done(makeOutput(content)); done({true, content});
return; return;
} }
const QString head = content.left(limit); const QString head = content.left(limit);
@@ -147,7 +130,7 @@ void FileReadTool::execute(
.arg(nextOffset) .arg(nextOffset)
.arg(fileSize) .arg(fileSize)
.arg(nextOffset); .arg(nextOffset);
done(makeOutput(output)); done({true, output});
} }
} // namespace ZShell::llm } // namespace ZShell::llm
+3 -4
View File
@@ -8,16 +8,15 @@ class FileReadTool : public LlmTool {
Q_OBJECT Q_OBJECT
public: public:
explicit FileReadTool(QObject* parent = nullptr); explicit FileReadTool(ToolRegistry* registry, QObject* parent = nullptr);
QString name() const override; QString name() const override;
QString description() const override; QString description() const override;
QJsonObject parameters() const override; QJsonObject parameters() const override;
void execute( void execute(
const QString& toolCallId, const ToolContext& context,
const QJsonObject& args, const QJsonObject& args,
int outputBudgetChars, ToolDone done) override;
std::function<void(const QJsonObject& result)> done) override;
}; };
} // namespace ZShell::llm } // namespace ZShell::llm
+112 -101
View File
@@ -1,37 +1,50 @@
#include "generation.hpp" #include "generation.hpp"
#include "llmclient.hpp" #include "llmclient.hpp"
#include "messagemodel.hpp" #include "message.hpp"
#include "session.hpp" #include "session.hpp"
#include <QDateTime> #include <QDateTime>
namespace ZShell::llm { namespace ZShell::llm {
ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) ChatGeneration::ChatGeneration(
: QObject(parent), m_timestamp(timestamp) { ChatMessage* message, qint64 timestamp, QObject* parent)
: QObject(parent), m_message(message), m_timestamp(timestamp) {
m_timer.setParent(this); m_timer.setParent(this);
m_timer.setInterval(500); m_timer.setInterval(500);
m_timer.setTimerType(Qt::CoarseTimer); m_timer.setTimerType(Qt::CoarseTimer);
connect(&m_timer, &QTimer::timeout, this, [this]() { connect(&m_timer, &QTimer::timeout, this, [this]() {
bool anyRunning = false; bool runningText = false;
bool runningTool = false;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (!segment->running()) continue; if (!segment->running()) continue;
anyRunning = true; Q_EMIT segment->elapsedMsChanged();
segment->elapsedMsChanged(); if (segment->type() == LlmSegment::Type::ToolCall)
runningTool = true;
else
runningText = true;
} }
if (anyRunning) Q_EMIT elapsedMsChanged(); if (runningText) {
if (!anyRunning && !m_streaming) m_timer.stop(); 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 { QString ChatGeneration::content() const {
QStringList parts; QStringList parts;
for (const auto* segment : m_segments) { for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content || if (segment->type() != LlmSegment::Type::Content) continue;
segment->text().isEmpty()) const auto* text = qobject_cast<const LlmTextSegment*>(segment);
continue; if (!text || text->text().isEmpty()) continue;
parts.append(segment->text()); parts.append(text->text());
} }
return parts.join(QStringLiteral("\n\n")); return parts.join(QStringLiteral("\n\n"));
} }
@@ -39,10 +52,10 @@ QString ChatGeneration::content() const {
QString ChatGeneration::reasoning() const { QString ChatGeneration::reasoning() const {
QStringList parts; QStringList parts;
for (const auto* segment : m_segments) { for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Reasoning || if (segment->type() != LlmSegment::Type::Reasoning) continue;
segment->text().isEmpty()) const auto* text = qobject_cast<const LlmTextSegment*>(segment);
continue; if (!text || text->text().isEmpty()) continue;
parts.append(segment->text()); parts.append(text->text());
} }
return parts.join(QStringLiteral("\n\n")); return parts.join(QStringLiteral("\n\n"));
} }
@@ -77,25 +90,27 @@ qint64 ChatGeneration::toolsElapsedMs() const {
return total; return total;
} }
QList<LlmToolCall*> ChatGeneration::toolCalls() const {
QList<LlmToolCall*> calls;
for (auto* segment : m_segments)
if (auto* call = qobject_cast<LlmToolCall*>(segment))
calls.append(call);
return calls;
}
int ChatGeneration::toolCallCount() const { int ChatGeneration::toolCallCount() const {
int count = 0; return static_cast<int>(toolCalls().size());
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall) ++count;
return count;
} }
bool ChatGeneration::hasRunningTool() const { bool ChatGeneration::hasRunningTool() const {
for (const auto* segment : m_segments) for (const auto* call : toolCalls())
if (segment->type() == LlmSegment::Type::ToolCall && segment->running()) if (call->running()) return true;
return true;
return false; return false;
} }
bool ChatGeneration::hasPendingTool() const { bool ChatGeneration::hasPendingTool() const {
for (const auto* segment : m_segments) for (const auto* call : toolCalls())
if (segment->type() == LlmSegment::Type::ToolCall && if (call->status() == LlmToolCall::Status::Pending) return true;
segment->status() == LlmSegment::Status::Pending)
return true;
return false; return false;
} }
@@ -103,30 +118,37 @@ void ChatGeneration::setApprovalPending(bool value) {
if (m_approvalPending == value) return; if (m_approvalPending == value) return;
m_approvalPending = value; m_approvalPending = value;
Q_EMIT toolApprovalPendingChanged(); Q_EMIT toolApprovalPendingChanged();
Q_EMIT pendingToolCallsChanged();
} }
QVariantList ChatGeneration::pendingToolCalls() const { QVariantList ChatGeneration::pendingToolCalls() const {
QVariantList out; QVariantList out;
if (!m_approvalPending) return out; if (!m_approvalPending) return out;
for (const auto* segment : m_segments) for (const auto* call : toolCalls())
if (segment->type() == LlmSegment::Type::ToolCall && if (call->status() == LlmToolCall::Status::Pending)
segment->status() == LlmSegment::Status::Pending) out.append(QVariant::fromValue(call));
out.append(QVariant::fromValue(segment));
return out; return out;
} }
void ChatGeneration::approveTools() { void ChatGeneration::approveTools() {
if (auto* llmClient = client()) llmClient->approveTools(); if (auto* llmClient = client()) llmClient->approveTools(this);
} }
void ChatGeneration::denyTools() { 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 { LlmClient* ChatGeneration::client() const {
if (auto* message = qobject_cast<ChatMessage*>(parent())) if (m_message && m_message->session())
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent())) return m_message->session()->client();
if (auto* session = model->session()) return session->client();
return nullptr; return nullptr;
} }
@@ -138,21 +160,19 @@ void ChatGeneration::updateReasoningActive() {
} }
void ChatGeneration::setContent(const QString& value) { void ChatGeneration::setContent(const QString& value) {
LlmSegment* first = nullptr; LlmTextSegment* first = nullptr;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content) continue; if (segment->type() != LlmSegment::Type::Content) continue;
auto* text = qobject_cast<LlmTextSegment*>(segment);
if (!text) continue;
if (!first) if (!first)
first = segment; first = text;
else else
segment->setText(QString()); text->setText(QString());
} }
if (!first) { if (!first) {
if (value.isEmpty()) return; if (value.isEmpty()) return;
first = new LlmSegment( first = openTextSegment(LlmSegment::Type::Content);
LlmSegment::Type::Content,
QDateTime::currentMSecsSinceEpoch(),
this);
addSegment(first);
} }
first->setText(value); first->setText(value);
} }
@@ -164,7 +184,7 @@ void ChatGeneration::appendContent(const QString& piece) {
segment->running()) segment->running())
segment->close(); segment->close();
} }
openContentSegment()->appendText(piece); openTextSegment(LlmSegment::Type::Content)->appendText(piece);
} }
void ChatGeneration::appendReasoning(const QString& 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()) if (segment->type() == LlmSegment::Type::Content && segment->running())
segment->close(); segment->close();
} }
openReasoningSegment()->appendText(piece); openTextSegment(LlmSegment::Type::Reasoning)->appendText(piece);
} }
void ChatGeneration::setError(const QString& value) { void ChatGeneration::setError(const QString& value) {
@@ -182,13 +202,7 @@ void ChatGeneration::setError(const QString& value) {
m_hasError = hasError; m_hasError = hasError;
m_errorMessage = value; m_errorMessage = value;
Q_EMIT errorChanged(); Q_EMIT errorChanged();
} Q_EMIT errorMessageChanged();
void ChatGeneration::dismissError() {
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
} }
void ChatGeneration::setStreaming(bool value) { void ChatGeneration::setStreaming(bool value) {
@@ -201,70 +215,67 @@ void ChatGeneration::setStreaming(bool value) {
closeOpenSegments(); closeOpenSegments();
m_timer.stop(); m_timer.stop();
} }
Q_EMIT elapsedMsChanged(); Q_EMIT reasoningElapsedMsChanged();
Q_EMIT contentElapsedMsChanged();
Q_EMIT toolsElapsedMsChanged();
updateReasoningActive(); updateReasoningActive();
} }
LlmSegment* ChatGeneration::openContentSegment() { LlmTextSegment* ChatGeneration::openTextSegment(LlmSegment::Type type) {
for (auto* segment : m_segments) for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Content && segment->running()) if (segment->type() != type) continue;
return segment; auto* text = qobject_cast<LlmTextSegment*>(segment);
auto* segment = new LlmSegment( if (text && text->running()) return text;
LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this); }
segment->begin(); auto* text =
addSegment(segment); new LlmTextSegment(type, QDateTime::currentMSecsSinceEpoch(), this);
return segment; text->begin();
addSegment(text);
return text;
} }
LlmSegment* ChatGeneration::openReasoningSegment() { LlmToolCall* ChatGeneration::beginToolCall(
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(
const QString& name, const QString& toolCallId) { const QString& name, const QString& toolCallId) {
closeOpenSegments(); closeOpenSegments();
auto* segment = new LlmSegment( auto* call = new LlmToolCall(QDateTime::currentMSecsSinceEpoch(), this);
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); call->setName(name);
segment->setName(name); call->setToolCallId(toolCallId);
segment->setToolCallId(toolCallId); call->setStatus(LlmToolCall::Status::Pending);
segment->setStatus(LlmSegment::Status::Pending); call->begin();
segment->begin(); addSegment(call);
addSegment(segment); Q_EMIT toolCallCountChanged();
Q_EMIT toolStateChanged(); return call;
return segment;
} }
void ChatGeneration::addSegment(LlmSegment* segment) { void ChatGeneration::addSegment(LlmSegment* segment) {
if (!segment || m_segments.contains(segment)) return; if (!segment || m_segments.contains(segment)) return;
segment->setParent(this); segment->setParent(this);
connect(segment, &LlmSegment::textChanged, this, [this, segment]() { connect(segment, &LlmSegment::runningChanged, this, [this, segment]() {
if (segment->type() == LlmSegment::Type::Reasoning) Q_EMIT toolCallCountChanged();
Q_EMIT reasoningChanged(); if (segment->type() == LlmSegment::Type::ToolCall)
else if (segment->type() == LlmSegment::Type::Content) Q_EMIT hasRunningToolChanged();
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();
updateReasoningActive(); updateReasoningActive();
}); });
if (auto* text = qobject_cast<LlmTextSegment*>(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<LlmToolCall*>(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); m_segments.append(segment);
Q_EMIT segmentsChanged(); Q_EMIT segmentsChanged();
Q_EMIT toolCallCountChanged();
} }
void ChatGeneration::closeOpenSegments() { void ChatGeneration::closeOpenSegments() {
+29 -18
View File
@@ -5,12 +5,13 @@
#include <QList> #include <QList>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QTimer>
#include <QVariantList> #include <QVariantList>
#include <QtQml> #include <QtQml>
namespace ZShell::llm { namespace ZShell::llm {
class ChatMessage;
class ChatSession;
class LlmClient; class LlmClient;
class ChatGeneration : public QObject { class ChatGeneration : public QObject {
@@ -18,35 +19,39 @@ class ChatGeneration : public QObject {
QML_ELEMENT QML_ELEMENT
QML_UNCREATABLE("Chat generations are managed by ChatMessage") QML_UNCREATABLE("Chat generations are managed by ChatMessage")
Q_PROPERTY( Q_PROPERTY(QString content READ content NOTIFY contentChanged)
QString content READ content WRITE setContent NOTIFY contentChanged)
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged)
Q_PROPERTY(bool error READ error NOTIFY errorChanged) Q_PROPERTY(bool error READ error NOTIFY errorChanged)
Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged) Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorMessageChanged)
Q_PROPERTY( Q_PROPERTY(
qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
elapsedMsChanged) reasoningElapsedMsChanged)
Q_PROPERTY( Q_PROPERTY(
qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) qint64 contentElapsedMs READ contentElapsedMs NOTIFY
Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged) contentElapsedMsChanged)
Q_PROPERTY(
qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY toolsElapsedMsChanged)
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
Q_PROPERTY( Q_PROPERTY(
bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) 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(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY( Q_PROPERTY(
QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
segmentsChanged) segmentsChanged)
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY toolCallCountChanged)
Q_PROPERTY( Q_PROPERTY(
bool toolApprovalPending READ toolApprovalPending NOTIFY bool toolApprovalPending READ toolApprovalPending NOTIFY
toolApprovalPendingChanged) toolApprovalPendingChanged)
Q_PROPERTY( Q_PROPERTY(
QVariantList pendingToolCalls READ pendingToolCalls NOTIFY QVariantList pendingToolCalls READ pendingToolCalls NOTIFY
toolApprovalPendingChanged) pendingToolCallsChanged)
public: 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 content() const;
[[nodiscard]] QString reasoning() const; [[nodiscard]] QString reasoning() const;
@@ -59,12 +64,12 @@ class ChatGeneration : public QObject {
[[nodiscard]] bool streaming() const { return m_streaming; } [[nodiscard]] bool streaming() const { return m_streaming; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; } [[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
[[nodiscard]] QList<LlmToolCall*> toolCalls() const;
[[nodiscard]] int toolCallCount() const; [[nodiscard]] int toolCallCount() const;
[[nodiscard]] bool hasRunningTool() const; [[nodiscard]] bool hasRunningTool() const;
[[nodiscard]] bool hasPendingTool() const; [[nodiscard]] bool hasPendingTool() const;
[[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; } [[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; }
void setApprovalPending(bool value);
[[nodiscard]] QVariantList pendingToolCalls() const; [[nodiscard]] QVariantList pendingToolCalls() const;
Q_INVOKABLE void approveTools(); Q_INVOKABLE void approveTools();
@@ -76,10 +81,10 @@ class ChatGeneration : public QObject {
void appendReasoning(const QString& piece); void appendReasoning(const QString& piece);
void setStreaming(bool value); void setStreaming(bool value);
void setError(const QString& value); void setError(const QString& value);
void setApprovalPending(bool value);
[[nodiscard]] LlmSegment* openContentSegment(); [[nodiscard]] LlmTextSegment* openTextSegment(LlmSegment::Type type);
[[nodiscard]] LlmSegment* openReasoningSegment(); [[nodiscard]] LlmToolCall* beginToolCall(
[[nodiscard]] LlmSegment* beginToolCall(
const QString& name, const QString& toolCallId); const QString& name, const QString& toolCallId);
void addSegment(LlmSegment* segment); void addSegment(LlmSegment* segment);
void closeOpenSegments(); void closeOpenSegments();
@@ -88,17 +93,23 @@ class ChatGeneration : public QObject {
void contentChanged(); void contentChanged();
void reasoningChanged(); void reasoningChanged();
void errorChanged(); void errorChanged();
void errorMessageChanged();
void reasoningActiveChanged(); void reasoningActiveChanged();
void elapsedMsChanged(); void reasoningElapsedMsChanged();
void contentElapsedMsChanged();
void toolsElapsedMsChanged();
void streamingChanged(); void streamingChanged();
void toolStateChanged(); void hasRunningToolChanged();
void segmentsChanged(); void segmentsChanged();
void toolCallCountChanged();
void toolApprovalPendingChanged(); void toolApprovalPendingChanged();
void pendingToolCallsChanged();
private: private:
void updateReasoningActive(); void updateReasoningActive();
LlmClient* client() const; [[nodiscard]] LlmClient* client() const;
ChatMessage* m_message;
QTimer m_timer; QTimer m_timer;
QList<LlmSegment*> m_segments; QList<LlmSegment*> m_segments;
bool m_reasoningActive = false; bool m_reasoningActive = false;
File diff suppressed because it is too large Load Diff
+53 -73
View File
@@ -1,18 +1,19 @@
#pragma once #pragma once
#include "segment.hpp" #include "chatstore.hpp"
#include "tool.hpp" #include "tool.hpp"
#include "turn.hpp"
#include <QByteArray> #include <QByteArray>
#include <QJsonArray> #include <QJsonArray>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QObject> #include <QObject>
#include <QPointer>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QTimer> #include <QTimer>
#include <functional> #include <functional>
#include <memory>
class QJsonObject; class QJsonObject;
class QNetworkReply; class QNetworkReply;
@@ -21,80 +22,75 @@ namespace ZShell::llm {
class ChatGeneration; class ChatGeneration;
class ChatSession; class ChatSession;
class LlmSegment;
class LlmTool;
class LlmClient : public QObject { class LlmClient : public QObject {
Q_OBJECT 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: public:
explicit LlmClient(QObject* parent = nullptr); explicit LlmClient(QObject* parent = nullptr);
~LlmClient() override; ~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]] QString model() const { return m_model; }
[[nodiscard]] double temperature() const { return m_temperature; }
[[nodiscard]] QStringList availableModels() const { [[nodiscard]] QStringList availableModels() const {
return m_availableModels; return m_availableModels;
} }
[[nodiscard]] int contextSize() const { return m_contextSize; } [[nodiscard]] int contextSize() const { return m_tools->contextSize(); }
[[nodiscard]] ToolRegistry* tools() const { return m_tools; }
[[nodiscard]] QString serverError() const { return m_serverError; }
void setEndpoint(const QString& value);
void setModel(const QString& value);
void setTemperature(double value);
void setContextSize(int size);
void probeContextSize(std::function<void()> done = {});
void refreshAllStates();
[[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
void setToolsEnabled(bool value) { m_tools->setEnabled(value); } [[nodiscard]] bool sandboxEnabled() const {
[[nodiscard]] bool sandboxEnabled() const { return m_sandboxEnabled; } 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); void setSandboxEnabled(bool value);
[[nodiscard]] QString activeWorkspace() const;
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } Q_INVOKABLE void selectModel(const QString& id);
[[nodiscard]] ChatSession* streamingSession() const { return m_active; } Q_INVOKABLE void stop();
Q_INVOKABLE void refreshModels();
Q_INVOKABLE void refreshFromServer();
void startGeneration(ChatSession* session, ChatGeneration* target); void startGeneration(ChatSession* session, ChatGeneration* target);
void stop(); void approveTools(ZShell::llm::ChatGeneration* generation);
void endStream(); void denyTools(ZShell::llm::ChatGeneration* generation);
void clearOnFinish(ChatSession* session);
void sessionRemoved(ChatSession* session);
void approveTools();
void denyTools();
void refreshModels();
void requestTitle(ChatSession* session, const QString& userText); void requestTitle(ChatSession* session, const QString& userText);
void requestIcon(ChatSession* session, const QString& userText); void requestIcon(ChatSession* session, const QString& userText);
Q_SIGNALS: Q_SIGNALS:
void busyChanged(); void busyChanged();
void endpointChanged();
void modelChanged(); void modelChanged();
void availableModelsChanged(); void availableModelsChanged();
void contextSizeChanged(); void contextSizeChanged();
void toolsEnabledChanged(); void toolsEnabledChanged();
void streamingChatIdChanged(); void sandboxEnabledChanged();
void errorOccurred(const QString& message);
void serverErrorChanged(); void serverErrorChanged();
void titleSuggested(ZShell::llm::ChatSession* session, const QString& title); void generationFailed(
void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon); ZShell::llm::ChatSession* session, const QString& message);
private: private:
struct ToolCallBuilder {
QString id;
QString name;
QString arguments;
QPointer<LlmSegment> segment;
bool seen = false;
};
void sendRound(); void sendRound();
QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
void appendSandboxPreamble(QJsonArray& messages);
void applyToolCallDelta(const QJsonObject& call); void applyToolCallDelta(const QJsonObject& call);
void roundFinished(); void roundFinished();
void executeAllCalls(); void executeAllCalls();
@@ -102,13 +98,17 @@ class LlmClient : public QObject {
void setApprovalPending(bool value); void setApprovalPending(bool value);
void finishPendingCalls(const QString& resultText); void finishPendingCalls(const QString& resultText);
void finishTurn(); void finishTurn();
void endStream();
void fail(const QString& message); void fail(const QString& message);
void handleLine(const QByteArray& line); void handleLine(const QByteArray& line);
void drainBuffer(); void drainBuffer();
void updateTokenUsage(const QJsonObject& data); void updateTokenUsage(const QJsonObject& data);
void setBusy(bool value); void setBusy(bool value);
void setStreamingChatId(const QString& id);
void setServerError(const QString& value); void setServerError(const QString& value);
void setModel(const QString& value);
void setContextSize(int size);
void probeContextSize(std::function<void()> done = {});
void refreshAllStates();
void shortRequest( void shortRequest(
const QString& tag, const QString& tag,
const QString& systemPrompt, const QString& systemPrompt,
@@ -119,42 +119,22 @@ class LlmClient : public QObject {
static QString jsonErrorMessage(const QByteArray& body); static QString jsonErrorMessage(const QByteArray& body);
static QString serverErrorMessage( static QString serverErrorMessage(
const QByteArray& body, const QString& fallback); const QByteArray& body, const QString& fallback);
[[nodiscard]] ToolContext toolContext() const;
QNetworkAccessManager m_manager; QNetworkAccessManager m_manager;
ChatStore* m_store = nullptr;
ToolRegistry* m_tools = nullptr; ToolRegistry* m_tools = nullptr;
std::unique_ptr<Turn> m_turn;
QNetworkReply* m_reply = nullptr; QNetworkReply* m_reply = nullptr;
QByteArray m_buffer; QByteArray m_buffer;
QTimer m_refreshTimer; QTimer m_refreshTimer;
QPointer<ChatSession> m_active;
QPointer<ChatGeneration> m_streaming;
QPointer<ChatSession> m_pendingClear;
bool m_busy = false;
QString m_streamingChatId;
QString m_endpoint; QString m_endpoint;
QString m_model; QString m_model;
QStringList m_availableModels; QStringList m_availableModels;
QString m_serverError; QString m_serverError;
double m_temperature = 0.7; double m_temperature = 0.7;
int m_contextSize = 0; bool m_busy = false;
bool m_configEnabled = true;
QJsonArray m_transcript;
QList<ToolCallBuilder> m_callBuilders;
struct ToolCallResult {
QString content;
bool success = false;
};
QList<ToolCallResult> 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;
static constexpr int kMaxToolRounds = 12; static constexpr int kMaxToolRounds = 12;
}; };
+23
View File
@@ -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
+37 -3
View File
@@ -1,20 +1,54 @@
#pragma once #pragma once
#include <QObject> #include <QObject>
#include <QString>
#include <QtQml> #include <QtQml>
namespace ZShell::llm { namespace ZShell::llm {
class LlmMarkdown : public QObject { class MarkdownBlock : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT 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: public:
enum class Type : int { Text = 0, Heading, Code, Math }; enum class Type : int { Text = 0, Heading, Code, Math };
Q_ENUM(Type) 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 } // namespace ZShell::llm
+16 -25
View File
@@ -1,18 +1,15 @@
#include "markdownparser.hpp" #include "markdownparser.hpp"
#include "markdownblock.hpp"
#include <cmark-gfm-extension_api.h> #include <cmark-gfm-extension_api.h>
#include <cmark-gfm.h> #include <cmark-gfm.h>
#include <QRegularExpression> #include <QRegularExpression>
#include <QStringList> #include <QStringList>
#include <QVariantMap>
namespace ZShell::llm { namespace ZShell::llm {
QVariantList MarkdownParser::parse(const QString& source) { QVector<MarkdownBlockData> MarkdownParser::parse(const QString& source) {
QVariantList blocks; QVector<MarkdownBlockData> blocks;
if (source.trimmed().isEmpty()) return blocks; if (source.trimmed().isEmpty()) return blocks;
const QStringList lines = source.split('\n'); const QStringList lines = source.split('\n');
@@ -35,42 +32,36 @@ QVariantList MarkdownParser::parse(const QString& source) {
QStringLiteral("\\$\\$(.+?)\\$\\$"), QStringLiteral("\\$\\$(.+?)\\$\\$"),
QRegularExpression::DotMatchesEverythingOption); QRegularExpression::DotMatchesEverythingOption);
auto makeBlock = [&](LlmMarkdown::Type type) {
QVariantMap block;
block.insert("type", static_cast<int>(type));
block.insert(
"id",
QString::number(static_cast<int>(blocks.size())) +
QLatin1Char(':') + QString::number(static_cast<int>(type)));
return block;
};
auto appendText = [&](const QString& text) { auto appendText = [&](const QString& text) {
if (text.trimmed().isEmpty()) return; if (text.trimmed().isEmpty()) return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Text); MarkdownBlockData block;
block.insert("text", text); block.type = MarkdownBlock::Type::Text;
block.text = text;
blocks.append(block); blocks.append(block);
}; };
auto appendMath = [&](const QString& latex) { auto appendMath = [&](const QString& latex) {
if (latex.trimmed().isEmpty()) return; if (latex.trimmed().isEmpty()) return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Math); MarkdownBlockData block;
block.insert("latex", latex); block.type = MarkdownBlock::Type::Math;
block.latex = latex;
blocks.append(block); blocks.append(block);
}; };
auto appendCode = [&](const QString& language, const QString& code) { auto appendCode = [&](const QString& language, const QString& code) {
QVariantMap block = makeBlock(LlmMarkdown::Type::Code); MarkdownBlockData block;
block.insert("language", language); block.type = MarkdownBlock::Type::Code;
block.insert("code", code); block.language = language;
block.code = code;
blocks.append(block); blocks.append(block);
}; };
auto appendHeading = [&](int level, const QString& text) { auto appendHeading = [&](int level, const QString& text) {
if (text.trimmed().isEmpty()) return; if (text.trimmed().isEmpty()) return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Heading); MarkdownBlockData block;
block.insert("level", level); block.type = MarkdownBlock::Type::Heading;
block.insert("text", text); block.level = level;
block.text = text;
blocks.append(block); blocks.append(block);
}; };
+13 -2
View File
@@ -1,13 +1,24 @@
#pragma once #pragma once
#include "markdownblock.hpp"
#include <QString> #include <QString>
#include <QVariantList> #include <QVector>
namespace ZShell::llm { namespace ZShell::llm {
struct MarkdownBlockData {
MarkdownBlock::Type type = MarkdownBlock::Type::Text;
QString text;
QString latex;
QString code;
QString language;
int level = 0;
};
class MarkdownParser { class MarkdownParser {
public: public:
[[nodiscard]] static QVariantList parse(const QString& source); [[nodiscard]] static QVector<MarkdownBlockData> parse(const QString& source);
}; };
} // namespace ZShell::llm } // namespace ZShell::llm
+40 -17
View File
@@ -1,25 +1,36 @@
#include "message.hpp" #include "message.hpp"
#include "messagemodel.hpp"
#include "session.hpp" #include "session.hpp"
namespace ZShell::llm { 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) { QString ChatMessage::content() const {
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent())) if (m_role == Role::User) return m_content;
return model->session(); const ChatGeneration* generation = activeGeneration();
return nullptr; return generation ? generation->content() : QString();
} }
} // namespace void ChatMessage::setContent(const QString& value) {
if (m_role != Role::User || m_content == value) return;
ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) m_content = value;
: QObject(parent), m_role(role), m_timestamp(timestamp) {} Q_EMIT contentChanged();
}
ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) { 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); m_generations.append(generation);
if (m_active < 0) m_active = static_cast<int>(m_generations.size() - 1); if (m_active < 0) m_active = static_cast<int>(m_generations.size() - 1);
Q_EMIT generationsChanged(); Q_EMIT generationsChanged();
@@ -27,7 +38,7 @@ ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) {
} }
ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) { ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) {
auto* generation = addGeneration(timestamp); ChatGeneration* generation = addGeneration(timestamp);
setActiveInternal(static_cast<int>(m_generations.size() - 1)); setActiveInternal(static_cast<int>(m_generations.size() - 1));
return generation; return generation;
} }
@@ -44,13 +55,17 @@ void ChatMessage::removeGeneration(ChatGeneration* generation) {
m_active = static_cast<int>(m_generations.size() - 1); m_active = static_cast<int>(m_generations.size() - 1);
} }
Q_EMIT generationsChanged(); Q_EMIT generationsChanged();
if (wasActive) Q_EMIT activeGenerationChanged(); if (wasActive) {
Q_EMIT activeGenerationChanged();
Q_EMIT contentChanged();
}
} }
void ChatMessage::setActiveInternal(int index) { void ChatMessage::setActiveInternal(int index) {
if (index < 0 || index >= m_generations.size() || index == m_active) return; if (index < 0 || index >= m_generations.size() || index == m_active) return;
m_active = index; m_active = index;
Q_EMIT activeGenerationChanged(); Q_EMIT activeGenerationChanged();
Q_EMIT contentChanged();
} }
void ChatMessage::setActiveGeneration(int index) { void ChatMessage::setActiveGeneration(int index) {
@@ -72,17 +87,25 @@ void ChatMessage::setAttachments(QList<Attachment*> attachments) {
} }
void ChatMessage::edit(const QString& newContent) { 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); generation->setContent(newContent);
if (auto* session = sessionOf(this)) session->persist(); } else {
return;
}
if (m_session) m_session->persist();
} }
void ChatMessage::retry() { 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() { 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 } // namespace ZShell::llm
+16 -2
View File
@@ -10,13 +10,16 @@
namespace ZShell::llm { namespace ZShell::llm {
class ChatSession;
class ChatMessage : public QObject { class ChatMessage : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT 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(Role role READ role CONSTANT)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(QString content READ content NOTIFY contentChanged)
Q_PROPERTY( Q_PROPERTY(
int generationCount READ generationCount NOTIFY generationsChanged) int generationCount READ generationCount NOTIFY generationsChanged)
Q_PROPERTY( Q_PROPERTY(
@@ -36,10 +39,16 @@ class ChatMessage : public QObject {
enum class Role : int { User = 0, Assistant }; enum class Role : int { User = 0, Assistant };
Q_ENUM(Role) 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]] Role role() const { return m_role; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] QString content() const;
[[nodiscard]] int generationCount() const { [[nodiscard]] int generationCount() const {
return static_cast<int>(m_generations.size()); return static_cast<int>(m_generations.size());
} }
@@ -68,7 +77,10 @@ class ChatMessage : public QObject {
ChatGeneration* appendGeneration(qint64 timestamp); ChatGeneration* appendGeneration(qint64 timestamp);
void removeGeneration(ChatGeneration* generation); void removeGeneration(ChatGeneration* generation);
void setContent(const QString& value);
Q_SIGNALS: Q_SIGNALS:
void contentChanged();
void generationsChanged(); void generationsChanged();
void activeGenerationChanged(); void activeGenerationChanged();
void attachmentsChanged(); void attachmentsChanged();
@@ -76,8 +88,10 @@ class ChatMessage : public QObject {
private: private:
void setActiveInternal(int index); void setActiveInternal(int index);
ChatSession* m_session;
Role m_role; Role m_role;
qint64 m_timestamp; qint64 m_timestamp;
QString m_content;
QList<ChatGeneration*> m_generations; QList<ChatGeneration*> m_generations;
QList<Attachment*> m_attachments; QList<Attachment*> m_attachments;
int m_active = -1; int m_active = -1;
+8 -4
View File
@@ -40,14 +40,18 @@ int ChatMessageModel::rowOf(const ChatMessage* message) const {
ChatMessage* ChatMessageModel::createMessage( ChatMessage* ChatMessageModel::createMessage(
ChatMessage::Role role, qint64 timestamp) { ChatMessage::Role role, qint64 timestamp) {
return new ChatMessage(role, timestamp, this); return new ChatMessage(m_session, role, timestamp, this);
} }
ChatMessage* ChatMessageModel::appendNewest( ChatMessage* ChatMessageModel::appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp) { ChatMessage::Role role, const QString& content, qint64 timestamp) {
auto* message = new ChatMessage(role, timestamp, this); auto* message = createMessage(role, timestamp);
auto* generation = message->addGeneration(timestamp); if (role == ChatMessage::Role::User) {
generation->setContent(content); message->setContent(content);
} else {
auto* generation = message->addGeneration(timestamp);
generation->setContent(content);
}
beginInsertRows(QModelIndex(), 0, 0); beginInsertRows(QModelIndex(), 0, 0);
m_messages.prepend(message); m_messages.prepend(message);
+68
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <QString>
#include <QStringList>
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
+104 -74
View File
@@ -11,16 +11,12 @@ namespace ZShell::llm {
namespace { namespace {
constexpr int kMarkdownRefreshMs = 150; constexpr int kMarkdownRefreshMs = 150;
} } // namespace
LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent)
: QObject(parent), m_type(type), m_timestamp(timestamp) { : QObject(parent), m_type(type), m_timestamp(timestamp) {}
m_markdownTimer.setInterval(kMarkdownRefreshMs);
connect(&m_markdownTimer, &QTimer::timeout, this, [this]() { LlmSegment::~LlmSegment() = default;
if (!m_markdownDirty) return;
if (parseMarkdown()) m_markdownDirty = false;
});
}
qint64 LlmSegment::elapsedMs() const { qint64 LlmSegment::elapsedMs() const {
if (m_startedAt <= 0) return 0; if (m_startedAt <= 0) return 0;
@@ -45,60 +41,7 @@ void LlmSegment::close() {
Q_EMIT runningChanged(); Q_EMIT runningChanged();
Q_EMIT elapsedMsChanged(); Q_EMIT elapsedMsChanged();
} }
if (m_type == Type::Content && m_markdownDirty) { closed();
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();
} }
void LlmSegment::restore(qint64 elapsedMs) { void LlmSegment::restore(qint64 elapsedMs) {
@@ -106,32 +49,119 @@ void LlmSegment::restore(qint64 elapsedMs) {
m_endedAt = m_timestamp + qMax<qint64>(0, elapsedMs); m_endedAt = m_timestamp + qMax<qint64>(0, elapsedMs);
} }
void LlmSegment::scheduleMarkdown() { LlmTextSegment::LlmTextSegment(Type type, qint64 timestamp, QObject* parent)
if (m_type != Type::Content) return; : 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; m_markdownDirty = true;
if (!m_markdownTimer.isActive()) m_markdownTimer.start(); if (!m_markdownTimer.isActive()) m_markdownTimer.start();
} }
bool LlmSegment::parseMarkdown() { bool LlmTextSegment::parseMarkdown() {
if (m_parseInFlight) return false; if (m_parseInFlight) return false;
m_parseInFlight = true; m_parseInFlight = true;
const QString text = m_text; const QString text = m_text;
QThreadPool::globalInstance()->start([this, text]() { QThreadPool::globalInstance()->start([this, text]() {
const QVariantList blocks = MarkdownParser::parse(text); const QVector<MarkdownBlockData> parsed = MarkdownParser::parse(text);
QPointer<LlmSegment> guard(this); QPointer<LlmTextSegment> guard(this);
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
QCoreApplication::instance(), QCoreApplication::instance(),
[guard, blocks]() { [guard, parsed]() {
LlmSegment* seg = guard; LlmTextSegment* segment = guard;
if (!seg) return; if (!segment) return;
seg->m_parseInFlight = false; segment->m_parseInFlight = false;
seg->m_markdown = blocks;
Q_EMIT seg->markdownChanged(); QList<MarkdownBlock*> blocks;
if (seg->m_markdownDirty) seg->parseMarkdown(); blocks.reserve(static_cast<int>(parsed.size()));
for (int i = 0; i < static_cast<int>(parsed.size()); ++i) {
const MarkdownBlockData& data = parsed.at(i);
const QString id = QStringLiteral("%1:%2").arg(i).arg(
static_cast<int>(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); Qt::QueuedConnection);
}); });
return true; 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 } // namespace ZShell::llm
+90 -46
View File
@@ -1,9 +1,11 @@
#pragma once #pragma once
#include "markdownblock.hpp"
#include <QList>
#include <QObject> #include <QObject>
#include <QTimer> #include <QTimer>
#include <QString> #include <QString>
#include <QVariantList>
#include <QtQml> #include <QtQml>
namespace ZShell::llm { namespace ZShell::llm {
@@ -15,79 +17,121 @@ class LlmSegment : public QObject {
Q_PROPERTY(Type type READ type CONSTANT) Q_PROPERTY(Type type READ type CONSTANT)
Q_PROPERTY(qint64 timestamp READ timestamp 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(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged)
public: public:
enum class Type : int { Reasoning = 0, ToolCall, Content }; enum class Type : int { Reasoning = 0, Content, ToolCall };
Q_ENUM(Type) Q_ENUM(Type)
enum class Status : int { None = 0, Running, Success, Error, Pending }; LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
Q_ENUM(Status) ~LlmSegment() override;
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
[[nodiscard]] Type type() const { return m_type; } [[nodiscard]] Type type() const { return m_type; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; } [[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]] bool running() const { return m_running; }
[[nodiscard]] qint64 elapsedMs() const; [[nodiscard]] qint64 elapsedMs() const;
[[nodiscard]] QVariantList markdown() const { return m_markdown; }
void begin(); void begin();
void close(); 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); 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<ZShell::llm::MarkdownBlock*> 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<MarkdownBlock*> markdown() const { return m_blocks; }
Q_SIGNALS: Q_SIGNALS:
void textChanged(); void textChanged();
void markdownChanged(); void markdownChanged();
void nameChanged();
void toolCallIdChanged();
void argumentsChanged();
void resultChanged();
void statusChanged();
void runningChanged();
void elapsedMsChanged();
private: private:
void scheduleMarkdown(); void scheduleMarkdown();
bool parseMarkdown(); bool parseMarkdown();
void closed() override;
Type m_type;
qint64 m_timestamp;
QString m_text; QString m_text;
QString m_name; QList<MarkdownBlock*> m_blocks;
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;
QTimer m_markdownTimer; QTimer m_markdownTimer;
bool m_markdownDirty = false; bool m_markdownDirty = false;
bool m_parseInFlight = 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 } // namespace ZShell::llm
+78 -202
View File
@@ -2,42 +2,29 @@
#include "attachment.hpp" #include "attachment.hpp"
#include "chatstore.hpp" #include "chatstore.hpp"
#include "clipboard.hpp"
#include "llmclient.hpp" #include "llmclient.hpp"
#include "prompts.hpp"
#include "workspace.hpp"
#include <QClipboard>
#include <QDebug> #include <QDebug>
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImage>
#include <QMimeDatabase>
#include <QMimeData>
#include <QMimeType>
#include <QStandardPaths>
#include <QUrl>
#include <QtGlobal>
namespace ZShell::llm { namespace ZShell::llm {
namespace { namespace {
constexpr qint64 kMaxImageBytes = 20 * 1024 * 1024; constexpr int kMaxStoredMessages = 200;
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("…");
}
} // namespace } // namespace
ChatSession::ChatSession(const QString& id, QObject* parent) ChatSession::ChatSession(ChatStore* store, const QString& id, QObject* parent)
: QObject(parent), m_id(id), m_workspace(sandboxDirFor(id)) { : QObject(parent), m_store(store), m_id(id) {
m_model = new ChatMessageModel(this, this); m_model = new ChatMessageModel(this, this);
m_workspace = new Workspace(this, this);
connect( connect(
m_model, m_model,
&QAbstractListModel::rowsInserted, &QAbstractListModel::rowsInserted,
@@ -70,18 +57,27 @@ void ChatSession::setIcon(const QString& value) {
if (m_icon == value) return; if (m_icon == value) return;
m_icon = value; m_icon = value;
Q_EMIT iconChanged(); Q_EMIT iconChanged();
if (m_store) m_store->saveFlags(this);
} }
void ChatSession::setUpdatedAt(qint64 value) { void ChatSession::setUpdatedAt(qint64 value) {
if (m_updatedAt == value) return; if (m_updatedAt == value) return;
m_updatedAt = value; m_updatedAt = value;
Q_EMIT updatedAtChanged(); Q_EMIT updatedAtChanged();
if (m_store) m_store->reposition(this);
} }
void ChatSession::setPinned(bool value) { void ChatSession::setPinned(bool value) {
if (m_pinned == value) return; if (m_pinned == value) return;
m_pinned = value; m_pinned = value;
Q_EMIT pinnedChanged(); 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) { void ChatSession::setCount(int value) {
@@ -90,29 +86,25 @@ void ChatSession::setCount(int value) {
Q_EMIT messageCountChanged(); Q_EMIT messageCountChanged();
} }
void ChatSession::setMeta( void ChatSession::hydrate(
const QString& title, qint64 createdAt, qint64 updatedAt, int messageCount) { const QString& title,
const QString& icon,
qint64 createdAt,
qint64 updatedAt,
int messageCount,
bool pinned) {
m_title = title; m_title = title;
m_icon = icon;
m_createdAt = createdAt; m_createdAt = createdAt;
m_updatedAt = updatedAt; m_updatedAt = updatedAt;
m_messageCount = messageCount; m_messageCount = messageCount;
} m_pinned = pinned;
void ChatSession::setLastTokenCount(int value) {
m_lastTokenCount = value;
}
LlmClient* ChatSession::client() const {
if (auto* store = qobject_cast<ChatStore*>(parent()))
return store->llmClient();
return nullptr;
} }
void ChatSession::ensureLoaded() { void ChatSession::ensureLoaded() {
if (m_loadRequested) return; if (m_loadRequested) return;
m_loadRequested = true; m_loadRequested = true;
if (auto* store = qobject_cast<ChatStore*>(parent())) if (m_store) m_store->loadMessagesInto(this);
store->loadMessagesInto(this);
} }
ChatMessageModel* ChatSession::messagesModel() { ChatMessageModel* ChatSession::messagesModel() {
@@ -126,18 +118,12 @@ void ChatSession::markLoaded() {
Q_EMIT loaded(); Q_EMIT loaded();
} }
bool ChatSession::takeClearPending() {
const bool pending = m_clearPending;
m_clearPending = false;
return pending;
}
void ChatSession::adoptMessages(QList<ChatMessage*> messages) { void ChatSession::adoptMessages(QList<ChatMessage*> messages) {
m_model->loadMessages(std::move(messages)); m_model->loadMessages(std::move(messages));
} }
void ChatSession::persist() { void ChatSession::persist() {
if (auto* store = qobject_cast<ChatStore*>(parent())) store->persist(this); if (m_store) m_store->persist(this);
} }
ChatMessage* ChatSession::appendNewest( ChatMessage* ChatSession::appendNewest(
@@ -154,32 +140,27 @@ void ChatSession::clearMessages() {
} }
void ChatSession::startGeneration(ChatMessage* target) { void ChatSession::startGeneration(ChatMessage* target) {
auto* generation = target->activeGeneration(); ChatGeneration* generation = target->activeGeneration();
auto* clientObject = client(); LlmClient* clientObject = client();
if (generation && clientObject) { if (!generation || !clientObject) return;
if (isLoaded()) {
clientObject->startGeneration(this, generation); if (isLoaded()) {
} else { clientObject->startGeneration(this, generation);
connect( return;
this,
&ChatSession::loaded,
clientObject,
[this, generation, clientObject]() {
clientObject->startGeneration(this, generation);
});
}
} }
connect(
this,
&ChatSession::loaded,
clientObject,
[this, generation, clientObject]() {
clientObject->startGeneration(this, generation);
});
persist(); persist();
} }
QString ChatSession::sandboxDirFor(const QString& sessionId) { LlmClient* ChatSession::client() const {
const QString state = return m_store ? m_store->client() : nullptr;
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation);
return state + QStringLiteral("/zshell/sandboxes/") + sessionId;
}
void ChatSession::ensureWorkspace() {
QDir().mkpath(m_workspace);
} }
void ChatSession::setAttachmentError(const QString& value) { void ChatSession::setAttachmentError(const QString& value) {
@@ -189,125 +170,34 @@ void ChatSession::setAttachmentError(const QString& value) {
} }
QVariantList ChatSession::addAttachments(const QStringList& paths) { QVariantList ChatSession::addAttachments(const QStringList& paths) {
ensureWorkspace(); if (!m_workspace) return {};
setAttachmentError(QString()); setAttachmentError(QString());
QVariantList result; QVariantList result;
for (const QString& rawPath : paths) { QString error;
const QFileInfo info(rawPath); for (Attachment* attachment : m_workspace->importFiles(paths, error))
if (!info.exists() || !info.isFile()) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": file not found or not a file";
setAttachmentError(
QStringLiteral("File not found: %1").arg(info.fileName()));
continue;
}
const QString mimeType = QMimeDatabase().mimeTypeForFile(info).name();
const bool isImage =
mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive);
const qint64 cap = isImage ? kMaxImageBytes : kMaxFileBytes;
if (info.size() > cap) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": too large (" << info.size() << "bytes, cap" << cap
<< ")";
setAttachmentError(
QStringLiteral("File too large: %1").arg(info.fileName()));
continue;
}
QString destination = m_workspace + QLatin1Char('/') + info.fileName();
int suffix = 1;
while (QFile::exists(destination)) {
const QString extension = info.suffix().isEmpty()
? QString()
: QLatin1Char('.') + info.suffix();
destination = m_workspace + QLatin1Char('/') +
info.completeBaseName() +
QStringLiteral("_%1%2").arg(suffix).arg(extension);
++suffix;
}
if (!QFile::copy(info.absoluteFilePath(), destination)) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": copy to" << destination << "failed";
setAttachmentError(
QStringLiteral("Could not copy file: %1").arg(info.fileName()));
continue;
}
auto* attachment = new Attachment(
info.fileName(),
destination,
isImage ? Attachment::Kind::Image : Attachment::Kind::File,
mimeType,
info.size(),
this);
result.append(QVariant::fromValue(attachment)); result.append(QVariant::fromValue(attachment));
} if (!error.isEmpty()) setAttachmentError(error);
return result; return result;
} }
bool ChatSession::clipboardHasAttachment() { bool ChatSession::clipboardHasAttachment() {
QClipboard* clipboard = QGuiApplication::clipboard(); return clipboard::hasAttachment();
if (!clipboard) return false;
const QMimeData* data = clipboard->mimeData();
if (!data) return false;
if (data->hasImage()) return true;
for (const QUrl& url : data->urls())
if (url.isLocalFile()) return true;
return false;
} }
QVariantList ChatSession::pasteAttachments() { QVariantList ChatSession::pasteAttachments() {
QClipboard* clipboard = QGuiApplication::clipboard(); QString error;
const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr; const QStringList paths = clipboard::attachmentPaths(error);
if (!data) {
qWarning() << "ChatSession: pasteAttachments: no clipboard data";
return {};
}
qInfo() << "ChatSession: pasteAttachments: formats =" << data->formats()
<< "hasImage =" << data->hasImage()
<< "hasUrls =" << data->hasUrls() << "hasText =" << data->hasText();
QStringList paths;
QString tempImagePath;
if (data->hasImage()) {
const QImage image = clipboard->image();
qInfo() << "ChatSession: pasteAttachments: image"
<< (image.isNull() ? "null"
: QString::number(image.width()) + "x" +
QString::number(image.height()));
if (image.isNull()) {
setAttachmentError(
QStringLiteral("Clipboard image could not be decoded"));
return {};
}
tempImagePath =
QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png")
.arg(QDateTime::currentMSecsSinceEpoch());
if (!image.save(tempImagePath)) {
qWarning() << "ChatSession: pasteAttachments: saving image to"
<< tempImagePath << "failed";
setAttachmentError(
QStringLiteral("Could not save the pasted image"));
return {};
}
paths.append(tempImagePath);
} else if (data->hasUrls()) {
for (const QUrl& url : data->urls()) {
qInfo() << "ChatSession: pasteAttachments: url =" << url.toString()
<< "local =" << url.isLocalFile();
if (url.isLocalFile()) paths.append(url.toLocalFile());
}
}
if (paths.isEmpty()) { if (paths.isEmpty()) {
if (data->hasImage() || data->hasUrls()) if (!error.isEmpty()) setAttachmentError(error);
setAttachmentError(QStringLiteral(
"Nothing usable on the clipboard (expected an image "
"or copied files)"));
return {}; return {};
} }
const QVariantList attachments = addAttachments(paths); const QVariantList attachments = addAttachments(paths);
if (!tempImagePath.isEmpty()) QFile::remove(tempImagePath);
qInfo() << "ChatSession: pasteAttachments: result =" << attachments.size() for (const QString& path : paths)
<< "attachment(s)"; if (path.startsWith(QDir::tempPath())) QFile::remove(path);
return attachments; return attachments;
} }
@@ -324,55 +214,52 @@ void ChatSession::sendMessage(
attachmentList.append(attachment); attachmentList.append(attachment);
const QString trimmed = text.trimmed(); const QString trimmed = text.trimmed();
if (trimmed.isEmpty() && attachmentList.isEmpty()) return; if (trimmed.isEmpty() && attachmentList.isEmpty()) return;
if (auto* clientObject = client()) { if (client() && client()->busy()) return;
if (clientObject->busy()) return;
} if (!m_committed && m_store) m_store->commitDraft(this);
const qint64 now = QDateTime::currentMSecsSinceEpoch(); 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); 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)); m_model->removeMessage(m_model->at(m_model->rowCount() - 1));
if (m_title.isEmpty()) { if (m_title.isEmpty()) {
QString titleSource = trimmed; QString titleSource = trimmed;
if (titleSource.isEmpty() && !attachmentList.isEmpty()) if (titleSource.isEmpty() && !attachmentList.isEmpty())
titleSource = attachmentList.first()->name(); titleSource = attachmentList.first()->name();
m_title = titleFrom(titleSource); m_title = prompts::cleanTitle(titleSource);
Q_EMIT titleChanged(); Q_EMIT titleChanged();
qInfo() << "ChatSession:" << m_id << "new conversation," persist();
<< "fallback title" << m_title
<< "- requesting generated title and icon"; if (LlmClient* clientObject = client()) {
if (auto* clientObject = client()) {
clientObject->requestTitle(this, titleSource); clientObject->requestTitle(this, titleSource);
clientObject->requestIcon(this, titleSource); clientObject->requestIcon(this, titleSource);
} }
} }
auto* assistant =
ChatMessage* assistant =
appendNewest(ChatMessage::Role::Assistant, QString(), now); appendNewest(ChatMessage::Role::Assistant, QString(), now);
startGeneration(assistant); startGeneration(assistant);
} }
void ChatSession::retry(ChatMessage* target) { void ChatSession::retry(ChatMessage* target) {
if (!target || target->role() != ChatMessage::Role::Assistant) return; if (!target || target->role() != ChatMessage::Role::Assistant) return;
if (auto* clientObject = client()) { if (client() && client()->busy()) return;
if (clientObject->busy()) return;
}
const int row = m_model->rowOf(target); const int row = m_model->rowOf(target);
if (row < 0) return; if (row < 0) return;
m_model->removeRange(0, row - 1); m_model->removeRange(0, row - 1);
if (m_model->rowCount() < 2) return; if (m_model->rowCount() < 2) return;
const qint64 now = QDateTime::currentMSecsSinceEpoch(); target->appendGeneration(QDateTime::currentMSecsSinceEpoch());
target->appendGeneration(now);
startGeneration(target); startGeneration(target);
} }
void ChatSession::continueFrom(ChatMessage* message) { void ChatSession::continueFrom(ChatMessage* message) {
if (!message) return; if (!message) return;
if (auto* clientObject = client()) { if (client() && client()->busy()) return;
if (clientObject->busy()) return;
}
const int row = m_model->rowOf(message); const int row = m_model->rowOf(message);
if (row < 0) return; if (row < 0) return;
@@ -384,26 +271,15 @@ void ChatSession::continueFrom(ChatMessage* message) {
message->appendGeneration(now); message->appendGeneration(now);
startGeneration(message); startGeneration(message);
} else { } else {
auto* assistant = ChatMessage* assistant =
appendNewest(ChatMessage::Role::Assistant, QString(), now); appendNewest(ChatMessage::Role::Assistant, QString(), now);
startGeneration(assistant); startGeneration(assistant);
} }
} }
void ChatSession::clear() { void ChatSession::clear() {
if (auto* clientObject = client()) { if (!m_store) return;
if (clientObject->busy() && clientObject->streamingSession() == this) { m_store->requestClear(this);
clientObject->clearOnFinish(this);
clientObject->stop();
return;
}
}
if (!isLoaded()) {
m_clearPending = true;
return;
}
m_model->clear();
persist();
} }
} // namespace ZShell::llm } // namespace ZShell::llm
+26 -22
View File
@@ -1,7 +1,7 @@
#pragma once #pragma once
#include "messagemodel.hpp"
#include "message.hpp" #include "message.hpp"
#include "messagemodel.hpp"
#include <QDateTime> #include <QDateTime>
#include <QObject> #include <QObject>
@@ -12,30 +12,32 @@
namespace ZShell::llm { namespace ZShell::llm {
class ChatStore;
class LlmClient; class LlmClient;
class Workspace;
class ChatSession : public QObject { class ChatSession : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT 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 id READ id CONSTANT)
Q_PROPERTY(QString title READ title WRITE setTitle NOTIFY titleChanged) 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 createdAt READ createdAt CONSTANT)
Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) 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(bool loaded READ isLoaded NOTIFY loaded)
Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged)
Q_PROPERTY(int usageTokens READ usageTokens NOTIFY usageTokensChanged)
Q_PROPERTY( Q_PROPERTY(
ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT) ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT)
Q_PROPERTY(QString workspace READ workspacePath CONSTANT)
Q_PROPERTY( Q_PROPERTY(
QString attachmentError READ attachmentError NOTIFY QString attachmentError READ attachmentError NOTIFY
attachmentErrorChanged) attachmentErrorChanged)
public: 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 id() const { return m_id; }
[[nodiscard]] QString title() const { return m_title; } [[nodiscard]] QString title() const { return m_title; }
@@ -50,34 +52,33 @@ class ChatSession : public QObject {
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
[[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] bool pinned() const { return m_pinned; }
[[nodiscard]] int messageCount() const { return m_messageCount; } [[nodiscard]] int messageCount() const { return m_messageCount; }
[[nodiscard]] int usageTokens() const { return m_usageTokens; }
[[nodiscard]] ChatMessageModel* messagesModel(); [[nodiscard]] ChatMessageModel* messagesModel();
void ensureLoaded(); void ensureLoaded();
[[nodiscard]] bool isLoaded() const { return m_loaded; } [[nodiscard]] bool isLoaded() const { return m_loaded; }
[[nodiscard]] ChatMessageModel* model() const { return m_model; }
void markLoaded(); 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; } [[nodiscard]] QString attachmentError() const { return m_attachmentError; }
void setAttachmentError(const QString& value);
static QString sandboxDirFor(const QString& sessionId); [[nodiscard]] ChatStore* store() const { return m_store; }
void ensureWorkspace(); [[nodiscard]] LlmClient* client() const;
[[nodiscard]] Workspace* workspace() const { return m_workspace; }
void setTitle(const QString& value); void setTitle(const QString& value);
void setIcon(const QString& value); void setIcon(const QString& value);
void setUpdatedAt(qint64 value); void setUpdatedAt(qint64 value);
void setPinned(bool value); void setPinned(bool value);
void setUsageTokens(int value);
void setMeta( void hydrate(
const QString& title, const QString& title,
const QString& icon,
qint64 createdAt, qint64 createdAt,
qint64 updatedAt, 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<ChatMessage*> messages); void adoptMessages(QList<ChatMessage*> messages);
void persist(); void persist();
@@ -101,6 +102,7 @@ class ChatSession : public QObject {
void pinnedChanged(); void pinnedChanged();
void messageCountChanged(); void messageCountChanged();
void loaded(); void loaded();
void usageTokensChanged();
void attachmentErrorChanged(); void attachmentErrorChanged();
private: private:
@@ -109,20 +111,22 @@ class ChatSession : public QObject {
ChatMessage::Role role, const QString& content, qint64 timestamp); ChatMessage::Role role, const QString& content, qint64 timestamp);
void startGeneration(ChatMessage* target); void startGeneration(ChatMessage* target);
void setCount(int value); void setCount(int value);
void setAttachmentError(const QString& value);
ChatStore* m_store;
QString m_id; QString m_id;
bool m_committed = false;
QString m_title; QString m_title;
QString m_icon; QString m_icon;
qint64 m_createdAt = 0; qint64 m_createdAt = 0;
qint64 m_updatedAt = 0; qint64 m_updatedAt = 0;
bool m_pinned = false; bool m_pinned = false;
int m_messageCount = 0; int m_messageCount = 0;
int m_usageTokens = 0;
ChatMessageModel* m_model = nullptr; ChatMessageModel* m_model = nullptr;
Workspace* m_workspace = nullptr;
bool m_loadRequested = false; bool m_loadRequested = false;
bool m_loaded = false; bool m_loaded = false;
bool m_clearPending = false;
int m_lastTokenCount = 0;
QString m_workspace;
QString m_attachmentError; QString m_attachmentError;
}; };
+23 -46
View File
@@ -1,8 +1,5 @@
#include "shelltool.hpp" #include "shelltool.hpp"
#include "llmclient.hpp"
#include "session.hpp"
#include <QDir> #include <QDir>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
@@ -13,24 +10,8 @@
namespace ZShell::llm { namespace ZShell::llm {
namespace { ShellTool::ShellTool(ToolRegistry* registry, QObject* parent)
: LlmTool(registry, parent) {}
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() { ShellTool::~ShellTool() {
for (auto* job : m_jobs) { for (auto* job : m_jobs) {
@@ -125,27 +106,29 @@ QStringList ShellTool::sandboxArguments(const QString& workspace) {
return args; return args;
} }
bool ShellTool::available(const ToolContext& context) const {
return registry()->sandboxEnabled() && !context.workspace.isEmpty();
}
void ShellTool::execute( void ShellTool::execute(
const QString& toolCallId, const ToolContext& context, const QJsonObject& args, ToolDone done) {
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
Q_UNUSED(toolCallId);
const QString command = args[QStringLiteral("command")].toString(); const QString command = args[QStringLiteral("command")].toString();
if (command.trimmed().isEmpty()) { if (command.trimmed().isEmpty()) {
done(makeError(QStringLiteral("Missing required argument 'command'"))); done({false, QStringLiteral("Missing required argument 'command'")});
return; return;
} }
LlmClient* client = m_client; if (!registry()->sandboxEnabled()) {
if (!client || !client->sandboxEnabled()) { done(
done(makeError(QStringLiteral( {false,
"The file sandbox is disabled, so no commands can run"))); QStringLiteral(
"The file sandbox is disabled, so no commands can run")});
return; return;
} }
const QString workspace = client->activeWorkspace(); const QString workspace = context.workspace;
if (workspace.isEmpty()) { if (workspace.isEmpty()) {
done(makeError( done(
QStringLiteral("No sandbox available: no chat session is active"))); {false,
QStringLiteral("No sandbox available: no chat session is active")});
return; return;
} }
QDir().mkpath(workspace); QDir().mkpath(workspace);
@@ -156,7 +139,7 @@ void ShellTool::execute(
MaxTimeoutSeconds); MaxTimeoutSeconds);
auto* job = new Job; auto* job = new Job;
job->timeoutSeconds = timeoutSeconds; job->timeoutSeconds = timeoutSeconds;
job->outputBudgetChars = outputBudgetChars; job->outputBudgetChars = context.outputBudgetChars;
job->done = std::move(done); job->done = std::move(done);
m_jobs.append(job); m_jobs.append(job);
@@ -230,9 +213,8 @@ void ShellTool::finishJob(Job* job) {
"Command timed out after %1 seconds and was killed.\n") "Command timed out after %1 seconds and was killed.\n")
.arg(job->timeoutSeconds); .arg(job->timeoutSeconds);
int budget = inlineBudgetChars(); int budget = job->outputBudgetChars > 0 ? job->outputBudgetChars
if (job->outputBudgetChars > 0) : registry()->outputBudgetChars(1);
budget = qMin(budget, job->outputBudgetChars);
if (output.size() > budget) if (output.size() > budget)
output = output.left(budget) + QStringLiteral( output = output.left(budget) + QStringLiteral(
"\n[output truncated: %1 characters " "\n[output truncated: %1 characters "
@@ -242,17 +224,12 @@ void ShellTool::finishJob(Job* job) {
const QString result = const QString result =
note + QStringLiteral("exit code: %1\n").arg(exitCode) + note + QStringLiteral("exit code: %1\n").arg(exitCode) +
(output.isEmpty() ? QStringLiteral("(no output)") : output); (output.isEmpty() ? QStringLiteral("(no output)") : output);
const bool ok = !job->cancelled && !job->timedOut && exitCode == 0;
QJsonObject resultObject; ToolDone done = std::move(job->done);
if (!job->cancelled && !job->timedOut && exitCode == 0)
resultObject = makeOutput(result);
else
resultObject = makeError(result);
std::function<void(const QJsonObject&)> done = std::move(job->done);
if (process) process->deleteLater(); if (process) process->deleteLater();
delete job; delete job;
if (done) done(resultObject); if (done) done({ok, result});
} }
} // namespace ZShell::llm } // namespace ZShell::llm
+5 -8
View File
@@ -11,23 +11,21 @@ class QTimer;
namespace ZShell::llm { namespace ZShell::llm {
class LlmClient;
class ShellTool : public LlmTool { class ShellTool : public LlmTool {
Q_OBJECT Q_OBJECT
public: public:
explicit ShellTool(LlmClient* client, QObject* parent = nullptr); explicit ShellTool(ToolRegistry* registry, QObject* parent = nullptr);
~ShellTool() override; ~ShellTool() override;
QString name() const override; QString name() const override;
QString description() const override; QString description() const override;
QJsonObject parameters() const override; QJsonObject parameters() const override;
bool available(const ToolContext& context) const override;
void execute( void execute(
const QString& toolCallId, const ToolContext& context,
const QJsonObject& args, const QJsonObject& args,
int outputBudgetChars, ToolDone done) override;
std::function<void(const QJsonObject& result)> done) override;
void cancel() override; void cancel() override;
static constexpr int DefaultTimeoutSeconds = 60; static constexpr int DefaultTimeoutSeconds = 60;
@@ -39,7 +37,7 @@ class ShellTool : public LlmTool {
QPointer<QTimer> timer; QPointer<QTimer> timer;
int timeoutSeconds = DefaultTimeoutSeconds; int timeoutSeconds = DefaultTimeoutSeconds;
int outputBudgetChars = 0; int outputBudgetChars = 0;
std::function<void(const QJsonObject&)> done; ToolDone done;
QString startError; QString startError;
bool timedOut = false; bool timedOut = false;
bool cancelled = false; bool cancelled = false;
@@ -50,7 +48,6 @@ class ShellTool : public LlmTool {
void finishJob(Job* job); void finishJob(Job* job);
QPointer<LlmClient> m_client;
QList<Job*> m_jobs; QList<Job*> m_jobs;
}; };
+29 -26
View File
@@ -2,20 +2,14 @@
namespace ZShell::llm { namespace ZShell::llm {
LlmTool::LlmTool(QObject* parent) : QObject(parent) {} LlmTool::LlmTool(ToolRegistry* registry, QObject* parent)
: QObject(parent), m_registry(registry) {}
LlmTool::~LlmTool() = default; LlmTool::~LlmTool() = default;
void LlmTool::setContextSize(int value) { bool LlmTool::available(const ToolContext& context) const {
if (m_contextSize == value) return; Q_UNUSED(context);
m_contextSize = value; return true;
}
int LlmTool::inlineBudgetChars() const {
if (m_contextSize <= 0) return DefaultInlineChars;
const int tokens =
qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken);
return tokens * CharsPerToken;
} }
void LlmTool::cancel() {} void LlmTool::cancel() {}
@@ -33,30 +27,22 @@ QJsonObject LlmTool::specification() const {
ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {} 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) { void ToolRegistry::registerTool(LlmTool* tool) {
if (!tool || m_tools.contains(tool)) return; if (!tool || m_tools.contains(tool)) return;
tool->setParent(this); tool->setParent(this);
tool->setContextSize(m_contextSize);
m_tools.append(tool); m_tools.append(tool);
} }
LlmTool* ToolRegistry::tool(const QString& name) const { LlmTool* ToolRegistry::tool(const QString& name) const {
for (const auto* tool : m_tools) for (auto* tool : m_tools)
if (tool->name() == name) return const_cast<LlmTool*>(tool); if (tool->name() == name) return tool;
return nullptr; return nullptr;
} }
QJsonArray ToolRegistry::specifications() const { QJsonArray ToolRegistry::specifications(const ToolContext& context) const {
if (!m_enabled) return {};
QJsonArray specs; QJsonArray specs;
for (const auto* tool : m_tools) for (auto* tool : m_tools)
specs.append(tool->specification()); if (tool->available(context)) specs.append(tool->specification());
return specs; return specs;
} }
@@ -68,8 +54,25 @@ void ToolRegistry::cancelAll() {
void ToolRegistry::setContextSize(int value) { void ToolRegistry::setContextSize(int value) {
if (m_contextSize == value) return; if (m_contextSize == value) return;
m_contextSize = value; 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 } // namespace ZShell::llm
+37 -22
View File
@@ -10,61 +10,76 @@
namespace ZShell::llm { namespace ZShell::llm {
struct ToolResult {
bool ok = false;
QString text;
};
struct ToolContext {
QString toolCallId;
QString workspace;
int outputBudgetChars = 0;
};
using ToolDone = std::function<void(const ToolResult&)>;
class ToolRegistry;
class LlmTool : public QObject { class LlmTool : public QObject {
Q_OBJECT Q_OBJECT
public: public:
explicit LlmTool(QObject* parent = nullptr); explicit LlmTool(ToolRegistry* registry, QObject* parent = nullptr);
~LlmTool() override; ~LlmTool() override;
[[nodiscard]] virtual QString name() const = 0; [[nodiscard]] virtual QString name() const = 0;
[[nodiscard]] virtual QString description() const = 0; [[nodiscard]] virtual QString description() const = 0;
[[nodiscard]] virtual QJsonObject parameters() const = 0; [[nodiscard]] virtual QJsonObject parameters() const = 0;
[[nodiscard]] virtual bool available(const ToolContext& context) const;
virtual void execute( virtual void execute(
const QString& toolCallId, const ToolContext& context, const QJsonObject& args, ToolDone done) = 0;
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) = 0;
virtual void cancel(); virtual void cancel();
[[nodiscard]] QJsonObject specification() const; [[nodiscard]] QJsonObject specification() const;
[[nodiscard]] int contextSize() const { return m_contextSize; } [[nodiscard]] ToolRegistry* registry() const { return m_registry; }
void setContextSize(int value);
[[nodiscard]] int inlineBudgetChars() const; private:
ToolRegistry* m_registry;
static constexpr int CharsPerToken = 4;
static constexpr int DefaultInlineChars = 64 * 1024;
protected:
int m_contextSize = 0;
}; };
class ToolRegistry : public QObject { class ToolRegistry : public QObject {
Q_OBJECT Q_OBJECT
Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged)
public: public:
explicit ToolRegistry(QObject* parent = nullptr); explicit ToolRegistry(QObject* parent = nullptr);
[[nodiscard]] bool enabled() const { return m_enabled; }
void setEnabled(bool value);
void registerTool(LlmTool* tool); void registerTool(LlmTool* tool);
[[nodiscard]] LlmTool* tool(const QString& name) const; [[nodiscard]] LlmTool* tool(const QString& name) const;
[[nodiscard]] QJsonArray specifications() const; [[nodiscard]] QList<LlmTool*> tools() const { return m_tools; }
[[nodiscard]] QJsonArray specifications(const ToolContext& context) const;
void cancelAll(); 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); void setContextSize(int value);
Q_SIGNALS: [[nodiscard]] int outputBudgetChars(int callCount) const;
void enabledChanged();
static constexpr int CharsPerToken = 4;
static constexpr int DefaultInlineChars = 64 * 1024;
static constexpr int MinBudgetTokens = 512;
private: private:
bool m_enabled = true; bool m_enabled = true;
bool m_sandbox = true;
int m_contextSize = 0; int m_contextSize = 0;
QList<LlmTool*> m_tools; QList<LlmTool*> m_tools;
}; };
+391
View File
@@ -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 <QFile>
#include <QSet>
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<QJsonObject>& unit, int budgetTokens) {
static const QStringList kTextKeys = {
QStringLiteral("content"), QStringLiteral("reasoning_content")};
for (int pass = 0; pass < 8; ++pass) {
int tokens = 0;
for (const QJsonObject& message : unit)
tokens += estimateMessageTokens(message);
if (tokens <= budgetTokens) return;
qsizetype bestMessage = -1;
QString bestKey;
qsizetype bestLength = 0;
for (qsizetype i = 0; i < unit.size(); ++i) {
const QJsonObject message = unit.at(i);
for (const QString& key : kTextKeys) {
const QJsonValue value = message.value(key);
if (value.isString() && value.toString().size() > bestLength) {
bestMessage = i;
bestKey = key;
bestLength = value.toString().size();
}
}
}
if (bestMessage < 0) return;
QString text = unit.at(bestMessage).value(bestKey).toString();
text.truncate(qMax<qsizetype>(32, text.size() * 3 / 4));
const QString marker = QStringLiteral("\n[…]");
if (!text.endsWith(marker)) text += marker;
unit[bestMessage][bestKey] = text;
}
}
} // namespace
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<QString> seenPaths;
for (int row = 0; row < model->rowCount(); ++row) {
const auto* message = model->at(row);
if (message->role() != ChatMessage::Role::User) continue;
for (const auto* attachment : message->attachments()) {
if (attachment->kind() != Attachment::Kind::File) continue;
const QString relativePath =
attachment->path().mid(workspace.size() + 1);
if (seenPaths.contains(relativePath)) continue;
seenPaths.insert(relativePath);
files.append(QStringLiteral("- %1 (available as %2)")
.arg(attachment->name(), relativePath));
}
}
if (files.isEmpty()) return;
QJsonObject preamble;
preamble[QStringLiteral("role")] = QStringLiteral("system");
preamble[QStringLiteral("content")] =
prompts::sandboxPreamble(workspace, files);
messages.prepend(preamble);
}
QJsonArray Turn::contextMessages() const {
const auto* model = m_session->messagesModel();
const int stopBeforeRow = targetRow();
QList<QList<QJsonObject>> 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<QJsonObject> unit;
QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user");
const QString text = message->content();
const QList<Attachment*> attachments = message->attachments();
if (attachments.isEmpty()) {
user[QStringLiteral("content")] = text;
} else {
QJsonArray parts;
if (!text.isEmpty()) {
QJsonObject textPart;
textPart[QStringLiteral("type")] = QStringLiteral("text");
textPart[QStringLiteral("text")] = text;
parts.append(textPart);
}
for (const auto* attachment : attachments) {
if (attachment->kind() != Attachment::Kind::Image) continue;
QFile file(attachment->path());
if (!file.open(QIODevice::ReadOnly)) continue;
const QByteArray base64 = file.readAll().toBase64();
file.close();
if (base64.isEmpty()) continue;
QJsonObject imageUrl;
imageUrl[QStringLiteral("url")] =
QStringLiteral("data:%1;base64,%2")
.arg(attachment->mimeType())
.arg(QString::fromLatin1(base64));
QJsonObject imagePart;
imagePart[QStringLiteral("type")] =
QLatin1String("image_url");
imagePart[QStringLiteral("image_url")] = imageUrl;
parts.append(imagePart);
}
if (parts.isEmpty())
user[QStringLiteral("content")] = text;
else
user[QStringLiteral("content")] = parts;
}
unit.append(user);
units.append(unit);
continue;
}
const ChatGeneration* generation = message->activeGeneration();
if (!generation) continue;
const QList<LlmToolCall*> 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<QJsonObject> 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<QJsonObject>& 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<QJsonObject> single = units.first();
shrinkUnitToBudget(single, budget);
units[0] = single;
}
}
QJsonArray messages;
for (const QList<QJsonObject>& 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
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include "generation.hpp"
#include "segment.hpp"
#include "tool.hpp"
#include <QJsonArray>
#include <QJsonObject>
#include <QPointer>
#include <QString>
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<LlmToolCall> segment;
bool seen = false;
};
QList<Call>& calls() { return m_calls; }
[[nodiscard]] const QList<Call>& calls() const { return m_calls; }
QList<ToolResult>& results() { return m_results; }
[[nodiscard]] const QList<ToolResult>& 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<Call> m_calls;
QList<ToolResult> m_results;
qsizetype m_contentMark = 0;
qsizetype m_reasoningMark = 0;
};
} // namespace ZShell::llm
+37 -42
View File
@@ -10,30 +10,16 @@
namespace ZShell::llm { namespace ZShell::llm {
const QString WebFetchTool::StoragePath =
QStringLiteral("/tmp/zshell-llm/webfetch");
namespace { namespace {
const char* kUserAgent = const char* kUserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/143.0.0.0 Safari/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 } // namespace
WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {} WebFetchTool::WebFetchTool(ToolRegistry* registry, QObject* parent)
: LlmTool(registry, parent) {}
WebFetchTool::~WebFetchTool() { WebFetchTool::~WebFetchTool() {
for (auto* job : m_jobs) { 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 " "Fetch content from an HTTP or HTTPS URL and return it as plain "
"text or raw HTML. HTML pages are reduced to their visible text " "text or raw HTML. HTML pages are reduced to their visible text "
"by default. Only a limited amount of content is returned inline; " "by default. Only a limited amount of content is returned inline; "
"the full output of every call is saved under " "the full output of every call is saved in the sandbox directory "
"/tmp/zshell-llm/webfetch/, where it can be paged through with " "of the conversation, where it can be paged through with the "
"the readfile tool. This tool is read-only."); "readfile tool. This tool is read-only.");
} }
QJsonObject WebFetchTool::parameters() const { QJsonObject WebFetchTool::parameters() const {
@@ -94,7 +80,7 @@ QJsonObject WebFetchTool::parameters() const {
return schema; return schema;
} }
void WebFetchTool::completeJob(Job* job, QJsonObject result) { void WebFetchTool::completeJob(Job* job, ToolResult result) {
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, job, result = std::move(result)]() mutable { [this, job, result = std::move(result)]() mutable {
@@ -110,19 +96,23 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) {
} }
void WebFetchTool::execute( void WebFetchTool::execute(
const QString& toolCallId, const ToolContext& context, const QJsonObject& args, ToolDone done) {
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
auto* job = new Job; auto* job = new Job;
job->done = std::move(done); job->done = std::move(done);
job->toolCallId = toolCallId; job->toolCallId = context.toolCallId;
job->budgetChars = outputBudgetChars; 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); m_jobs.append(job);
auto fail = [this, job](const QString& message) { auto fail = [this, job](const QString& message) {
job->timer->stop(); job->timer->stop();
completeJob(job, makeError(message)); completeJob(job, {false, message});
}; };
const QString urlText = args[QStringLiteral("url")].toString().trimmed(); const QString urlText = args[QStringLiteral("url")].toString().trimmed();
@@ -194,23 +184,27 @@ void WebFetchTool::execute(
if (job->tooLarge) { if (job->tooLarge) {
completeJob( completeJob(
job, job,
makeError(QStringLiteral( ToolResult{
"Response too large (exceeds the 5 MB " false,
"limit"))); QStringLiteral(
"Response too large (exceeds the 5 MB "
"limit")});
return; return;
} }
if (error != QNetworkReply::NoError) { if (error != QNetworkReply::NoError) {
completeJob( completeJob(
job, job,
makeError( ToolResult{
QStringLiteral("Request failed: %1").arg(errorString))); false,
QStringLiteral("Request failed: %1").arg(errorString)});
return; return;
} }
if (status < 200 || status >= 300) { if (status < 200 || status >= 300) {
completeJob( completeJob(
job, job,
makeError( ToolResult{
QStringLiteral("Server returned status %1").arg(status))); false,
QStringLiteral("Server returned status %1").arg(status)});
return; return;
} }
@@ -220,9 +214,10 @@ void WebFetchTool::execute(
if (mime.startsWith(QLatin1String("image/"))) { if (mime.startsWith(QLatin1String("image/"))) {
completeJob( completeJob(
job, job,
makeError( ToolResult{
false,
QStringLiteral("Unsupported fetched image content type: %1") QStringLiteral("Unsupported fetched image content type: %1")
.arg(mime))); .arg(mime)});
return; return;
} }
const bool textual = const bool textual =
@@ -236,9 +231,10 @@ void WebFetchTool::execute(
if (!textual) { if (!textual) {
completeJob( completeJob(
job, job,
makeError( ToolResult{
false,
QStringLiteral("Unsupported fetched file content type: %1") QStringLiteral("Unsupported fetched file content type: %1")
.arg(mime))); .arg(mime)});
return; return;
} }
@@ -249,9 +245,8 @@ void WebFetchTool::execute(
const QString savedPath = saveToFile(*job, content, mime); const QString savedPath = saveToFile(*job, content, mime);
const int budget = job->budgetChars;
QString output = content; QString output = content;
int budget = inlineBudgetChars();
if (job->budgetChars > 0) budget = qMin(budget, job->budgetChars);
if (content.size() > budget) { if (content.size() > budget) {
output = content.left(budget); output = content.left(budget);
const QString note = const QString note =
@@ -269,13 +264,13 @@ void WebFetchTool::execute(
". The remaining content is not " ". The remaining content is not "
"available."); "available.");
} }
completeJob(job, makeOutput(output)); completeJob(job, ToolResult{true, output});
}); });
} }
QString WebFetchTool::saveToFile( QString WebFetchTool::saveToFile(
const Job& job, const QString& content, const QString& mime) const { 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(); if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) return QString();
QString fileName; QString fileName;
+6 -7
View File
@@ -20,19 +20,17 @@ class WebFetchTool : public LlmTool {
static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
static constexpr int DefaultTimeoutSeconds = 30; static constexpr int DefaultTimeoutSeconds = 30;
static constexpr int MaxTimeoutSeconds = 120; static constexpr int MaxTimeoutSeconds = 120;
static const QString StoragePath;
explicit WebFetchTool(QObject* parent = nullptr); explicit WebFetchTool(ToolRegistry* registry, QObject* parent = nullptr);
~WebFetchTool() override; ~WebFetchTool() override;
QString name() const override; QString name() const override;
QString description() const override; QString description() const override;
QJsonObject parameters() const override; QJsonObject parameters() const override;
void execute( void execute(
const QString& toolCallId, const ToolContext& context,
const QJsonObject& args, const QJsonObject& args,
int outputBudgetChars, ToolDone done) override;
std::function<void(const QJsonObject& result)> done) override;
void cancel() override; void cancel() override;
static QString extractTextFromHtml(const QString& html); static QString extractTextFromHtml(const QString& html);
@@ -46,11 +44,12 @@ class WebFetchTool : public LlmTool {
bool tooLarge = false; bool tooLarge = false;
QString format; QString format;
QString toolCallId; QString toolCallId;
QString storagePath;
int budgetChars = 0; int budgetChars = 0;
std::function<void(const QJsonObject& result)> done; ToolDone done;
}; };
void completeJob(Job* job, QJsonObject result); void completeJob(Job* job, ToolResult result);
QString saveToFile( QString saveToFile(
const Job& job, const QString& content, const QString& mime) const; const Job& job, const QString& content, const QString& mime) const;
+89
View File
@@ -0,0 +1,89 @@
#include "workspace.hpp"
#include "session.hpp"
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMimeDatabase>
#include <QStandardPaths>
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<Attachment*> Workspace::importFiles(
const QStringList& paths, QString& error) {
error.clear();
if (!ensure()) {
error = QStringLiteral("Could not create the sandbox directory");
return {};
}
QMimeDatabase mimeDatabase;
QList<Attachment*> 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
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "attachment.hpp"
#include <QObject>
#include <QString>
#include <QStringList>
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<Attachment*> 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