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

This commit is contained in:
2026-10-18 12:58:38 +02:00
parent fc81be922f
commit 1fe8a3a959
54 changed files with 3022 additions and 2559 deletions
@@ -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 {
@@ -5,28 +5,28 @@ import qs.Components
import qs.Services
ColumnLayout {
id: root
id: root
spacing: Tokens.spacing.small
spacing: Tokens.spacing.small
Item {
Layout.fillHeight: true
}
Item {
Layout.fillHeight: true
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant
font.pointSize: 36
text: "chat"
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant
fontStyle: Tokens.font.icon.extraLarge
text: "chat"
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("Send a message to get started")
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("Send a message to get started")
}
Item {
Layout.fillHeight: true
}
Item {
Layout.fillHeight: true
}
}
@@ -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");