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