This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/gui/ChatBlockDelegate.qml

358 lines
12 KiB
QML

// ChatBlockDelegate.qml — Delegate for rendering chat blocks in the ListView
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Column {
id: root
spacing: 2
// Required properties from model
required property string blockType
required property string blockName
required property var blockSegments
required property string blockRenderedHtml
// Expand state based on block type
property bool blockExpanded: {
switch (blockType) {
case "user":
case "assistant":
case "tool":
case "info":
case "retry":
case "interrupt":
return true
default:
return false
}
}
// Header color based on block type and theme
function headerColor() {
var bg = theme.baseColor
if (theme.isDark) {
switch (blockType) {
case "user": return Qt.lighter(bg, 1.8)
case "assistant": return Qt.lighter(bg, 1.5)
case "reasoning": return Qt.lighter(bg, 1.3)
case "call": return Qt.lighter(bg, 1.7)
case "tool": return Qt.lighter(bg, 1.2)
case "error": return Qt.rgba(0.6, 0.15, 0.15, 0.7)
case "info": return Qt.lighter(bg, 1.4)
case "retry": return Qt.rgba(0.6, 0.5, 0.0, 0.5)
case "interrupt": return "#3d2e00"
default: return bg
}
} else {
switch (blockType) {
case "user": return Qt.darker(bg, 1.12)
case "assistant": return Qt.darker(bg, 1.06)
case "reasoning": return Qt.darker(bg, 1.03)
case "call": return Qt.darker(bg, 1.1)
case "tool": return Qt.darker(bg, 1.04)
case "error": return Qt.rgba(1.0, 0.8, 0.8, 0.7)
case "info": return Qt.darker(bg, 1.02)
case "retry": return Qt.rgba(1.0, 0.9, 0.6, 0.5)
case "interrupt": return "#fff8e1"
default: return bg
}
}
}
// Header label text based on block type
function headerText() {
switch (blockType) {
case "user": return "\uD83E\uDDD1 You"
case "assistant": return "\uD83E\uDD16 Ollie"
case "reasoning": return "Reasoning"
case "call": return "\u25B6\uFE0F " + blockName
case "tool": return "\uD83D\uDDA5\uFE0F " + blockName
case "error": return "\u274C Error"
case "info": return "\u2139\uFE0F Info"
case "retry": return "Retry"
case "interrupt": return "\u26A0\uFE0F User Interrupt"
default: return ""
}
}
// Clickable header
Rectangle {
width: root.width
height: 22
radius: 3
color: root.headerColor()
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.blockExpanded = !root.blockExpanded
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 6
spacing: 4
Label {
text: root.blockExpanded ? "▼" : "▶"
font.pixelSize: 8
opacity: 0.6
}
Label {
Layout.fillWidth: true
text: root.headerText()
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11
font.bold: true
color: theme.textColor
opacity: 0.9
}
}
}
// Block content rendered as segments
Column {
width: root.width
visible: root.blockExpanded
spacing: 4
// Fast path: single TextEdit for the concatenated HTML.
// Diagrams are handled separately via the segments list.
TextEdit {
id: mainTextEdit
width: root.width
visible: !hasDiagrams()
text: root.blockRenderedHtml
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
cursorShape: Qt.IBeamCursor
onClicked: {
if (!mainTextEdit._menu) {
mainTextEdit._menu = contextMenuComponent.createObject(mainTextEdit)
}
mainTextEdit._menu.popup()
}
}
property var _menu: null
}
// Diagram path: use Repeater for blocks that contain diagrams
Repeater {
model: hasDiagrams() ? root.blockSegments : []
delegate: Loader {
width: root.width
active: modelData !== null && modelData !== undefined
sourceComponent: (modelData && modelData.type === "diagram") ? diagramComponent : textComponent
property var segmentData: modelData || {}
}
}
}
function hasDiagrams() {
if (!root.blockSegments) return false
for (var i = 0; i < root.blockSegments.length; i++) {
if (root.blockSegments[i] && root.blockSegments[i].type === "diagram")
return true
}
return false
}
// Text segment component
Component {
id: textComponent
TextEdit {
id: textEdit
width: root.width
text: segmentData.html || ""
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
// Left-click: disable ListView flick while selecting text
// Right-click: lazy context menu
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
cursorShape: Qt.IBeamCursor
onClicked: {
if (!textEdit._menu) {
textEdit._menu = contextMenuComponent.createObject(textEdit)
}
textEdit._menu.popup()
}
}
property Menu _menu: null
Component.onDestruction: {
if (_menu) _menu.destroy()
}
}
}
// Shared context menu component (created on demand)
Component {
id: contextMenuComponent
Menu {
id: ctxMenu
property TextEdit target: parent
MenuItem {
text: "Copy"
enabled: ctxMenu.target ? ctxMenu.target.selectedText.length > 0 : false
onTriggered: if (ctxMenu.target) ctxMenu.target.copy()
}
MenuItem {
text: "Select All"
onTriggered: if (ctxMenu.target) ctxMenu.target.selectAll()
}
}
}
// Diagram segment component
Component {
id: diagramComponent
Rectangle {
id: diagramRect
width: root.width
height: diagramContent.height + diagramHeader.height + 8
color: "transparent"
border.color: theme.midColor
border.width: 1
radius: 4
property bool showSource: false
RowLayout {
id: diagramHeader
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 4
height: 24
Label {
text: segmentData.lang || "diagram"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 1 : 11
color: theme.textColor
opacity: 0.7
}
Item { Layout.fillWidth: true }
Button {
text: diagramRect.showSource ? "📊" : "</>"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10
implicitWidth: 32
implicitHeight: 20
ToolTip.visible: hovered
ToolTip.text: diagramRect.showSource ? "Show diagram" : "Show source"
onClicked: diagramRect.showSource = !diagramRect.showSource
}
Button {
text: "📋"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10
implicitWidth: 32
implicitHeight: 20
ToolTip.visible: hovered
ToolTip.text: diagramRect.showSource ? "Copy source" : "Copy image"
onClicked: {
if (diagramRect.showSource) {
clipboard.copyText(segmentData.source)
} else if (segmentData.svg) {
clipboard.copyImage(segmentData.svg)
}
}
}
}
Item {
id: diagramContent
anchors.top: diagramHeader.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 4
height: diagramRect.showSource ? sourceEdit.height : imageColumn.height
TextEdit {
id: sourceEdit
width: parent.width
visible: diagramRect.showSource
text: segmentData.html || ""
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
cursorShape: Qt.IBeamCursor
onClicked: {
if (!sourceEdit._menu) {
sourceEdit._menu = contextMenuComponent.createObject(sourceEdit)
}
sourceEdit._menu.popup()
}
}
property Menu _menu: null
}
Column {
id: imageColumn
width: parent.width
visible: !diagramRect.showSource
Rectangle {
visible: segmentData.svg && !segmentData.loading
color: "white"
width: diagramImg.width
height: diagramImg.height
Image {
id: diagramImg
source: segmentData.svg || ""
fillMode: Image.PreserveAspectFit
width: Math.min(implicitWidth, imageColumn.width)
}
}
Label {
visible: segmentData.loading
text: "Loading " + (segmentData.lang || "diagram") + "..."
font.italic: true
color: theme.textColor
opacity: 0.7
}
}
}
}
}
}