add markdown parsing + latex + tree-sitter highlighting for codeblocks in llm responses
This commit is contained in:
@@ -70,7 +70,7 @@ Item {
|
||||
fadeAmount: 0.05
|
||||
fadeThreshold: Tokens.padding.medium
|
||||
model: root.chatData.messagesModel
|
||||
spacing: Tokens.spacing.medium
|
||||
spacing: 0
|
||||
verticalLayoutDirection: VerticalFadeListView.BottomToTop
|
||||
|
||||
add: Transition {
|
||||
@@ -127,26 +127,38 @@ Item {
|
||||
visible: !root.chatData
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: scrollToBottom
|
||||
|
||||
Item {
|
||||
anchors.bottom: input.top
|
||||
anchors.bottomMargin: Tokens.spacing.extraLarge
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
font.pointSize: Tokens.font.size.large
|
||||
icon: "arrow_downward"
|
||||
isRound: true
|
||||
padding: Tokens.padding.extraSmall
|
||||
implicitHeight: scrollToBottom.implicitHeight
|
||||
implicitWidth: scrollToBottom.implicitWidth
|
||||
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0
|
||||
type: IconButton.Filled
|
||||
visible: scale > 0
|
||||
|
||||
Behavior on scale {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
list.positionViewAtBeginning();
|
||||
Elevation {
|
||||
anchors.fill: parent
|
||||
level: 2
|
||||
radius: scrollToBottom.radius
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: scrollToBottom
|
||||
|
||||
anchors.centerIn: parent
|
||||
font.pointSize: Tokens.font.size.large
|
||||
icon: "arrow_downward"
|
||||
isRound: true
|
||||
padding: Tokens.padding.extraSmall
|
||||
type: IconButton.Tonal
|
||||
|
||||
onClicked: {
|
||||
list.positionViewAtBeginning();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ Item {
|
||||
required property ChatGeneration current
|
||||
required property TextEdit edit
|
||||
required property bool hovered
|
||||
readonly property bool isUser: message.role === ChatMessage.Role.User
|
||||
required property bool isUser
|
||||
required property ChatMessage message
|
||||
required property LlmSegment segment
|
||||
|
||||
implicitHeight: root.current.content !== "" ? editButton.implicitHeight : 0
|
||||
implicitHeight: editButton.implicitHeight
|
||||
implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small
|
||||
|
||||
ButtonRow {
|
||||
@@ -66,8 +67,7 @@ Item {
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
@@ -78,8 +78,7 @@ Item {
|
||||
visible: !root.isUser
|
||||
|
||||
Behavior on scale {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
@@ -94,8 +93,7 @@ Item {
|
||||
scale: root.edit.readOnly ? 1 : 0
|
||||
|
||||
Behavior on scale {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: Quickshell.clipboardText = root.current.content
|
||||
@@ -107,8 +105,11 @@ Item {
|
||||
icon: root.edit.readOnly ? "edit" : "check"
|
||||
inactiveColor: Colors.palette.m3tertiary
|
||||
inactiveOnColor: Colors.palette.m3onTertiary
|
||||
visible: root.isUser
|
||||
|
||||
onClicked: root.edit.readOnly = !root.edit.readOnly
|
||||
onClicked: {
|
||||
root.edit.readOnly = !root.edit.readOnly;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
CustomClippingRect {
|
||||
id: root
|
||||
|
||||
required property string language
|
||||
required property string code
|
||||
property bool copied: false
|
||||
property color codeColor: Colors.palette.m3onSurfaceVariant
|
||||
property color codeBackgroundColor: Colors.palette.m3surfaceContainerLow
|
||||
property color codeHeaderColor: Colors.palette.m3outline
|
||||
property color codeAccentColor: Colors.palette.m3primary
|
||||
|
||||
// Highlighter spans for the current code; refreshed when the code or
|
||||
// its language changes.
|
||||
property var codeSpans: []
|
||||
|
||||
function refresh() {
|
||||
codeSpans = CodeHighlighter.highlight(root.code, root.language);
|
||||
}
|
||||
|
||||
function roleColor(kind) {
|
||||
switch (kind) {
|
||||
case "comment":
|
||||
return Colors.palette.m3outline;
|
||||
case "string":
|
||||
return Colors.palette.m3tertiary;
|
||||
case "string.key":
|
||||
return Colors.palette.m3secondary;
|
||||
case "number":
|
||||
case "constant":
|
||||
return Colors.palette.m3tertiaryFixed;
|
||||
case "keyword":
|
||||
return root.codeAccentColor;
|
||||
case "type":
|
||||
return Colors.palette.m3secondary;
|
||||
case "function":
|
||||
case "method":
|
||||
return Colors.palette.m3onSurface;
|
||||
case "macro":
|
||||
case "preproc":
|
||||
return Colors.palette.m3secondaryContainer;
|
||||
case "operator":
|
||||
case "property":
|
||||
return root.codeColor;
|
||||
case "label":
|
||||
case "attribute":
|
||||
return Colors.palette.m3tertiaryFixedDim;
|
||||
default:
|
||||
return root.codeColor;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Wraps the code in <font color> tags following the highlighter spans.
|
||||
function highlightedHtml(code, spans) {
|
||||
let out;
|
||||
if (!spans.length) {
|
||||
out = escapeHtml(code);
|
||||
} else {
|
||||
out = "";
|
||||
let pos = 0;
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
const span = spans[i];
|
||||
if (span.start > pos)
|
||||
out += escapeHtml(code.slice(pos, span.start));
|
||||
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(span.start, span.start + span.length)) + "</font>";
|
||||
pos = span.start + span.length;
|
||||
}
|
||||
if (pos < code.length)
|
||||
out += escapeHtml(code.slice(pos));
|
||||
}
|
||||
// RichText collapses HTML whitespace: break newlines, and protect
|
||||
// line-leading indentation with nbsp (internal spaces stay normal
|
||||
// so long lines can still wrap).
|
||||
return out
|
||||
.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, " "))
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin
|
||||
implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeText.implicitHeight + codeText.anchors.margins * 2
|
||||
color: root.codeBackgroundColor
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
onLanguageChanged: refresh()
|
||||
onCodeChanged: refresh()
|
||||
|
||||
CustomText {
|
||||
id: codeText
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.top: headerRow.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.padding.small
|
||||
color: root.codeColor
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
textFormat: Text.RichText
|
||||
text: root.highlightedHtml(root.code, root.codeSpans)
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: headerRow
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: Tokens.padding.small
|
||||
anchors.leftMargin: Tokens.padding.small
|
||||
anchors.rightMargin: Tokens.padding.small
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: root.codeHeaderColor
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.language
|
||||
visible: root.language.length > 0
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
IconButton {
|
||||
icon: root.copied ? "check" : "content_copy"
|
||||
inactiveColor: "transparent"
|
||||
inactiveOnColor: root.codeHeaderColor
|
||||
type: IconButton.Text
|
||||
|
||||
onClicked: {
|
||||
Quickshell.clipboardText = root.code;
|
||||
root.copied = true;
|
||||
copyResetTimer.restart();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: copyResetTimer
|
||||
|
||||
interval: 1500
|
||||
|
||||
onTriggered: root.copied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Llm
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property bool isUser
|
||||
required property LlmSegment segment
|
||||
required property bool hovered
|
||||
required property Repeater repeater
|
||||
required property int index
|
||||
required property ChatMessage message
|
||||
required property ChatGeneration current
|
||||
|
||||
// Widest the bubble may grow to; assistant bubbles always use it so
|
||||
// the markdown blocks have a deterministic width to wrap against.
|
||||
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
|
||||
|
||||
signal edit(text: string)
|
||||
|
||||
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
|
||||
|
||||
CustomRect {
|
||||
id: bubble
|
||||
|
||||
radius: Tokens.rounding.medium
|
||||
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
||||
implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
||||
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + Tokens.padding.medium * 2
|
||||
anchors.right: root.isUser ? parent.right : undefined
|
||||
|
||||
// User messages stay a plain editable text field.
|
||||
TextEditBase {
|
||||
id: msgText
|
||||
|
||||
visible: root.isUser
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
animateCursor: false
|
||||
color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
||||
cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
readOnly: true
|
||||
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
|
||||
text: root.isUser ? root.segment.text : ""
|
||||
textFormat: CustomText.MarkdownText
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key == Qt.Key_Return) {
|
||||
if (!(event.modifiers & Qt.ShiftModifier)) {
|
||||
readOnly = true;
|
||||
event.accepted = true;
|
||||
}
|
||||
} else if (event.key == Qt.Key_Escape) {
|
||||
text = root.segment.text;
|
||||
readOnly = true;
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
onEditingFinished: {
|
||||
const old = root.segment.text;
|
||||
if (old !== text)
|
||||
root.edit(text);
|
||||
|
||||
readOnly = true;
|
||||
}
|
||||
onReadOnlyChanged: {
|
||||
if (readOnly) {
|
||||
animateCursor = false;
|
||||
root.forceActiveFocus();
|
||||
textFormat = CustomText.MarkdownText;
|
||||
} else {
|
||||
var raw = root.segment.text;
|
||||
textFormat = CustomText.PlainText;
|
||||
text = raw;
|
||||
forceActiveFocus();
|
||||
cursorPosition = text.length;
|
||||
animateCursor = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assistant content: parsed markdown blocks.
|
||||
MarkdownBlocks {
|
||||
id: blocks
|
||||
|
||||
visible: !root.isUser
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
blocks: root.segment.markdown
|
||||
}
|
||||
}
|
||||
|
||||
Actions {
|
||||
id: actionsRow
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
|
||||
anchors.top: bubble.bottom
|
||||
visible: {
|
||||
return (root.segment.type === LlmSegment.Type.Content) && root.segment.status !== LlmSegment.Status.Running && root.repeater.count === (root.index + 1);
|
||||
}
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
edit: msgText
|
||||
hovered: root.hovered
|
||||
isUser: root.isUser
|
||||
segment: root.segment
|
||||
current: root.current
|
||||
message: root.message
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// Top-level markdown blocks (see MarkdownParser / LlmSegment.markdown).
|
||||
required property var blocks
|
||||
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
Repeater {
|
||||
id: blockRep
|
||||
|
||||
model: root.blocks
|
||||
|
||||
delegate: DelegateChooser {
|
||||
role: "type"
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Code
|
||||
|
||||
delegate: CodeBlockView {
|
||||
required property var modelData
|
||||
|
||||
language: modelData.language
|
||||
code: modelData.code
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Math
|
||||
|
||||
delegate: MathBlockView {
|
||||
required property var modelData
|
||||
|
||||
latex: modelData.latex
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Heading
|
||||
|
||||
delegate: CustomText {
|
||||
required property var modelData
|
||||
|
||||
color: Colors.palette.m3onSurface
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
text: modelData.text
|
||||
textFormat: Text.MarkdownText
|
||||
font.bold: true
|
||||
font.pointSize: {
|
||||
if (modelData.level <= 1)
|
||||
return Tokens.font.size.larger;
|
||||
if (modelData.level === 2)
|
||||
return Tokens.font.size.normal;
|
||||
return Tokens.font.size.smaller;
|
||||
}
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Text
|
||||
|
||||
delegate: CustomText {
|
||||
required property var modelData
|
||||
|
||||
color: Colors.palette.m3onSurface
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
text: modelData.text
|
||||
textFormat: Text.MarkdownText
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property string latex
|
||||
property color mathColor: Colors.palette.m3onSurface
|
||||
property real fontSize: Tokens.font.size.large
|
||||
|
||||
implicitWidth: Math.max(fallbackText.implicitWidth, root.width)
|
||||
implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
|
||||
|
||||
LlmMathText {
|
||||
id: math
|
||||
|
||||
latex: root.latex
|
||||
color: root.mathColor
|
||||
fontPointSize: root.fontSize
|
||||
devicePixelRatio: Screen.devicePixelRatio
|
||||
}
|
||||
|
||||
Image {
|
||||
id: equationImage
|
||||
|
||||
anchors.centerIn: parent
|
||||
source: math.imageUrl
|
||||
asynchronous: true
|
||||
visible: math.ok
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
width: Math.min(math.width, root.width)
|
||||
height: math.width > 0 ? width * (math.height / math.width) : 0
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: fallbackText
|
||||
|
||||
anchors.centerIn: parent
|
||||
color: root.mathColor
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.latex
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
visible: !math.ok
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
@@ -14,6 +16,7 @@ MouseArea {
|
||||
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
||||
required property ChatMessage modelData
|
||||
property bool reasoningExpanded: false
|
||||
readonly property var blocks: blockify(current.segments)
|
||||
property real savedContentY: -1
|
||||
|
||||
function handleReasoningToggle(expanded: bool): void {
|
||||
@@ -34,14 +37,36 @@ MouseArea {
|
||||
}
|
||||
}
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: root.isUser ? 0 : Tokens.spacing.extraSmall
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: root.isUser ? Tokens.spacing.extraSmall : 0
|
||||
function blockify(segs) {
|
||||
const blocks = [];
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const seg = segs[i];
|
||||
if (seg.type === LlmSegment.Type.Content) {
|
||||
blocks.push({
|
||||
kind: "content",
|
||||
id: i,
|
||||
segments: [seg]
|
||||
});
|
||||
} else {
|
||||
const last = blocks.length ? blocks[blocks.length - 1] : null;
|
||||
if (last && last.kind === "process") {
|
||||
last.segments.push(seg);
|
||||
} else {
|
||||
blocks.push({
|
||||
kind: "process",
|
||||
id: i,
|
||||
segments: [seg]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
hoverEnabled: true
|
||||
// Explicit, synchronous height — sum of children, no Layout polish involved.
|
||||
implicitHeight: reasoningLoader.implicitHeight + (reasoningLoader.active ? Tokens.spacing.medium : 0) + bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
|
||||
preventStealing: true
|
||||
implicitHeight: layout.implicitHeight + Tokens.padding.medium
|
||||
|
||||
Binding {
|
||||
property: "contentY"
|
||||
@@ -59,106 +84,61 @@ MouseArea {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: reasoningLoader
|
||||
Column {
|
||||
id: layout
|
||||
|
||||
readonly property bool shouldBeActive: root.current.reasoningActive || root.current.content === ""
|
||||
|
||||
active: !root.isUser
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Tokens.spacing.extraLarge
|
||||
anchors.top: parent.top
|
||||
|
||||
sourceComponent: Reasoning {
|
||||
current: root.current
|
||||
Repeater {
|
||||
id: segmentRep
|
||||
|
||||
onExpandedChanged: {
|
||||
root.handleReasoningToggle(expanded);
|
||||
model: ScriptModel {
|
||||
values: root.blocks
|
||||
objectProp: "id"
|
||||
}
|
||||
}
|
||||
}
|
||||
delegate: DelegateChooser {
|
||||
role: "kind"
|
||||
|
||||
CustomRect {
|
||||
id: bubble
|
||||
DelegateChoice {
|
||||
roleValue: "process"
|
||||
|
||||
anchors.left: root.isUser ? undefined : parent.left
|
||||
anchors.right: root.isUser ? parent.right : undefined
|
||||
anchors.top: reasoningLoader.active ? reasoningLoader.bottom : parent.top
|
||||
anchors.topMargin: reasoningLoader.active ? Tokens.spacing.medium : 0
|
||||
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
||||
implicitHeight: root.current.content !== "" ? msgText.contentHeight + Tokens.padding.medium * 2 : 0
|
||||
implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge)
|
||||
radius: Tokens.rounding.medium
|
||||
visible: !root.current.reasoningActive
|
||||
delegate: ProcessBlock {
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
TextEditBase {
|
||||
id: msgText
|
||||
segments: modelData.segments
|
||||
isActive: index === root.blocks.length - 1
|
||||
width: root.width
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
animateCursor: false
|
||||
color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
||||
cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
readOnly: true
|
||||
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
|
||||
text: root.current.content
|
||||
textFormat: CustomText.MarkdownText
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key == Qt.Key_Return) {
|
||||
if (!(event.modifiers & Qt.ShiftModifier)) {
|
||||
readOnly = true;
|
||||
event.accepted = true;
|
||||
onExpandedChanged: root.handleReasoningToggle(expanded)
|
||||
}
|
||||
} else if (event.key == Qt.Key_Escape) {
|
||||
text = root.current.content;
|
||||
readOnly = true;
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
onEditingFinished: {
|
||||
const old = root.current.content;
|
||||
if (old !== text) {
|
||||
root.modelData.edit(text);
|
||||
|
||||
if (root.isUser)
|
||||
root.modelData.generate();
|
||||
}
|
||||
|
||||
readOnly = true;
|
||||
}
|
||||
onReadOnlyChanged: {
|
||||
if (readOnly) {
|
||||
animateCursor = false;
|
||||
root.forceActiveFocus();
|
||||
textFormat = CustomText.MarkdownText;
|
||||
} else {
|
||||
var raw = root.current.content;
|
||||
textFormat = CustomText.PlainText;
|
||||
text = raw;
|
||||
forceActiveFocus();
|
||||
cursorPosition = text.length;
|
||||
animateCursor = true;
|
||||
DelegateChoice {
|
||||
roleValue: "content"
|
||||
|
||||
delegate: ContentBubble {
|
||||
required property var modelData
|
||||
|
||||
isUser: root.isUser
|
||||
segment: modelData.segments[0]
|
||||
width: root.width
|
||||
repeater: segmentRep
|
||||
message: root.modelData
|
||||
current: root.current
|
||||
hovered: root.containsMouse
|
||||
|
||||
onEdit: text => {
|
||||
root.modelData.edit(text);
|
||||
|
||||
if (root.isUser)
|
||||
root.modelData.generate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Actions {
|
||||
id: actionsRow
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
|
||||
anchors.top: bubble.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
current: root.current
|
||||
edit: msgText
|
||||
hovered: root.containsMouse
|
||||
message: root.modelData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var segments
|
||||
required property bool isActive
|
||||
property bool expanded: false
|
||||
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1]
|
||||
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
|
||||
|
||||
implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin
|
||||
|
||||
LoadingIndicator {
|
||||
id: spinnerReasoning
|
||||
|
||||
anchors.centerIn: expandBtn
|
||||
implicitSize: collapsedText.implicitHeight
|
||||
opacity: root.isActive ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: expandBtn
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: collapsedText.verticalCenter
|
||||
font.pointSize: Tokens.font.size.large
|
||||
anchors.topMargin: 0
|
||||
icon: "keyboard_arrow_down"
|
||||
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||
opacity: root.isActive ? 0 : 1
|
||||
rotation: root.expanded ? 180 : 0
|
||||
type: IconButton.Text
|
||||
|
||||
Behavior on rotation {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: collapsedText
|
||||
|
||||
anchors.left: expandBtn.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 0
|
||||
color: Colors.palette.m3outline
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: {
|
||||
if (root.isActive) {
|
||||
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
|
||||
return qsTr("Using %1...").arg(root.lastSegment.name);
|
||||
return qsTr("Thinking...");
|
||||
}
|
||||
return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1));
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
id: expandedRect
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: collapsedText.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
color: Colors.palette.m3surfaceContainerLow
|
||||
implicitHeight: root.expanded ? expandedContent.contentHeight + expandedContent.anchors.margins * 2 : 0
|
||||
opacity: root.expanded ? 1 : 0
|
||||
radius: Tokens.rounding.medium
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: expandedContent
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.medium
|
||||
model: root.segments
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
delegate: Item {
|
||||
id: segment
|
||||
|
||||
required property LlmSegment modelData
|
||||
required property int index
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
implicitHeight: header.implicitHeight + content.implicitHeight + content.anchors.topMargin
|
||||
|
||||
RowLayout {
|
||||
id: header
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
MaterialIcon {
|
||||
text: segment.modelData.type === LlmSegment.Type.Reasoning ? "cognition" : "build"
|
||||
fill: 1
|
||||
}
|
||||
|
||||
CustomText {
|
||||
Layout.fillWidth: true
|
||||
text: segment.modelData.type === LlmSegment.Type.Reasoning ? qsTr("Thought for %1s").arg((segment.modelData.elapsedMs / 1000).toFixed(1)) : qsTr("Used %1 for %2s").arg(segment.modelData.name).arg((segment.modelData.elapsedMs / 1000).toFixed(1))
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: content
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Tokens.padding.small
|
||||
anchors.top: header.bottom
|
||||
anchors.topMargin: Tokens.spacing.small
|
||||
anchors.right: parent.right
|
||||
color: Colors.palette.m3outline
|
||||
font.pointSize: Tokens.font.size.small
|
||||
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
|
||||
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property ChatGeneration current
|
||||
property bool expanded: false
|
||||
readonly property bool isReasoning: root.current.reasoningActive || root.current.content === ""
|
||||
|
||||
implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin
|
||||
|
||||
LoadingIndicator {
|
||||
id: spinnerReasoning
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitSize: collapsedText.implicitHeight
|
||||
opacity: root.isReasoning ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: expandBtn
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.verticalCenter: collapsedText.verticalCenter
|
||||
font.pointSize: Tokens.font.size.large
|
||||
icon: "keyboard_arrow_down"
|
||||
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||
opacity: root.isReasoning ? 0 : 1
|
||||
rotation: root.expanded ? 180 : 0
|
||||
type: IconButton.Text
|
||||
|
||||
Behavior on rotation {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: collapsedText
|
||||
|
||||
anchors.left: root.current.reasoningActive ? spinnerReasoning.right : expandBtn.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 0
|
||||
color: Colors.palette.m3outline
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.current.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((root.current.reasoningElapsedMs / 1000).toFixed(1))
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
id: expandedRect
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: collapsedText.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
color: Colors.palette.m3surfaceContainerLow
|
||||
implicitHeight: root.expanded ? expandedText.implicitHeight + expandedText.anchors.margins * 2 : -anchors.topMargin
|
||||
implicitWidth: expandedText.implicitWidth + expandedText.anchors.margins * 2
|
||||
opacity: root.expanded ? 1 : 0
|
||||
radius: Tokens.rounding.medium
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: expandedText
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.medium
|
||||
color: Colors.palette.m3onSurface
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.current.reasoning
|
||||
textFormat: CustomText.MarkdownText
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user