diff --git a/Helpers/Picker.qml b/Helpers/Picker.qml index 05b4146..b87f53e 100755 --- a/Helpers/Picker.qml +++ b/Helpers/Picker.qml @@ -126,33 +126,29 @@ Item { focus: true opacity: 0 - Behavior on opacity { - Anim { - duration: 300 - } - } Behavior on rsx { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { - } + ExAnim {} } Behavior on rsy { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { - } + ExAnim {} } Behavior on sh { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { - } + ExAnim {} } Behavior on sw { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { + ExAnim {} + } + Behavior on opacity { + Anim { + duration: 300 } } @@ -352,8 +348,7 @@ Item { y: selectionRect.y - root.realBorderWidth Behavior on border.color { - Anim { - } + Anim {} } } @@ -402,6 +397,5 @@ Item { onUndoRequested: annotations.undo() } - component ExAnim: Anim { - } + component ExAnim: Anim {} } diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 4a1220d..2908013 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -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(); + } } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml index 2b0bade..1d4efdf 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml @@ -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; + } } } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml new file mode 100644 index 0000000..617c0a1 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml @@ -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, ">"); + } + + // Wraps the code in 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 += `` + escapeHtml(code.slice(span.start, span.start + span.length)) + ""; + 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, "
"); + } + + 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 + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml new file mode 100644 index 0000000..c937931 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -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 + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml new file mode 100644 index 0000000..f9654e7 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml @@ -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 + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml new file mode 100644 index 0000000..856005d --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml @@ -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 + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 73e758d..0133a1e 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -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 - } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml new file mode 100644 index 0000000..18f7c85 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -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") + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml b/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml deleted file mode 100644 index 74bad30..0000000 --- a/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml +++ /dev/null @@ -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 - } - } -} diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp index e68584d..e7859f0 100644 --- a/Plugins/ZShell/Config/llm.hpp +++ b/Plugins/ZShell/Config/llm.hpp @@ -11,6 +11,9 @@ class Llm : public ConfigObject { CFG_PROPERTY(QString, endpoint, "http://localhost:8080") CFG_PROPERTY(QString, model, "") CFG_PROPERTY(double, temperature, 0.7) + // Whether tools are offered to the model. Models without a tool-calling + // template should run with this off. + CFG_PROPERTY(bool, tools, true) public: explicit Llm(QObject* parent = nullptr) : ConfigObject(parent) {} diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index 05aca8c..c3a358e 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -1,17 +1,76 @@ +# cmark-gfm: markdown -> block structure (pkg-config; no CMake config +# package is installed). +pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm) + +# tree-sitter runtime for code block highlighting (grammars are +# dlopen()'d at runtime and optional). +pkg_check_modules(TREE_SITTER REQUIRED IMPORTED_TARGET tree-sitter) + +# JKQTMathText (JKQtPlotter) for LaTeX rendering. The config files live +# in a shared JKQTPlotter6 directory, not one named after the package. +find_path(JKQTPlotter6_CMAKE_DIR + NAMES JKQTMathText6Config.cmake + HINTS /usr/lib/cmake/JKQTPlotter6 /usr/local/lib/cmake/JKQTPlotter6 + DOC "Directory containing the JKQtPlotter cmake package files") +find_package(JKQTMathText6 REQUIRED PATHS "${JKQTPlotter6_CMAKE_DIR}") + +# Embed the vendored highlight queries as C++ string literals. +set(HIGHLIGHT_QUERY_LANGS c cpp python javascript typescript bash json rust go yaml toml sql) +set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp") +set(_hl_header "#pragma once\n\n// Vendored tree-sitter highlight queries (MIT; see the per-file\n// source headers in the highlight-queries/ directory).\nnamespace ZShell::llm::hq {\n") +foreach(_hl_lang IN LISTS HIGHLIGHT_QUERY_LANGS) + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_hl_lang}.scm" _hl_src) + string(APPEND _hl_header "inline constexpr const char* ${_hl_lang} = R\"ZSQUERY(${_hl_src})ZSQUERY\";\n") +endforeach() +string(APPEND _hl_header "}\n") +file(WRITE "${HIGHLIGHT_QUERIES_HPP}" "${_hl_header}") + +# Embed the vendored Latin Modern fonts (GUST Font License; provenance +# in fonts/latinmodern/GUST-FONT-LICENSE.txt) as byte arrays. +set(LM_FONTS + lmroman10-regular + lmroman10-italic + lmroman10-bold + lmroman10-bolditalic + latinmodern-math) +set(LM_FONTS_HPP "${CMAKE_CURRENT_BINARY_DIR}/latinmodern-fonts.hpp") +set(_lm_header "#pragma once\n\n// Vendored Latin Modern fonts (GUST Font License; see\n// fonts/latinmodern/GUST-FONT-LICENSE.txt).\nnamespace ZShell::llm::lmfont {\n") +foreach(_lm_font IN LISTS LM_FONTS) + string(REPLACE "-" "_" _lm_sym "${_lm_font}") + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/fonts/latinmodern/${_lm_font}.otf" _lm_hex HEX) + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _lm_bytes "${_lm_hex}") + string(APPEND _lm_header "inline const unsigned char ${_lm_sym}[] = { ${_lm_bytes} };\n") +endforeach() +string(APPEND _lm_header "}\n") +file(WRITE "${LM_FONTS_HPP}" "${_lm_header}") + qml_module(ZShell-llm URI ZShell.Llm SOURCES chat.hpp chat.cpp chatstore.hpp chatstore.cpp + codehighlighter.hpp codehighlighter.cpp generation.hpp generation.cpp llmclient.hpp llmclient.cpp + markdownblock.hpp + markdownparser.hpp markdownparser.cpp + mathtext.hpp mathtext.cpp message.hpp message.cpp messagemodel.hpp messagemodel.cpp + segment.hpp segment.cpp session.hpp session.cpp + tool.hpp tool.cpp + webfetchtool.hpp webfetchtool.cpp LIBRARIES Qt::Network Qt::Sql + Qt::Gui + Qt::Widgets ZShell-config + JKQTPlotter::JKQTMathText + PkgConfig::CMARK_GFM + PkgConfig::TREE_SITTER ) +target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Config) diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index 08ae399..6387699 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -3,6 +3,7 @@ #include "config.hpp" #include "llm.hpp" #include "llmclient.hpp" +#include "webfetchtool.hpp" #include @@ -14,11 +15,13 @@ Chat::Chat(QObject* parent) new config::Config(); m_store->setLlmClient(m_client); + m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); const auto* llm = config::Config::instance()->llm(); m_client->setEndpoint(llm->endpoint()); m_client->setModel(llm->model()); m_client->setTemperature(llm->temperature()); + m_client->setToolsEnabled(llm->tools()); connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { m_client->setEndpoint(llm->endpoint()); @@ -29,6 +32,9 @@ Chat::Chat(QObject* parent) 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(m_client, &LlmClient::busyChanged, this, [this]() { // A fresh run supersedes the previous error. @@ -57,6 +63,11 @@ Chat::Chat(QObject* parent) &LlmClient::streamingChatIdChanged, this, &Chat::streamingChatIdChanged); + connect( + m_client, + &LlmClient::toolsEnabledChanged, + this, + &Chat::toolsEnabledChanged); connect( m_client, &LlmClient::errorOccurred, @@ -113,6 +124,16 @@ 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::Config::instance()) + config->llm()->set_tools(value); +} + QString Chat::streamingChatId() const { return m_client->streamingChatId(); } diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index c9e98ac..bbafabe 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -27,6 +27,7 @@ class Chat : public QObject { 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(QString lastError READ lastError NOTIFY lastErrorChanged) Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged) @@ -39,6 +40,8 @@ class Chat : public QObject { [[nodiscard]] QString model() const; [[nodiscard]] QStringList availableModels() const; [[nodiscard]] int contextSize() const; + [[nodiscard]] bool toolsEnabled() const; + void setToolsEnabled(bool value); [[nodiscard]] QString lastError() const { return m_lastError; } [[nodiscard]] ChatStore* chats() const { return m_store; } [[nodiscard]] QString streamingChatId() const; @@ -56,6 +59,7 @@ class Chat : public QObject { void modelChanged(); void availableModelsChanged(); void contextSizeChanged(); + void toolsEnabledChanged(); void errorOccurred(const QString& message); void lastErrorChanged(); void streamingChatIdChanged(); diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 3d4b286..e8aff1f 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -2,6 +2,7 @@ #include "llmclient.hpp" #include "message.hpp" +#include "segment.hpp" #include #include @@ -17,6 +18,38 @@ namespace ZShell::llm { +namespace { + +QString segmentTypeName(LlmSegment::Type type) { + switch (type) { + case LlmSegment::Type::Reasoning: + return QStringLiteral("reasoning"); + case LlmSegment::Type::ToolCall: + return QStringLiteral("tool_call"); + case LlmSegment::Type::Content: + return QStringLiteral("content"); + } + return QStringLiteral("reasoning"); +} + +LlmSegment::Type segmentTypeFromName(const QString& name) { + if (name == QLatin1String("tool_call")) + return LlmSegment::Type::ToolCall; + if (name == QLatin1String("content")) + return LlmSegment::Type::Content; + return LlmSegment::Type::Reasoning; +} + +// A null QString binds as SQL NULL, which violates the NOT NULL columns; +// DEFAULT only applies to omitted columns, not explicit NULLs. +QString sqlText(const QString& value) { + if (value.isNull()) + return QStringLiteral(""); + return value; +} + +} // namespace + ChatStore::ChatStore(QObject* parent) : QObject(parent), m_connectionName(QUuid::createUuid().toString()) { openDb(); @@ -86,14 +119,29 @@ void ChatStore::openDb() { " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " message_id INTEGER NOT NULL REFERENCES messages " "(id) ON DELETE CASCADE,\n" - " content TEXT,\n" - " reasoning TEXT,\n" " timestamp INTEGER NOT NULL,\n" - " reasoning_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" - " content_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" " is_active INTEGER NOT NULL DEFAULT 1\n" ")")); } + { + QSqlQuery query(db); + query.exec( + QStringLiteral( + "CREATE TABLE IF NOT EXISTS segments (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " generation_id INTEGER NOT NULL REFERENCES generations " + "(id) ON DELETE CASCADE,\n" + " type TEXT NOT NULL,\n" + " text TEXT NOT NULL DEFAULT '',\n" + " name TEXT NOT NULL DEFAULT '',\n" + " tool_call_id TEXT NOT NULL DEFAULT '',\n" + " arguments TEXT NOT NULL DEFAULT '',\n" + " result TEXT NOT NULL DEFAULT '',\n" + " status INTEGER NOT NULL DEFAULT 0,\n" + " elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" + " timestamp INTEGER NOT NULL\n" + ")")); + } { QSqlQuery query(db); query.exec(QStringLiteral( @@ -102,6 +150,9 @@ void ChatStore::openDb() { query.exec(QStringLiteral( "CREATE INDEX IF NOT EXISTS idx_generations_message " "ON generations (message_id)")); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_segments_generation " + "ON segments (generation_id)")); } } @@ -212,8 +263,8 @@ void ChatStore::saveMeta(ChatSession* session) { QSqlQuery query(db()); query.prepare("UPDATE sessions SET title = :title, icon = :icon " "WHERE id = :id"); - query.bindValue(":title", session->title()); - query.bindValue(":icon", session->icon()); + query.bindValue(":title", sqlText(session->title())); + query.bindValue(":icon", sqlText(session->icon())); query.bindValue(":id", session->id()); if (!query.exec()) qWarning() << "ChatStore: failed to save meta for" << session->id() @@ -234,7 +285,7 @@ bool ChatStore::saveSession(ChatSession* session) { query.prepare( "UPDATE sessions SET title = :title, updated_at = :updated_at " "WHERE id = :id"); - query.bindValue(":title", session->title()); + query.bindValue(":title", sqlText(session->title())); query.bindValue(":updated_at", session->updatedAtMs()); query.bindValue(":id", session->id()); ok = query.exec(); @@ -252,10 +303,15 @@ bool ChatStore::saveSession(ChatSession* session) { "VALUES (:id, :role, :timestamp)"); QSqlQuery generationInsert(handle); ok = ok && generationInsert.prepare( - "INSERT INTO generations (message_id, content, reasoning, " - "timestamp, reasoning_elapsed_ms, content_elapsed_ms, " - "is_active) VALUES (:mid, :content, :reasoning, :timestamp, " - ":reasoning_elapsed_ms, :content_elapsed_ms, :is_active)"); + "INSERT INTO generations (message_id, timestamp, is_active) " + "VALUES (:mid, :timestamp, :is_active)"); + QSqlQuery segmentInsert(handle); + ok = ok && segmentInsert.prepare( + "INSERT INTO segments (generation_id, type, text, name, " + "tool_call_id, arguments, result, status, elapsed_ms, " + "timestamp) VALUES (:gid, :type, :text, :name, " + ":tool_call_id, :arguments, :result, :status, :elapsed_ms, " + ":timestamp)"); // The model holds messages most recent first; the database keeps // natural rowid order, so iterate from the oldest row up. const auto* model = session->messagesModel(); @@ -279,14 +335,8 @@ bool ChatStore::saveSession(ChatSession* session) { for (int i = 0; ok && i < message->generationCount(); ++i) { const auto* generation = message->generation(i); generationInsert.bindValue(":mid", messageId); - generationInsert.bindValue(":content", generation->content()); generationInsert.bindValue( - ":reasoning", generation->reasoning()); - generationInsert.bindValue(":timestamp", generation->timestamp()); - generationInsert.bindValue( - ":reasoning_elapsed_ms", generation->reasoningElapsedMs()); - generationInsert.bindValue( - ":content_elapsed_ms", generation->contentElapsedMs()); + ":timestamp", generation->timestamp()); generationInsert.bindValue( ":is_active", i == message->activeGenerationIndex() ? 1 : 0); @@ -297,6 +347,33 @@ bool ChatStore::saveSession(ChatSession* session) { << generationInsert.lastError().text(); break; } + const int generationId = + generationInsert.lastInsertId().toInt(); + for (const auto* segment : generation->segments()) { + segmentInsert.bindValue(":gid", generationId); + segmentInsert.bindValue( + ":type", segmentTypeName(segment->type())); + segmentInsert.bindValue(":text", sqlText(segment->text())); + segmentInsert.bindValue(":name", sqlText(segment->name())); + segmentInsert.bindValue( + ":tool_call_id", sqlText(segment->toolCallId())); + segmentInsert.bindValue( + ":arguments", sqlText(segment->arguments())); + segmentInsert.bindValue(":result", sqlText(segment->result())); + segmentInsert.bindValue( + ":status", static_cast(segment->status())); + segmentInsert.bindValue( + ":elapsed_ms", segment->elapsedMs()); + segmentInsert.bindValue( + ":timestamp", segment->timestamp()); + if (!segmentInsert.exec()) { + ok = false; + qWarning() << "ChatStore: saveSession" << id + << "segment insert failed:" + << segmentInsert.lastError().text(); + break; + } + } } } } @@ -333,21 +410,54 @@ void ChatStore::loadMessagesInto(ChatSession* session) { query.value(2).toLongLong()); QSqlQuery generationQuery(db()); generationQuery.prepare( - "SELECT content, reasoning, timestamp, reasoning_elapsed_ms, " - "content_elapsed_ms, is_active FROM generations " + "SELECT id, timestamp, is_active FROM generations " "WHERE message_id = :mid ORDER BY rowid"); generationQuery.bindValue(":mid", messageId); int activeIndex = 0; if (generationQuery.exec()) { int index = 0; while (generationQuery.next()) { - message->addGeneration( - generationQuery.value(2).toLongLong(), - generationQuery.value(0).toString(), - generationQuery.value(1).toString(), - generationQuery.value(3).toLongLong(), - generationQuery.value(4).toLongLong()); - if (generationQuery.value(5).toInt() != 0) + auto* generation = message->addGeneration( + generationQuery.value(1).toLongLong()); + QSqlQuery segmentQuery(db()); + segmentQuery.prepare( + "SELECT type, text, name, tool_call_id, arguments, " + "result, status, elapsed_ms, timestamp FROM segments " + "WHERE generation_id = :gid ORDER BY rowid"); + segmentQuery.bindValue( + ":gid", generationQuery.value(0).toInt()); + if (segmentQuery.exec()) { + while (segmentQuery.next()) { + auto* segment = new LlmSegment( + segmentTypeFromName( + segmentQuery.value(0).toString()), + segmentQuery.value(8).toLongLong(), + generation); + segment->setText( + segmentQuery.value(1).toString()); + segment->setName( + segmentQuery.value(2).toString()); + segment->setToolCallId( + segmentQuery.value(3).toString()); + segment->appendArguments( + segmentQuery.value(4).toString()); + segment->setResult( + segmentQuery.value(5).toString()); + segment->setStatus( + static_cast( + segmentQuery.value(6).toInt())); + segment->restore( + segmentQuery.value(7).toLongLong()); + generation->addSegment(segment); + } + } else { + qWarning() << "ChatStore: failed to load segments for " + << "generation" + << generationQuery.value(0).toInt() + << ":" + << segmentQuery.lastError().text(); + } + if (generationQuery.value(2).toInt() != 0) activeIndex = index; ++index; } diff --git a/Plugins/ZShell/Llm/codehighlighter.cpp b/Plugins/ZShell/Llm/codehighlighter.cpp new file mode 100644 index 0000000..7f31413 --- /dev/null +++ b/Plugins/ZShell/Llm/codehighlighter.cpp @@ -0,0 +1,384 @@ +#include "codehighlighter.hpp" + +#include "highlight-queries.hpp" + +#include + +#include +#include +#include + +#include + +namespace ZShell::llm { + +namespace hl { + +// Role ids; 0 means "no color". +enum Role : uint8_t { + None = 0, + Comment, + String, + StringKey, + Number, + Constant, + Keyword, + Type, + Function, + Method, + Macro, + Preproc, + Operator, + Property, + Label, + Attribute, +}; + +const char* roleName(Role role) { + switch (role) { + case Comment: return "comment"; + case String: return "string"; + case StringKey: return "string.key"; + case Number: return "number"; + case Constant: return "constant"; + case Keyword: return "keyword"; + case Type: return "type"; + case Function: return "function"; + case Method: return "method"; + case Macro: return "macro"; + case Preproc: return "preproc"; + case Operator: return "operator"; + case Property: return "property"; + case Label: return "label"; + case Attribute: return "attribute"; + default: return ""; + } +} + +using LanguageFn = const TSLanguage* (*)(); + +// Query sources, indexed by Grammar::queries. +struct QuerySources { + std::vector sources; + int c, cpp, python, javascript, typescriptExtended, typescript, bash, json, rust, go, yaml, toml, sql, cppExtended; +}; + +const QuerySources& querySources() { + static const QuerySources sources = [] { + QuerySources qs; + // javascript + typescript concatenated: the TS grammar reuses the + // JS node names, so the JS query usually compiles against it and + // gives full coverage; the TS-only query is the fallback. + const std::string tsExtended = + std::string(hq::javascript) + "\n" + std::string(hq::typescript); + // Same for cpp: the C++ grammar is a superset of C, and the C++ + // query only covers the C++-specific delta. Base C coverage + // (keywords, types, calls, strings, comments) comes from the C + // query. + const std::string cppExtended = + std::string(hq::c) + "\n" + std::string(hq::cpp); + qs.sources.reserve(14); + qs.sources.push_back(hq::c); + qs.sources.push_back(hq::cpp); + qs.sources.push_back(hq::python); + qs.sources.push_back(hq::javascript); + qs.sources.push_back(tsExtended); + qs.sources.push_back(hq::typescript); + qs.sources.push_back(hq::bash); + qs.sources.push_back(hq::json); + qs.sources.push_back(hq::rust); + qs.sources.push_back(hq::go); + qs.sources.push_back(hq::yaml); + qs.sources.push_back(hq::toml); + qs.sources.push_back(hq::sql); + qs.sources.push_back(cppExtended); + qs.c = 0; + qs.cpp = 1; + qs.python = 2; + qs.javascript = 3; + qs.typescriptExtended = 4; + qs.typescript = 5; + qs.bash = 6; + qs.json = 7; + qs.rust = 8; + qs.go = 9; + qs.yaml = 10; + qs.toml = 11; + qs.sql = 12; + qs.cppExtended = 13; + return qs; + }(); + return sources; +} + +const QHash& grammars() { + static const QHash grammars = [] { + const auto& qs = querySources(); + QHash map; + map.insert("c", {"libtree-sitter-c.so", "tree_sitter_c", {qs.c}}); + map.insert("cpp", {"libtree-sitter-cpp.so", "tree_sitter_cpp", {qs.cppExtended, qs.cpp}}); + map.insert("python", {"libtree-sitter-python.so", "tree_sitter_python", {qs.python}}); + map.insert("javascript", {"libtree-sitter-javascript.so", "tree_sitter_javascript", {qs.javascript}}); + map.insert("typescript", {"libtree-sitter-typescript.so", "tree_sitter_typescript", {qs.typescriptExtended, qs.typescript}}); + map.insert("tsx", {"libtree-sitter-tsx.so", "tree_sitter_tsx", {qs.typescriptExtended, qs.typescript}}); + map.insert("bash", {"libtree-sitter-bash.so", "tree_sitter_bash", {qs.bash}}); + map.insert("json", {"libtree-sitter-json.so", "tree_sitter_json", {qs.json}}); + map.insert("rust", {"libtree-sitter-rust.so", "tree_sitter_rust", {qs.rust}}); + map.insert("go", {"libtree-sitter-go.so", "tree_sitter_go", {qs.go}}); + map.insert("yaml", {"libtree-sitter-yaml.so", "tree_sitter_yaml", {qs.yaml}}); + map.insert("toml", {"libtree-sitter-toml.so", "tree_sitter_toml", {qs.toml}}); + map.insert("sql", {"libtree-sitter-sql.so", "tree_sitter_sql", {qs.sql}}); + return map; + }(); + return grammars; +} + +} // namespace hl + +CodeHighlighter* CodeHighlighter::s_instance = nullptr; + +const QHash& CodeHighlighter::aliases() { + // Language tags as written in code fences (and common variants) to + // grammar id. + static const QHash aliases = [] { + QHash map; + map.insert("c", "c"); + map.insert("h", "c"); + map.insert("cpp", "cpp"); + map.insert("c++", "cpp"); + map.insert("cc", "cpp"); + map.insert("cxx", "cpp"); + map.insert("h++", "cpp"); + map.insert("hpp", "cpp"); + map.insert("hh", "cpp"); + map.insert("python", "python"); + map.insert("py", "python"); + map.insert("javascript", "javascript"); + map.insert("js", "javascript"); + map.insert("jsx", "javascript"); + map.insert("mjs", "javascript"); + map.insert("cjs", "javascript"); + map.insert("typescript", "typescript"); + map.insert("ts", "typescript"); + map.insert("mts", "typescript"); + map.insert("cts", "typescript"); + map.insert("tsx", "tsx"); + map.insert("bash", "bash"); + map.insert("sh", "bash"); + map.insert("shell", "bash"); + map.insert("shellscript", "bash"); + map.insert("shell-session", "bash"); + map.insert("zsh", "bash"); + map.insert("console", "bash"); + map.insert("json", "json"); + map.insert("jsonc", "json"); + map.insert("rust", "rust"); + map.insert("rs", "rust"); + map.insert("go", "go"); + map.insert("golang", "go"); + map.insert("yaml", "yaml"); + map.insert("yml", "yaml"); + map.insert("toml", "toml"); + map.insert("sql", "sql"); + map.insert("mysql", "sql"); + map.insert("postgres", "sql"); + map.insert("postgresql", "sql"); + map.insert("sqlite", "sql"); + map.insert("sqlite3", "sql"); + return map; + }(); + return aliases; +} + +const std::vector& CodeHighlighter::querySources() const { + return hl::querySources().sources; +} + +uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) { + const QString n = QString::fromUtf8(name, length); + if (n == "comment") + return hl::Role::Comment; + if (n.startsWith("string")) + return n == "string.special.key" ? hl::Role::StringKey : hl::Role::String; + if (n == "escape" || n == "regexp") + return hl::Role::String; + if (n.startsWith("number")) + return hl::Role::Number; + if (n.startsWith("constant")) + return hl::Role::Constant; + if (n.startsWith("keyword")) + return hl::Role::Keyword; + if (n == "type" || n.startsWith("type.")) + return hl::Role::Type; + if (n == "namespace" || n == "module") + return hl::Role::Type; + if (n.startsWith("function") || n == "constructor") + return hl::Role::Function; + if (n == "method" || n == "method.builtin") + return hl::Role::Method; + if (n.startsWith("macro")) + return hl::Role::Macro; + if (n.startsWith("preproc")) + return hl::Role::Preproc; + if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator.")) + return hl::Role::Operator; + if (n == "property" || n == "field" || n.startsWith("property.")) + return hl::Role::Property; + if (n == "label") + return hl::Role::Label; + if (n.startsWith("attribute") || n == "annotation") + return hl::Role::Attribute; + return hl::Role::None; +} + +const char* CodeHighlighter::roleName(uint8_t role) { + return hl::roleName(static_cast(role)); +} + +QVariantList CodeHighlighter::highlight(const QString& code, const QString& language) const { + QVariantList spans; + if (code.isEmpty()) + return spans; + + const QString id = aliases().value(language.trimmed().toLower()); + if (id.isEmpty()) + return spans; + const Grammar& grammar = hl::grammars().value(id); + + // Guard against pathological blocks; highlighting is best-effort. + static constexpr size_t kMaxBytes = 512 * 1024; + const QByteArray utf8 = code.toUtf8(); + if (static_cast(utf8.size()) > kMaxBytes) + return spans; + + State& state = m_states[id]; + if (state.bad) + return spans; + + if (!state.lang) { + // Missing library is retriable (it may be installed while the + // shell runs); ABI/query failures below are not. + state.lib = dlopen(grammar.lib.toUtf8().constData(), RTLD_NOW | RTLD_LOCAL); + if (!state.lib) + return spans; + auto* symbol = reinterpret_cast( + dlsym(state.lib, grammar.symbol.toUtf8().constData())); + if (!symbol) { + dlclose(state.lib); + state.lib = nullptr; + return spans; + } + const TSLanguage* lang = symbol(); + const uint32_t version = ts_language_abi_version(lang); + if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION || + version > TREE_SITTER_LANGUAGE_VERSION) { + state.bad = true; + return spans; + } + state.lang = lang; + } + + if (!state.query) { + // Candidates in priority order; first that compiles wins. + for (const int candidate : grammar.queries) { + const std::string& source = + querySources()[static_cast(candidate)]; + TSQueryError errorType = TSQueryErrorNone; + uint32_t errorOffset = 0; + TSQuery* query = ts_query_new( + static_cast(state.lang), + source.data(), + static_cast(source.size()), + &errorOffset, + &errorType); + if (!query) + continue; + state.query = query; + break; + } + if (!state.query) { + state.bad = true; + return spans; + } + } + + TSParser* parser = ts_parser_new(); + ts_parser_set_language(parser, static_cast(state.lang)); + TSTree* tree = ts_parser_parse_string( + parser, nullptr, utf8.constData(), static_cast(utf8.size())); + if (!tree) { + ts_parser_delete(parser); + return spans; + } + + TSQuery* query = static_cast(state.query); + TSQueryCursor* cursor = ts_query_cursor_new(); + ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree)); + + // Per-byte winner table: captures arrive in document order and later + // captures overwrite earlier ones (tree-sitter highlight convention). + const uint32_t size = static_cast(utf8.size()); + std::vector kinds(size, 0); + + // QML slices the code by UTF-16 code unit, so spans must be in code + // units, not bytes. cu[b] = code units before byte b. + std::vector cu(size + 1, 0); + for (uint32_t b = 0; b < size; ++b) { + cu[b + 1] = cu[b]; + const unsigned char c = static_cast(utf8[b]); + if (c < 0x80) + cu[b + 1] += 1; + else if (c < 0xC0) + ; // continuation byte + else if (c < 0xF0) + cu[b + 1] += 1; // 2/3-byte lead -> BMP -> one unit + else + cu[b + 1] += 2; // 4-byte lead -> surrogate pair + } + + TSQueryMatch match; + uint32_t captureIndex = 0; + while (ts_query_cursor_next_capture(cursor, &match, &captureIndex)) { + const TSQueryCapture& capture = match.captures[captureIndex]; + uint32_t nameLength = 0; + const char* name = ts_query_capture_name_for_id(query, capture.index, &nameLength); + const uint8_t role = roleFor(name, nameLength); + if (role == 0) + continue; + const uint32_t start = ts_node_start_byte(capture.node); + const uint32_t end = ts_node_end_byte(capture.node); + if (end <= start || end > size) + continue; + std::fill(kinds.begin() + start, kinds.begin() + end, role); + } + + uint32_t position = 0; + while (position < size) { + if (kinds[position] == 0) { + ++position; + continue; + } + const uint8_t role = kinds[position]; + const uint32_t start = position; + while (position < size && kinds[position] == role) + ++position; + QVariantMap span; + span.insert("start", static_cast(cu[start])); + span.insert("length", static_cast(cu[position] - cu[start])); + span.insert("kind", roleName(role)); + spans.append(span); + } + + ts_query_cursor_delete(cursor); + ts_tree_delete(tree); + ts_parser_delete(parser); + return spans; +} + +CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) { + if (!s_instance) + s_instance = new CodeHighlighter(); + return s_instance; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/codehighlighter.hpp b/Plugins/ZShell/Llm/codehighlighter.hpp new file mode 100644 index 0000000..659d764 --- /dev/null +++ b/Plugins/ZShell/Llm/codehighlighter.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +class QQmlEngine; +class QJSEngine; + +namespace ZShell::llm { + +// Syntax highlighting for LLM code blocks via tree-sitter. +// +// The tree-sitter runtime is linked. Grammar libraries +// (libtree-sitter-.so) are dlopen()'d lazily, so a missing +// grammar package degrades that language to plain text instead of +// breaking the build or the app. Highlight queries are embedded at +// build time (highlight-queries/*.scm, vendored from the grammar +// repos, MIT). +// +// The fence language the LLM wrote (```cpp, ```python, ...) is mapped +// to a grammar through an alias table. +// +// highlight() returns a list of span maps: +// { "start": int, "length": int, "kind": QString } +// where kind is a semantic role (keyword, string, comment, number, +// function, type, ...) that QML maps to theme colors. An empty list +// means "no highlighting" (unknown language or grammar not installed). +class CodeHighlighter : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + public: + Q_INVOKABLE QVariantList highlight(const QString& code, const QString& language) const; + + static CodeHighlighter* create(QQmlEngine*, QJSEngine*); + + struct Grammar { + QString lib; // soname to dlopen + QString symbol; // tree_sitter_() entry point + // Candidate query sources, first wins. Lets typescript reuse + // the javascript query when it compiles against the grammar. + std::vector queries; // indices into querySources() + }; + + private: + struct State { + bool bad = false; // permanent failure, do not retry + void* lib = nullptr; + const void* lang = nullptr; // const TSLanguage* + void* query = nullptr; // TSQuery* + }; + + [[nodiscard]] static const QHash& aliases(); + [[nodiscard]] const std::vector& querySources() const; + // Maps a tree-sitter capture name to a role index (0 = unstyled). + [[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length); + [[nodiscard]] static const char* roleName(uint8_t role); + + mutable QHash m_states; + static CodeHighlighter* s_instance; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/GUST-FONT-LICENSE.txt b/Plugins/ZShell/Llm/fonts/latinmodern/GUST-FONT-LICENSE.txt new file mode 100644 index 0000000..d656332 --- /dev/null +++ b/Plugins/ZShell/Llm/fonts/latinmodern/GUST-FONT-LICENSE.txt @@ -0,0 +1,40 @@ +% This is a preliminary version (2006-09-30), barring acceptance from +% the LaTeX Project Team and other feedback, of the GUST Font License. +% (GUST is the Polish TeX Users Group, http://www.gust.org.pl) +% +% For the most recent version of this license see +% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt +% or +% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt +% +% This work may be distributed and/or modified under the conditions +% of the LaTeX Project Public License, either version 1.3c of this +% license or (at your option) any later version. +% +% Please also observe the following clause: +% 1) it is requested, but not legally required, that derived works be +% distributed only after changing the names of the fonts comprising this +% work and given in an accompanying "manifest", and that the +% files comprising the Work, as listed in the manifest, also be given +% new names. Any exceptions to this request are also given in the +% manifest. +% +% We recommend the manifest be given in a separate file named +% MANIFEST-.txt, where is some unique identification +% of the font family. If a separate "readme" file accompanies the Work, +% we recommend a name of the form README-.txt. +% +% The latest version of the LaTeX Project Public License is in +% http://www.latex-project.org/lppl.txt and version 1.3c or later +% is part of all distributions of LaTeX version 2006/05/20 or later. + + +--- + +Provenance: + lmroman10-*.otf: Latin Modern v2.007 (GUST, 31-03-2026) + https://www.gust.org.pl/projects/e-foundry/latin-modern/download + (Latin_Modern-otf-2_007-31_03_2026.zip) + latinmodern-math.otf: Latin Modern Math v1.959 (GUST) + https://www.gust.org.pl/projects/e-foundry/lm-math/download + (latinmodern-math-1959.zip; same release as CTAN fonts/lm-math) diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/latinmodern-math.otf b/Plugins/ZShell/Llm/fonts/latinmodern/latinmodern-math.otf new file mode 100644 index 0000000..0e4642e Binary files /dev/null and b/Plugins/ZShell/Llm/fonts/latinmodern/latinmodern-math.otf differ diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bold.otf b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bold.otf new file mode 100644 index 0000000..e54ad36 Binary files /dev/null and b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bold.otf differ diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bolditalic.otf b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bolditalic.otf new file mode 100644 index 0000000..b7f00bd Binary files /dev/null and b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bolditalic.otf differ diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-italic.otf b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-italic.otf new file mode 100644 index 0000000..721f973 Binary files /dev/null and b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-italic.otf differ diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-regular.otf b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-regular.otf new file mode 100644 index 0000000..2d10885 Binary files /dev/null and b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-regular.otf differ diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp index 08203d9..bd96d4f 100644 --- a/Plugins/ZShell/Llm/generation.cpp +++ b/Plugins/ZShell/Llm/generation.cpp @@ -10,34 +10,92 @@ ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) m_timer.setInterval(500); m_timer.setTimerType(Qt::CoarseTimer); connect(&m_timer, &QTimer::timeout, this, [this]() { - if (!reasoningInFlight() && !contentInFlight()) { - m_timer.stop(); - return; + bool anyRunning = false; + for (auto* segment : m_segments) { + if (!segment->running()) + continue; + anyRunning = true; + segment->elapsedMsChanged(); } - Q_EMIT elapsedMsChanged(); + if (anyRunning) + Q_EMIT elapsedMsChanged(); + if (!anyRunning && !m_streaming) + m_timer.stop(); }); } +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()); + } + return parts.join(QStringLiteral("\n\n")); +} + +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()); + } + return parts.join(QStringLiteral("\n\n")); +} + +bool ChatGeneration::reasoningActive() const { + if (!m_streaming) + return false; + if (!content().isEmpty()) + return false; + return !hasRunningTool(); +} + qint64 ChatGeneration::reasoningElapsedMs() const { - if (m_reasoningStartedAt <= 0) - return 0; - const qint64 end = m_reasoningEndedAt > 0 - ? m_reasoningEndedAt - : QDateTime::currentMSecsSinceEpoch(); - return end - m_reasoningStartedAt; + qint64 total = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::Reasoning) + total += segment->elapsedMs(); + return total; } qint64 ChatGeneration::contentElapsedMs() const { - if (m_contentStartedAt <= 0) - return 0; - const qint64 end = m_contentEndedAt > 0 - ? m_contentEndedAt - : QDateTime::currentMSecsSinceEpoch(); - return end - m_contentStartedAt; + qint64 total = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::Content) + total += segment->elapsedMs(); + return total; +} + +qint64 ChatGeneration::toolsElapsedMs() const { + qint64 total = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::ToolCall) + total += segment->elapsedMs(); + return total; +} + +int ChatGeneration::toolCallCount() const { + int count = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::ToolCall) + ++count; + return count; +} + +bool ChatGeneration::hasRunningTool() const { + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::ToolCall && + segment->running()) + return true; + return false; } void ChatGeneration::updateReasoningActive() { - const bool active = m_streaming && m_content.isEmpty(); + const bool active = reasoningActive(); if (m_reasoningActive == active) return; m_reasoningActive = active; @@ -45,60 +103,51 @@ void ChatGeneration::updateReasoningActive() { } void ChatGeneration::setContent(const QString& value) { - if (m_content == value) - return; - m_content = value; - Q_EMIT contentChanged(); -} - -void ChatGeneration::setReasoning(const QString& value) { - if (m_reasoning == value) - return; - m_reasoning = value; - Q_EMIT reasoningChanged(); + // Replaces the entire answer: the first content segment takes the + // new text, any later content bursts are cleared. + LlmSegment* first = nullptr; + for (auto* segment : m_segments) { + if (segment->type() != LlmSegment::Type::Content) + continue; + if (!first) + first = segment; + else + segment->setText(QString()); + } + if (!first) { + // Do not materialize an empty content segment (e.g. the assistant + // placeholder created before the stream starts). + if (value.isEmpty()) + return; + first = new LlmSegment( + LlmSegment::Type::Content, + QDateTime::currentMSecsSinceEpoch(), + this); + addSegment(first); + } + first->setText(value); } void ChatGeneration::appendContent(const QString& piece) { if (piece.isEmpty()) return; - if (m_content.isEmpty()) { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (reasoningInFlight()) - m_reasoningEndedAt = now; - m_contentStartedAt = now; - if (!m_timer.isActive()) - m_timer.start(); + for (auto* segment : m_segments) { + if (segment->type() == LlmSegment::Type::Reasoning && + segment->running()) + segment->close(); } - m_content += piece; - Q_EMIT contentChanged(); - updateReasoningActive(); - Q_EMIT elapsedMsChanged(); + openContentSegment()->appendText(piece); } void ChatGeneration::appendReasoning(const QString& piece) { if (piece.isEmpty()) return; - if (m_reasoning.isEmpty()) { - if (m_reasoningStartedAt <= 0) - m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); - if (!m_timer.isActive()) - m_timer.start(); - } - m_reasoning += piece; - Q_EMIT reasoningChanged(); - updateReasoningActive(); - Q_EMIT elapsedMsChanged(); -} - -void ChatGeneration::setElapsedMs(qint64 reasoningMs, qint64 contentMs) { - if (reasoningMs > 0) { - m_reasoningStartedAt = m_timestamp; - m_reasoningEndedAt = m_timestamp + reasoningMs; - } - if (contentMs > 0) { - m_contentStartedAt = m_timestamp; - m_contentEndedAt = m_timestamp + contentMs; + for (auto* segment : m_segments) { + if (segment->type() == LlmSegment::Type::Content && + segment->running()) + segment->close(); } + openReasoningSegment()->appendText(piece); } void ChatGeneration::setStreaming(bool value) { @@ -107,19 +156,94 @@ void ChatGeneration::setStreaming(bool value) { m_streaming = value; Q_EMIT streamingChanged(); if (value) { - if (m_reasoningStartedAt <= 0) - m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); if (!m_timer.isActive()) m_timer.start(); } else { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (reasoningInFlight()) - m_reasoningEndedAt = now; - if (contentInFlight()) - m_contentEndedAt = now; + closeOpenSegments(); m_timer.stop(); - Q_EMIT elapsedMsChanged(); } + Q_EMIT elapsedMsChanged(); + 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; +} + +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( + 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::Running); + segment->begin(); + addSegment(segment); + Q_EMIT toolStateChanged(); + return segment; +} + +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) + Q_EMIT reasoningChanged(); + else if (segment->type() == LlmSegment::Type::Content) + Q_EMIT contentChanged(); + updateReasoningActive(); + }); + connect( + segment, &LlmSegment::statusChanged, this, [this]() { + Q_EMIT toolStateChanged(); + }); + connect( + segment, &LlmSegment::resultChanged, this, [this]() { + Q_EMIT toolStateChanged(); + }); + connect( + segment, &LlmSegment::runningChanged, this, [this]() { + Q_EMIT elapsedMsChanged(); + Q_EMIT toolStateChanged(); + updateReasoningActive(); + }); + m_segments.append(segment); + Q_EMIT segmentsChanged(); +} + +void ChatGeneration::closeOpenSegments() { + for (auto* segment : m_segments) + if (segment->running()) + segment->close(); updateReasoningActive(); } diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp index 0ec1a83..c6e093e 100644 --- a/Plugins/ZShell/Llm/generation.hpp +++ b/Plugins/ZShell/Llm/generation.hpp @@ -1,5 +1,8 @@ #pragma once +#include "segment.hpp" + +#include #include #include #include @@ -7,6 +10,11 @@ namespace ZShell::llm { +// One attempt at answering a message: a chronologically ordered list +// of segments. Content bursts, reasoning bursts and tool calls all +// appear in the order the model produced them; a new content (or +// reasoning) segment starts whenever the model switches between them. +// Together with the attempt's aggregate state. class ChatGeneration : public QObject { Q_OBJECT QML_ELEMENT @@ -16,55 +24,71 @@ class ChatGeneration : public QObject { Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged) 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(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY( + QList segments READ segments + NOTIFY segmentsChanged) + Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) public: explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); - [[nodiscard]] QString content() const { return m_content; } - [[nodiscard]] QString reasoning() const { return m_reasoning; } - [[nodiscard]] bool reasoningActive() const { return m_reasoningActive; } + // All content bursts, joined (the model's full answer). + [[nodiscard]] QString content() const; + // Every reasoning burst, joined. + [[nodiscard]] QString reasoning() const; + // True while the model is thinking: streaming, no content yet, and + // no tool call in flight. + [[nodiscard]] bool reasoningActive() const; [[nodiscard]] qint64 reasoningElapsedMs() const; [[nodiscard]] qint64 contentElapsedMs() const; + [[nodiscard]] qint64 toolsElapsedMs() const; [[nodiscard]] bool streaming() const { return m_streaming; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] QList segments() const { return m_segments; } + [[nodiscard]] int toolCallCount() const; + [[nodiscard]] bool hasRunningTool() const; void setContent(const QString& value); - void setReasoning(const QString& value); void appendContent(const QString& piece); void appendReasoning(const QString& piece); - void setElapsedMs(qint64 reasoningMs, qint64 contentMs); void setStreaming(bool value); + // The in-flight content segment, or a fresh one. A new content + // segment starts whenever the model resumes writing after reasoning + // or a tool call. + [[nodiscard]] LlmSegment* openContentSegment(); + // The in-flight reasoning segment, or a fresh one. + [[nodiscard]] LlmSegment* openReasoningSegment(); + // Creates and appends a running tool-call segment. + [[nodiscard]] LlmSegment* beginToolCall( + const QString& name, const QString& toolCallId); + // Appends a segment created by the persistence layer. + void addSegment(LlmSegment* segment); + // Stops the clocks of every in-flight segment. + void closeOpenSegments(); + Q_SIGNALS: void contentChanged(); void reasoningChanged(); void reasoningActiveChanged(); void elapsedMsChanged(); void streamingChanged(); + void toolStateChanged(); + void segmentsChanged(); private: void updateReasoningActive(); - [[nodiscard]] bool reasoningInFlight() const { - return m_reasoningStartedAt > 0 && m_reasoningEndedAt <= 0; - } - [[nodiscard]] bool contentInFlight() const { - return m_contentStartedAt > 0 && m_contentEndedAt <= 0; - } - QTimer m_timer; - QString m_content; - QString m_reasoning; + QList m_segments; bool m_reasoningActive = false; bool m_streaming = false; qint64 m_timestamp; - qint64 m_reasoningStartedAt = 0; - qint64 m_reasoningEndedAt = 0; - qint64 m_contentStartedAt = 0; - qint64 m_contentEndedAt = 0; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/highlight-queries/bash.scm b/Plugins/ZShell/Llm/highlight-queries/bash.scm new file mode 100644 index 0000000..a45327d --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/bash.scm @@ -0,0 +1,59 @@ +; Vendored from bash (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-bash + +[ + (string) + (raw_string) + (heredoc_body) + (heredoc_start) +] @string + +(command_name) @function + +(variable_name) @property + +[ + "case" + "do" + "done" + "elif" + "else" + "esac" + "export" + "fi" + "for" + "function" + "if" + "in" + "select" + "then" + "unset" + "until" + "while" +] @keyword + +(comment) @comment + +(function_definition name: (word) @function) + +(file_descriptor) @number + +[ + (command_substitution) + (process_substitution) + (expansion) +]@embedded + +[ + "$" + "&&" + ">" + ">>" + "<" + "|" +] @operator + +( + (command (_) @constant) + (#match? @constant "^-") +) diff --git a/Plugins/ZShell/Llm/highlight-queries/c.scm b/Plugins/ZShell/Llm/highlight-queries/c.scm new file mode 100644 index 0000000..c9652e5 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/c.scm @@ -0,0 +1,84 @@ +; Vendored from c (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-c + +(identifier) @variable + +((identifier) @constant + (#match? @constant "^[A-Z][A-Z\\d_]*$")) + +"break" @keyword +"case" @keyword +"const" @keyword +"continue" @keyword +"default" @keyword +"do" @keyword +"else" @keyword +"enum" @keyword +"extern" @keyword +"for" @keyword +"if" @keyword +"inline" @keyword +"return" @keyword +"sizeof" @keyword +"static" @keyword +"struct" @keyword +"switch" @keyword +"typedef" @keyword +"union" @keyword +"volatile" @keyword +"while" @keyword + +"#define" @keyword +"#elif" @keyword +"#else" @keyword +"#endif" @keyword +"#if" @keyword +"#ifdef" @keyword +"#ifndef" @keyword +"#include" @keyword +(preproc_directive) @keyword + +"--" @operator +"-" @operator +"-=" @operator +"->" @operator +"=" @operator +"!=" @operator +"*" @operator +"&" @operator +"&&" @operator +"+" @operator +"++" @operator +"+=" @operator +"<" @operator +"==" @operator +">" @operator +"||" @operator + +"." @delimiter +";" @delimiter + +(string_literal) @string +(system_lib_string) @string + +(null) @constant +(number_literal) @number +(char_literal) @number + +(field_identifier) @property +(statement_identifier) @label +(type_identifier) @type +(primitive_type) @type +(sized_type_specifier) @type + +(call_expression + function: (identifier) @function) +(call_expression + function: (field_expression + field: (field_identifier) @function)) +(function_declarator + declarator: (identifier) @function) +(preproc_function_def + name: (identifier) @function.special) + +(comment) @comment diff --git a/Plugins/ZShell/Llm/highlight-queries/cpp.scm b/Plugins/ZShell/Llm/highlight-queries/cpp.scm new file mode 100644 index 0000000..127c6a9 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/cpp.scm @@ -0,0 +1,73 @@ +; Vendored from cpp (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-cpp (tag v0.23.4, matching the distro grammar) + +; Functions + +(call_expression + function: (qualified_identifier + name: (identifier) @function)) + +(template_function + name: (identifier) @function) + +(template_method + name: (field_identifier) @function) + +(template_function + name: (identifier) @function) + +(function_declarator + declarator: (qualified_identifier + name: (identifier) @function)) + +(function_declarator + declarator: (field_identifier) @function) + +; Types + +((namespace_identifier) @type + (#match? @type "^[A-Z]")) + +(auto) @type + +; Constants + +(this) @variable.builtin +(null "nullptr" @constant) + +; Keywords + +[ + "catch" + "class" + "co_await" + "co_return" + "co_yield" + "constexpr" + "constinit" + "consteval" + "delete" + "explicit" + "final" + "friend" + "mutable" + "namespace" + "noexcept" + "new" + "override" + "private" + "protected" + "public" + "template" + "throw" + "try" + "typename" + "using" + "concept" + "requires" + "virtual" +] @keyword + +; Strings + +(raw_string_literal) @string diff --git a/Plugins/ZShell/Llm/highlight-queries/go.scm b/Plugins/ZShell/Llm/highlight-queries/go.scm new file mode 100644 index 0000000..481d5cc --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/go.scm @@ -0,0 +1,126 @@ +; Vendored from go (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-go + +; Function calls + +(call_expression + function: (identifier) @function) + +(call_expression + function: (identifier) @function.builtin + (#match? @function.builtin "^(append|cap|close|complex|copy|delete|imag|len|make|new|panic|print|println|real|recover)$")) + +(call_expression + function: (selector_expression + field: (field_identifier) @function.method)) + +; Function definitions + +(function_declaration + name: (identifier) @function) + +(method_declaration + name: (field_identifier) @function.method) + +; Identifiers + +(type_identifier) @type +(field_identifier) @property +(identifier) @variable + +; Operators + +[ + "--" + "-" + "-=" + ":=" + "!" + "!=" + "..." + "*" + "*" + "*=" + "/" + "/=" + "&" + "&&" + "&=" + "%" + "%=" + "^" + "^=" + "+" + "++" + "+=" + "<-" + "<" + "<<" + "<<=" + "<=" + "=" + "==" + ">" + ">=" + ">>" + ">>=" + "|" + "|=" + "||" + "~" +] @operator + +; Keywords + +[ + "break" + "case" + "chan" + "const" + "continue" + "default" + "defer" + "else" + "fallthrough" + "for" + "func" + "go" + "goto" + "if" + "import" + "interface" + "map" + "package" + "range" + "return" + "select" + "struct" + "switch" + "type" + "var" +] @keyword + +; Literals + +[ + (interpreted_string_literal) + (raw_string_literal) + (rune_literal) +] @string + +(escape_sequence) @escape + +[ + (int_literal) + (float_literal) + (imaginary_literal) +] @number + +[ + (true) + (false) + (nil) + (iota) +] @constant.builtin + +(comment) @comment diff --git a/Plugins/ZShell/Llm/highlight-queries/javascript.scm b/Plugins/ZShell/Llm/highlight-queries/javascript.scm new file mode 100644 index 0000000..92b1a52 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/javascript.scm @@ -0,0 +1,207 @@ +; Vendored from javascript (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-javascript + +; Variables +;---------- + +(identifier) @variable + +; Properties +;----------- + +(property_identifier) @property + +; Function and method definitions +;-------------------------------- + +(function_expression + name: (identifier) @function) +(function_declaration + name: (identifier) @function) +(method_definition + name: (property_identifier) @function.method) + +(pair + key: (property_identifier) @function.method + value: [(function_expression) (arrow_function)]) + +(assignment_expression + left: (member_expression + property: (property_identifier) @function.method) + right: [(function_expression) (arrow_function)]) + +(variable_declarator + name: (identifier) @function + value: [(function_expression) (arrow_function)]) + +(assignment_expression + left: (identifier) @function + right: [(function_expression) (arrow_function)]) + +; Function and method calls +;-------------------------- + +(call_expression + function: (identifier) @function) + +(call_expression + function: (member_expression + property: (property_identifier) @function.method)) + +; Special identifiers +;-------------------- + +((identifier) @constructor + (#match? @constructor "^[A-Z]")) + +([ + (identifier) + (shorthand_property_identifier) + (shorthand_property_identifier_pattern) + ] @constant + (#match? @constant "^[A-Z_][A-Z\\d_]+$")) + +((identifier) @variable.builtin + (#match? @variable.builtin "^(arguments|module|console|window|document)$") + (#is-not? local)) + +((identifier) @function.builtin + (#eq? @function.builtin "require") + (#is-not? local)) + +; Literals +;--------- + +(this) @variable.builtin +(super) @variable.builtin + +[ + (true) + (false) + (null) + (undefined) +] @constant.builtin + +(comment) @comment + +[ + (string) + (template_string) +] @string + +(regex) @string.special +(number) @number + +; Tokens +;------- + +[ + ";" + (optional_chain) + "." + "," +] @punctuation.delimiter + +[ + "-" + "--" + "-=" + "+" + "++" + "+=" + "*" + "*=" + "**" + "**=" + "/" + "/=" + "%" + "%=" + "<" + "<=" + "<<" + "<<=" + "=" + "==" + "===" + "!" + "!=" + "!==" + "=>" + ">" + ">=" + ">>" + ">>=" + ">>>" + ">>>=" + "~" + "^" + "&" + "|" + "^=" + "&=" + "|=" + "&&" + "||" + "??" + "&&=" + "||=" + "??=" +] @operator + +[ + "(" + ")" + "[" + "]" + "{" + "}" +] @punctuation.bracket + +(template_substitution + "${" @punctuation.special + "}" @punctuation.special) @embedded + +[ + "as" + "async" + "await" + "break" + "case" + "catch" + "class" + "const" + "continue" + "debugger" + "default" + "delete" + "do" + "else" + "export" + "extends" + "finally" + "for" + "from" + "function" + "get" + "if" + "import" + "in" + "instanceof" + "let" + "new" + "of" + "return" + "set" + "static" + "switch" + "target" + "throw" + "try" + "typeof" + "var" + "void" + "while" + "with" + "yield" +] @keyword diff --git a/Plugins/ZShell/Llm/highlight-queries/json.scm b/Plugins/ZShell/Llm/highlight-queries/json.scm new file mode 100644 index 0000000..ede99aa --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/json.scm @@ -0,0 +1,19 @@ +; Vendored from json (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-json + +(pair + key: (_) @string.special.key) + +(string) @string + +(number) @number + +[ + (null) + (true) + (false) +] @constant.builtin + +(escape_sequence) @escape + +(comment) @comment diff --git a/Plugins/ZShell/Llm/highlight-queries/python.scm b/Plugins/ZShell/Llm/highlight-queries/python.scm new file mode 100644 index 0000000..fd315a9 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/python.scm @@ -0,0 +1,140 @@ +; Vendored from python (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-python + +; Identifier naming conventions + +(identifier) @variable + +((identifier) @constructor + (#match? @constructor "^[A-Z]")) + +((identifier) @constant + (#match? @constant "^[A-Z][A-Z_]*$")) + +; Function calls + +(decorator) @function +(decorator + (identifier) @function) + +(call + function: (attribute attribute: (identifier) @function.method)) +(call + function: (identifier) @function) + +; Builtin functions + +((call + function: (identifier) @function.builtin) + (#match? + @function.builtin + "^(abs|all|any|ascii|bin|bool|breakpoint|bytearray|bytes|callable|chr|classmethod|compile|complex|delattr|dict|dir|divmod|enumerate|eval|exec|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|isinstance|issubclass|iter|len|list|locals|map|max|memoryview|min|next|object|oct|open|ord|pow|print|property|range|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|vars|zip|__import__)$")) + +; Function definitions + +(function_definition + name: (identifier) @function) + +(attribute attribute: (identifier) @property) +(type (identifier) @type) + +; Literals + +[ + (none) + (true) + (false) +] @constant.builtin + +[ + (integer) + (float) +] @number + +(comment) @comment +(string) @string +(escape_sequence) @escape + +(interpolation + "{" @punctuation.special + "}" @punctuation.special) @embedded + +[ + "-" + "-=" + "!=" + "*" + "**" + "**=" + "*=" + "/" + "//" + "//=" + "/=" + "&" + "&=" + "%" + "%=" + "^" + "^=" + "+" + "->" + "+=" + "<" + "<<" + "<<=" + "<=" + "<>" + "=" + ":=" + "==" + ">" + ">=" + ">>" + ">>=" + "|" + "|=" + "~" + "@=" + "and" + "in" + "is" + "not" + "or" + "is not" + "not in" +] @operator + +[ + "as" + "assert" + "async" + "await" + "break" + "class" + "continue" + "def" + "del" + "elif" + "else" + "except" + "exec" + "finally" + "for" + "from" + "global" + "if" + "import" + "lambda" + "nonlocal" + "pass" + "print" + "raise" + "return" + "try" + "while" + "with" + "yield" + "match" + "case" +] @keyword diff --git a/Plugins/ZShell/Llm/highlight-queries/rust.scm b/Plugins/ZShell/Llm/highlight-queries/rust.scm new file mode 100644 index 0000000..c1acc9e --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/rust.scm @@ -0,0 +1,164 @@ +; Vendored from rust (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-rust + +; Identifiers + +(type_identifier) @type +(primitive_type) @type.builtin +(field_identifier) @property + +; Identifier conventions + +; Assume all-caps names are constants +((identifier) @constant + (#match? @constant "^[A-Z][A-Z\\d_]+$'")) + +; Assume uppercase names are enum constructors +((identifier) @constructor + (#match? @constructor "^[A-Z]")) + +; Assume that uppercase names in paths are types +((scoped_identifier + path: (identifier) @type) + (#match? @type "^[A-Z]")) +((scoped_identifier + path: (scoped_identifier + name: (identifier) @type)) + (#match? @type "^[A-Z]")) +((scoped_type_identifier + path: (identifier) @type) + (#match? @type "^[A-Z]")) +((scoped_type_identifier + path: (scoped_identifier + name: (identifier) @type)) + (#match? @type "^[A-Z]")) + +; Assume all qualified names in struct patterns are enum constructors. (They're +; either that, or struct names; highlighting both as constructors seems to be +; the less glaring choice of error, visually.) +(struct_pattern + type: (scoped_type_identifier + name: (type_identifier) @constructor)) + +; Function calls + +(call_expression + function: (identifier) @function) +(call_expression + function: (field_expression + field: (field_identifier) @function.method)) +(call_expression + function: (scoped_identifier + "::" + name: (identifier) @function)) + +(generic_function + function: (identifier) @function) +(generic_function + function: (scoped_identifier + name: (identifier) @function)) +(generic_function + function: (field_expression + field: (field_identifier) @function.method)) + +(macro_invocation + macro: (identifier) @function.macro + "!" @function.macro) + +; Function definitions + +(function_item (identifier) @function) +(function_signature_item (identifier) @function) + +(line_comment) @comment +(block_comment) @comment + +(line_comment (doc_comment)) @comment.documentation +(block_comment (doc_comment)) @comment.documentation + +"(" @punctuation.bracket +")" @punctuation.bracket +"[" @punctuation.bracket +"]" @punctuation.bracket +"{" @punctuation.bracket +"}" @punctuation.bracket + +(type_arguments + "<" @punctuation.bracket + ">" @punctuation.bracket) +(type_parameters + "<" @punctuation.bracket + ">" @punctuation.bracket) + +"::" @punctuation.delimiter +":" @punctuation.delimiter +"." @punctuation.delimiter +"," @punctuation.delimiter +";" @punctuation.delimiter + +(parameter (identifier) @variable.parameter) + +(lifetime (identifier) @label) + +"as" @keyword +"async" @keyword +"await" @keyword +"break" @keyword +"const" @keyword +"continue" @keyword +"default" @keyword +"dyn" @keyword +"else" @keyword +"enum" @keyword +"extern" @keyword +"fn" @keyword +"for" @keyword +"gen" @keyword +"if" @keyword +"impl" @keyword +"in" @keyword +"let" @keyword +"loop" @keyword +"macro_rules!" @keyword +"match" @keyword +"mod" @keyword +"move" @keyword +"pub" @keyword +"raw" @keyword +"ref" @keyword +"return" @keyword +"static" @keyword +"struct" @keyword +"trait" @keyword +"type" @keyword +"union" @keyword +"unsafe" @keyword +"use" @keyword +"where" @keyword +"while" @keyword +"yield" @keyword +(crate) @keyword +(mutable_specifier) @keyword +(use_list (self) @keyword) +(scoped_use_list (self) @keyword) +(scoped_identifier (self) @keyword) +(super) @keyword + +(self) @variable.builtin + +(char_literal) @string +(string_literal) @string +(raw_string_literal) @string + +(boolean_literal) @constant.builtin +(integer_literal) @constant.builtin +(float_literal) @constant.builtin + +(escape_sequence) @escape + +(attribute_item) @attribute +(inner_attribute_item) @attribute + +"*" @operator +"&" @operator +"'" @operator diff --git a/Plugins/ZShell/Llm/highlight-queries/sql.scm b/Plugins/ZShell/Llm/highlight-queries/sql.scm new file mode 100644 index 0000000..de1438a --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/sql.scm @@ -0,0 +1,463 @@ +; Vendored from sql (MIT License) +; Source: https://github.com/DerekStride/tree-sitter-sql + +(object_reference + name: (identifier) @type) + +(invocation + (object_reference + name: (identifier) @function.call)) + +[ + (keyword_gist) + (keyword_btree) + (keyword_hash) + (keyword_spgist) + (keyword_gin) + (keyword_brin) + (keyword_array) + (keyword_object_id) +] @function.call + +(relation + alias: (identifier) @variable) + +(field + name: (identifier) @field) + +(term + alias: (identifier) @variable) + +((term + value: (cast + name: (keyword_cast) @function.call + parameter: [(literal)]?))) + +(literal) @string +(comment) @comment @spell +(marginalia) @comment + +((literal) @number + (#match? @number "^[-+]?%d+$")) + +((literal) @float + (#match? @float "^[-+]?%d*\.%d*$")) + +(parameter) @parameter + +[ + (keyword_true) + (keyword_false) +] @boolean + +[ + (keyword_asc) + (keyword_desc) + (keyword_terminated) + (keyword_escaped) + (keyword_unsigned) + (keyword_nulls) + (keyword_last) + (keyword_delimited) + (keyword_replication) + (keyword_auto_increment) + (keyword_default) + (keyword_collate) + (keyword_concurrently) + (keyword_engine) + (keyword_always) + (keyword_generated) + (keyword_preceding) + (keyword_following) + (keyword_first) + (keyword_current_timestamp) + (keyword_immutable) + (keyword_atomic) + (keyword_parallel) + (keyword_leakproof) + (keyword_safe) + (keyword_cost) + (keyword_strict) +] @attribute + +[ + (keyword_materialized) + (keyword_recursive) + (keyword_temp) + (keyword_temporary) + (keyword_unlogged) + (keyword_external) + (keyword_parquet) + (keyword_csv) + (keyword_rcfile) + (keyword_textfile) + (keyword_orc) + (keyword_avro) + (keyword_jsonfile) + (keyword_sequencefile) + (keyword_volatile) +] @storageclass + +[ + (keyword_case) + (keyword_when) + (keyword_then) + (keyword_else) +] @conditional + +[ + (keyword_select) + (keyword_from) + (keyword_where) + (keyword_index) + (keyword_join) + (keyword_primary) + (keyword_delete) + (keyword_create) + (keyword_show) + (keyword_unload) + (keyword_insert) + (keyword_merge) + (keyword_distinct) + (keyword_replace) + (keyword_update) + (keyword_into) + (keyword_overwrite) + (keyword_matched) + (keyword_values) + (keyword_value) + (keyword_attribute) + (keyword_set) + (keyword_left) + (keyword_right) + (keyword_outer) + (keyword_inner) + (keyword_full) + (keyword_order) + (keyword_partition) + (keyword_group) + (keyword_with) + (keyword_without) + (keyword_as) + (keyword_having) + (keyword_limit) + (keyword_offset) + (keyword_table) + (keyword_tables) + (keyword_key) + (keyword_references) + (keyword_foreign) + (keyword_constraint) + (keyword_force) + (keyword_use) + (keyword_include) + (keyword_for) + (keyword_if) + (keyword_exists) + (keyword_column) + (keyword_columns) + (keyword_cross) + (keyword_lateral) + (keyword_natural) + (keyword_alter) + (keyword_drop) + (keyword_add) + (keyword_view) + (keyword_end) + (keyword_is) + (keyword_using) + (keyword_between) + (keyword_window) + (keyword_no) + (keyword_data) + (keyword_type) + (keyword_rename) + (keyword_refresh) + (keyword_to) + (keyword_schema) + (keyword_owner) + (keyword_authorization) + (keyword_all) + (keyword_any) + (keyword_some) + (keyword_returning) + (keyword_begin) + (keyword_commit) + (keyword_rollback) + (keyword_transaction) + (keyword_only) + (keyword_like) + (keyword_rlike) + (keyword_similar) + (keyword_over) + (keyword_change) + (keyword_modify) + (keyword_after) + (keyword_before) + (keyword_range) + (keyword_rows) + (keyword_groups) + (keyword_exclude) + (keyword_current) + (keyword_ties) + (keyword_others) + (keyword_zerofill) + (keyword_format) + (keyword_fields) + (keyword_row) + (keyword_sort) + (keyword_compute) + (keyword_comment) + (keyword_location) + (keyword_cached) + (keyword_uncached) + (keyword_lines) + (keyword_stored) + (keyword_virtual) + (keyword_partitioned) + (keyword_analyze) + (keyword_explain) + (keyword_verbose) + (keyword_truncate) + (keyword_rewrite) + (keyword_optimize) + (keyword_vacuum) + (keyword_cache) + (keyword_language) + (keyword_called) + (keyword_conflict) + (keyword_declare) + (keyword_filter) + (keyword_function) + (keyword_input) + (keyword_name) + (keyword_oid) + (keyword_oids) + (keyword_precision) + (keyword_regclass) + (keyword_regnamespace) + (keyword_regproc) + (keyword_regtype) + (keyword_restricted) + (keyword_return) + (keyword_returns) + (keyword_separator) + (keyword_setof) + (keyword_stable) + (keyword_support) + (keyword_tblproperties) + (keyword_trigger) + (keyword_unsafe) + (keyword_admin) + (keyword_connection) + (keyword_cycle) + (keyword_database) + (keyword_encrypted) + (keyword_increment) + (keyword_logged) + (keyword_none) + (keyword_owned) + (keyword_password) + (keyword_reset) + (keyword_role) + (keyword_current_role) + (keyword_sequence) + (keyword_start) + (keyword_restart) + (keyword_tablespace) + (keyword_split) + (keyword_tablets) + (keyword_until) + (keyword_user) + (keyword_current_user) + (keyword_session_user) + (keyword_valid) + (keyword_action) + (keyword_definer) + (keyword_invoker) + (keyword_enable) + (keyword_disable) + (keyword_security) + (keyword_policy) + (keyword_permissive) + (keyword_restrictive) + (keyword_public) + (keyword_extension) + (keyword_version) + (keyword_out) + (keyword_inout) + (keyword_variadic) + (keyword_ordinality) + (keyword_session) + (keyword_isolation) + (keyword_level) + (keyword_serializable) + (keyword_repeatable) + (keyword_read) + (keyword_write) + (keyword_committed) + (keyword_uncommitted) + (keyword_deferrable) + (keyword_names) + (keyword_zone) + (keyword_immediate) + (keyword_deferred) + (keyword_constraints) + (keyword_snapshot) + (keyword_characteristics) + (keyword_off) + (keyword_follows) + (keyword_precedes) + (keyword_each) + (keyword_instead) + (keyword_of) + (keyword_initially) + (keyword_old) + (keyword_new) + (keyword_referencing) + (keyword_statement) + (keyword_execute) + (keyword_procedure) + (keyword_copy) + (keyword_delimiter) + (keyword_encoding) + (keyword_escape) + (keyword_force_not_null) + (keyword_force_null) + (keyword_force_quote) + (keyword_freeze) + (keyword_header) + (keyword_match) + (keyword_program) + (keyword_quote) + (keyword_stdin) + (keyword_extended) + (keyword_main) + (keyword_plain) + (keyword_storage) + (keyword_compression) + (keyword_duplicate) + (keyword_while) +] @keyword + +[ + (keyword_restrict) + (keyword_unbounded) + (keyword_unique) + (keyword_cascade) + (keyword_delayed) + (keyword_high_priority) + (keyword_low_priority) + (keyword_ignore) + (keyword_nothing) + (keyword_check) + (keyword_option) + (keyword_local) + (keyword_cascaded) + (keyword_wait) + (keyword_nowait) + (keyword_metadata) + (keyword_incremental) + (keyword_bin_pack) + (keyword_noscan) + (keyword_stats) + (keyword_statistics) + (keyword_maxvalue) + (keyword_minvalue) +] @type.qualifier + +[ + (keyword_int) + (keyword_null) + (keyword_boolean) + (keyword_binary) + (keyword_varbinary) + (keyword_image) + (keyword_bit) + (keyword_inet) + (keyword_character) + (keyword_smallserial) + (keyword_serial) + (keyword_bigserial) + (keyword_smallint) + (keyword_mediumint) + (keyword_bigint) + (keyword_tinyint) + (keyword_decimal) + (keyword_float) + (keyword_double) + (keyword_numeric) + (keyword_real) + (double) + (keyword_money) + (keyword_smallmoney) + (keyword_char) + (keyword_nchar) + (keyword_varchar) + (keyword_nvarchar) + (keyword_varying) + (keyword_text) + (keyword_string) + (keyword_uuid) + (keyword_json) + (keyword_jsonb) + (keyword_xml) + (keyword_bytea) + (keyword_enum) + (keyword_date) + (keyword_datetime) + (keyword_time) + (keyword_datetime2) + (keyword_datetimeoffset) + (keyword_smalldatetime) + (keyword_timestamp) + (keyword_timestamptz) + (keyword_geometry) + (keyword_geography) + (keyword_box2d) + (keyword_box3d) + (keyword_interval) +] @type.builtin + +[ + (keyword_in) + (keyword_and) + (keyword_or) + (keyword_not) + (keyword_by) + (keyword_on) + (keyword_do) + (keyword_union) + (keyword_except) + (keyword_intersect) +] @keyword.operator + +[ + "+" + "-" + "*" + "/" + "%" + "^" + ":=" + "=" + "<" + "<=" + "!=" + ">=" + ">" + "<>" + (op_other) + (op_unary_other) +] @operator + +[ + "(" + ")" +] @punctuation.bracket + +[ + ";" + "," + "." +] @punctuation.delimiter diff --git a/Plugins/ZShell/Llm/highlight-queries/toml.scm b/Plugins/ZShell/Llm/highlight-queries/toml.scm new file mode 100644 index 0000000..b26f3a9 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/toml.scm @@ -0,0 +1,36 @@ +; Vendored from toml (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-toml + +; Properties +;----------- + +(bare_key) @property +(quoted_key) @string + +; Literals +;--------- + +(boolean) @constant.builtin +(comment) @comment +(string) @string +(integer) @number +(float) @number +(offset_date_time) @string.special +(local_date_time) @string.special +(local_date) @string.special +(local_time) @string.special + +; Punctuation +;------------ + +"." @punctuation.delimiter +"," @punctuation.delimiter + +"=" @operator + +"[" @punctuation.bracket +"]" @punctuation.bracket +"[[" @punctuation.bracket +"]]" @punctuation.bracket +"{" @punctuation.bracket +"}" @punctuation.bracket diff --git a/Plugins/ZShell/Llm/highlight-queries/typescript.scm b/Plugins/ZShell/Llm/highlight-queries/typescript.scm new file mode 100644 index 0000000..72500ee --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/typescript.scm @@ -0,0 +1,38 @@ +; Vendored from typescript (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-typescript + +; Types + +(type_identifier) @type +(predefined_type) @type.builtin + +((identifier) @type + (#match? @type "^[A-Z]")) + +(type_arguments + "<" @punctuation.bracket + ">" @punctuation.bracket) + +; Variables + +(required_parameter (identifier) @variable.parameter) +(optional_parameter (identifier) @variable.parameter) + +; Keywords + +[ "abstract" + "declare" + "enum" + "export" + "implements" + "interface" + "keyof" + "namespace" + "private" + "protected" + "public" + "type" + "readonly" + "override" + "satisfies" +] @keyword diff --git a/Plugins/ZShell/Llm/highlight-queries/yaml.scm b/Plugins/ZShell/Llm/highlight-queries/yaml.scm new file mode 100644 index 0000000..bbfa7e6 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/yaml.scm @@ -0,0 +1,82 @@ +; Vendored from yaml (MIT License) +; Source: https://github.com/tree-sitter-grammars/tree-sitter-yaml + +(boolean_scalar) @boolean + +(null_scalar) @constant.builtin + +[ + (double_quote_scalar) + (single_quote_scalar) + (block_scalar) + (string_scalar) +] @string + +[ + (integer_scalar) + (float_scalar) +] @number + +(comment) @comment + +[ + (anchor_name) + (alias_name) +] @label + +(tag) @type + +[ + (yaml_directive) + (tag_directive) + (reserved_directive) +] @attribute + +(block_mapping_pair + key: (flow_node + [ + (double_quote_scalar) + (single_quote_scalar) + ] @property)) + +(block_mapping_pair + key: (flow_node + (plain_scalar + (string_scalar) @property))) + +(flow_mapping + (_ + key: (flow_node + [ + (double_quote_scalar) + (single_quote_scalar) + ] @property))) + +(flow_mapping + (_ + key: (flow_node + (plain_scalar + (string_scalar) @property)))) + +[ + "," + "-" + ":" + ">" + "?" + "|" +] @punctuation.delimiter + +[ + "[" + "]" + "{" + "}" +] @punctuation.bracket + +[ + "*" + "&" + "---" + "..." +] @punctuation.special diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index 5f4e1a5..b898456 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -1,7 +1,9 @@ #include "llmclient.hpp" +#include "generation.hpp" #include "message.hpp" #include "messagemodel.hpp" +#include "segment.hpp" #include "session.hpp" #include @@ -62,12 +64,19 @@ void LlmClient::setStreamingChatId(const QString& id) { } LlmClient::LlmClient(QObject* parent) : QObject(parent) { + m_tools = new ToolRegistry(this); + connect( + m_tools, + &ToolRegistry::enabledChanged, + this, + &LlmClient::toolsEnabledChanged); probeContextSize(); } LlmClient::~LlmClient() { if (m_reply) m_reply->abort(); + m_tools->cancelAll(); endStream(); } @@ -111,10 +120,6 @@ void LlmClient::startGeneration( return; } - QNetworkRequest request(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Accept", "text/event-stream"); - const auto* model = session->messagesModel(); const int targetRow = model->rowOf(qobject_cast(target->parent())); @@ -124,25 +129,50 @@ void LlmClient::startGeneration( return; } - // Context: every message older than the target, oldest first. - QJsonArray messages; - for (int row = model->rowCount() - 1; row > targetRow; --row) { - const auto* message = model->at(row); - const auto* generation = message->activeGeneration(); - if (!generation) - continue; - QJsonObject messageObj; - messageObj[QStringLiteral("role")] = - message->role() == ChatMessage::Role::User - ? QStringLiteral("user") - : QStringLiteral("assistant"); - messageObj[QStringLiteral("content")] = generation->content(); - if (!generation->reasoning().isEmpty()) - messageObj[QStringLiteral("reasoning_content")] = - generation->reasoning(); - messages.append(messageObj); + m_transcript = QJsonArray(); + m_callBuilders.clear(); + m_callResults.clear(); + m_finishReason.clear(); + m_round = 0; + m_contentMark = 0; + m_reasoningMark = 0; + + sendRound(); +} + +void LlmClient::sendRound() { + if (!m_active || !m_streaming) + return; + m_finishReason.clear(); + m_callBuilders.clear(); + m_callResults.clear(); + m_roundDone = false; + m_toolPhase = false; + m_pendingCalls = 0; + m_buffer.clear(); + + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) { + fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); + return; } + const auto* model = m_active->messagesModel(); + const int targetRow = + model->rowOf(qobject_cast(m_streaming->parent())); + if (targetRow < 0) { + fail(QStringLiteral("Internal error: generation target is not in the " + "session")); + return; + } + + // Context: every message older than the target, oldest first, plus + // the tool exchanges of the current turn so far. + QJsonArray messages = buildContextMessages(m_active, targetRow); + for (const QJsonValue& value : m_transcript) + messages.append(value); + QJsonObject body; QJsonObject streamOptions; streamOptions[QStringLiteral("include_usage")] = true; @@ -152,8 +182,14 @@ void LlmClient::startGeneration( body[QStringLiteral("temperature")] = m_temperature; if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model; + const QJsonArray toolSpecs = m_tools->specifications(); + if (!toolSpecs.isEmpty()) + body[QStringLiteral("tools")] = toolSpecs; + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "text/event-stream"); - m_buffer.clear(); m_reply = m_manager.post(request, QJsonDocument(body).toJson()); connect(m_reply, &QNetworkReply::readyRead, this, [this]() { @@ -178,14 +214,493 @@ void LlmClient::startGeneration( if (!m_streaming) return; - if (error == QNetworkReply::NoError || - error == QNetworkReply::OperationCanceledError) - finalize(); + if (error == QNetworkReply::NoError) + roundFinished(); + else if (error == QNetworkReply::OperationCanceledError) + finishTurn(); else fail(serverErrorMessage(responseBody, errorString)); }); } +QJsonArray LlmClient::buildContextMessages( + ChatSession* session, int stopBeforeRow) const { + const auto* model = session->messagesModel(); + QJsonArray messages; + for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { + const auto* message = model->at(row); + const auto* generation = message->activeGeneration(); + if (!generation) + continue; + + if (message->role() == ChatMessage::Role::User) { + QJsonObject user; + user[QStringLiteral("role")] = QStringLiteral("user"); + user[QStringLiteral("content")] = generation->content(); + messages.append(user); + continue; + } + + // Assistant message: replay its tool calls (and their results) + // so the model keeps the full history of the turn. + QList toolSegments; + for (const auto* segment : generation->segments()) + if (segment->type() == LlmSegment::Type::ToolCall) + toolSegments.append(segment); + + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + if (toolSegments.isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + else if (!generation->content().isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + if (!generation->reasoning().isEmpty()) + assistant[QStringLiteral("reasoning_content")] = + generation->reasoning(); + + if (toolSegments.isEmpty()) { + messages.append(assistant); + continue; + } + QJsonArray calls; + for (const auto* segment : toolSegments) { + QJsonObject function; + function[QStringLiteral("name")] = segment->name(); + function[QStringLiteral("arguments")] = segment->arguments(); + QJsonObject call; + call[QStringLiteral("id")] = segment->toolCallId(); + call[QStringLiteral("type")] = QStringLiteral("function"); + call[QStringLiteral("function")] = function; + calls.append(call); + } + assistant[QStringLiteral("tool_calls")] = calls; + messages.append(assistant); + for (const auto* segment : toolSegments) { + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = + segment->toolCallId(); + toolMessage[QStringLiteral("content")] = segment->result(); + messages.append(toolMessage); + } + } + return messages; +} + +void LlmClient::applyToolCallDelta(const QJsonObject& call) { + if (!m_streaming) + return; + const int index = call[QStringLiteral("index")].toInt(-1); + if (index < 0) + return; + while (m_callBuilders.size() <= index) + m_callBuilders.append(ToolCallBuilder{}); + ToolCallBuilder& builder = m_callBuilders[index]; + builder.seen = true; + const QString id = call[QStringLiteral("id")].toString(); + if (!id.isEmpty()) + builder.id = id; + const QJsonObject function = call[QStringLiteral("function")].toObject(); + const QString name = function[QStringLiteral("name")].toString(); + if (!name.isEmpty()) + builder.name = name; + const QString arguments = + function[QStringLiteral("arguments")].toString(); + if (!arguments.isEmpty()) + builder.arguments += arguments; + + if (!builder.segment) { + // A new call: close the in-flight text segments and open a + // running tool-call segment so the UI can track it live. + m_streaming->closeOpenSegments(); + builder.segment = + m_streaming->beginToolCall(builder.name, builder.id); + } + builder.segment->setName(builder.name); + builder.segment->setToolCallId(builder.id); + if (!arguments.isEmpty()) + builder.segment->appendArguments(arguments); +} + +void LlmClient::roundFinished() { + // [DONE] and the reply's finished signal both funnel here; only the + // first may act. + if (m_roundDone || !m_streaming) + return; + m_roundDone = true; + + bool hasCalls = false; + for (const auto& builder : m_callBuilders) + if (builder.seen) + hasCalls = true; + if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) { + finishTurn(); + return; + } + if (m_round >= kMaxToolRounds) { + qWarning() << "LlmClient: tool round limit reached, ending turn"; + finishTurn(); + return; + } + + m_streaming->closeOpenSegments(); + + // Record the assistant's tool-call message in the transcript so the + // next round (and the model) can see it. + const QString content = m_streaming->content(); + const QString reasoning = m_streaming->reasoning(); + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + 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 auto& builder : m_callBuilders) { + if (!builder.seen) + continue; + QJsonObject function; + function[QStringLiteral("name")] = builder.name; + function[QStringLiteral("arguments")] = builder.arguments; + QJsonObject call; + call[QStringLiteral("id")] = builder.id; + call[QStringLiteral("type")] = QStringLiteral("function"); + call[QStringLiteral("function")] = function; + calls.append(call); + } + assistant[QStringLiteral("tool_calls")] = calls; + m_transcript.append(assistant); + m_round++; + + executeAllCalls(); +} + +void LlmClient::executeAllCalls() { + m_toolPhase = true; + m_pendingCalls = 0; + m_callResults = QList(m_callBuilders.size()); + + for (int i = 0; i < m_callBuilders.size(); ++i) { + const auto& call = m_callBuilders.at(i); + if (!call.seen) + continue; + + LlmTool* tool = m_tools->tool(call.name); + QJsonObject args; + QString errorText; + if (!tool) { + errorText = QStringLiteral("Error: unknown tool '%1'") + .arg(call.name); + } else if (!call.arguments.isEmpty()) { + const QJsonDocument doc = + QJsonDocument::fromJson(call.arguments.toUtf8()); + if (!doc.isObject()) { + errorText = + QStringLiteral("Error: tool arguments are not valid " + "JSON: %1") + .arg(call.arguments); + } else { + args = doc.object(); + } + } + if (!errorText.isEmpty()) { + m_callResults[i] = { errorText, false }; + if (LlmSegment* segment = call.segment) + segment->finishTool(errorText, false); + continue; + } + + ++m_pendingCalls; + tool->execute( + args, + [this, i, call](const QJsonObject& result) { + if (!m_streaming) + return; + const bool success = + result.contains(QStringLiteral("output")); + const QString content = success + ? result[QStringLiteral("output")].toString() + : QStringLiteral("Error: ") + + result[QStringLiteral("error")].toString(); + m_callResults[i] = { content, success }; + if (LlmSegment* segment = call.segment) + segment->finishTool(content, success); + if (--m_pendingCalls == 0) + flushCallResults(); + }); + } + if (m_pendingCalls == 0) + flushCallResults(); +} + +void LlmClient::flushCallResults() { + if (!m_toolPhase) + return; + m_toolPhase = false; + if (!m_streaming) + return; + for (int i = 0; i < m_callBuilders.size(); ++i) { + const auto& call = m_callBuilders.at(i); + if (!call.seen) + continue; + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = call.id; + toolMessage[QStringLiteral("content")] = + m_callResults.at(i).content; + m_transcript.append(toolMessage); + } + sendRound(); +} + +void LlmClient::stop() { + if (!m_busy) + return; + if (m_toolPhase) { + m_tools->cancelAll(); + if (m_streaming) { + for (auto* segment : m_streaming->segments()) { + if (segment->type() == LlmSegment::Type::ToolCall && + segment->running()) + segment->finishTool( + QStringLiteral("Cancelled"), false); + } + } + finishTurn(); + return; + } + if (m_reply) + m_reply->abort(); +} + +void LlmClient::endStream() { + if (!m_streaming) + return; + auto* generation = m_streaming; + auto* session = m_active; + m_streaming = nullptr; + m_active = nullptr; + generation->setStreaming(false); + if (generation->content().isEmpty() && + generation->reasoning().isEmpty() && + generation->toolCallCount() == 0) { + if (auto* message = qobject_cast(generation->parent())) { + if (message->generationCount() <= 1) { + if (session) + session->removeMessage(message); + } else { + message->removeGeneration(generation); + } + } + } + setBusy(false); + setStreamingChatId(QString()); +} + +void LlmClient::finishTurn() { + if (!m_streaming) + return; + ChatSession* session = m_active; + endStream(); + if (session && m_pendingClear == session) { + session->clearMessages(); + m_pendingClear.clear(); + } + if (session) + session->persist(); +} + +void LlmClient::clearOnFinish(ChatSession* session) { + m_pendingClear = session; +} + +void LlmClient::sessionRemoved(ChatSession* session) { + if (m_pendingClear == session) + m_pendingClear.clear(); + if (m_active == session) { + stop(); + endStream(); + } +} + +void LlmClient::fail(const QString& message) { + qWarning() << "LlmClient:" << message; + finishTurn(); + Q_EMIT errorOccurred(message); +} + +void LlmClient::drainBuffer() { + while (true) { + const qsizetype newline = m_buffer.indexOf('\n'); + if (newline < 0) + break; + const QByteArray line = m_buffer.left(newline).trimmed(); + m_buffer.remove(0, newline + 1); + handleLine(line); + } +} + +void LlmClient::handleLine(const QByteArray& line) { + if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) + return; + + const QByteArray data = line.mid(5).trimmed(); + if (data == "[DONE]") { + roundFinished(); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (!doc.isObject()) + return; + const QJsonObject obj = doc.object(); + updateTokenUsage(obj); + + if (obj.contains("error")) { + const QJsonObject error = obj["error"].toObject(); + const QString message = error["message"].toString(); + fail(message.isEmpty() + ? QStringLiteral("LLM server returned an error") + : message); + return; + } + + for (const QJsonValue& choiceValue : obj["choices"].toArray()) { + if (!m_streaming) + continue; + const QJsonObject choice = choiceValue.toObject(); + const QJsonObject delta = choice["delta"].toObject(); + + const QString finishReason = + choice[QStringLiteral("finish_reason")].toString(); + if (!finishReason.isEmpty()) + m_finishReason = finishReason; + + m_streaming->appendContent(delta["content"].toString()); + QString reasoning = + delta["reasoning_content"].toString(); + if (reasoning.isEmpty()) + reasoning = delta["reasoning"].toString(); + m_streaming->appendReasoning(reasoning); + + for (const QJsonValue& callValue : + delta["tool_calls"].toArray()) { + if (!m_streaming) + break; + applyToolCallDelta(callValue.toObject()); + } + } +} + +void LlmClient::refreshModels() { + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (error != QNetworkReply::NoError) { + qWarning() << "LlmClient: failed to fetch models:" << error; + return; + } + const QJsonArray dataArr = + QJsonDocument::fromJson(data).object()["data"].toArray(); + + QStringList models; + QSet seen; + for (const QJsonValue& value : dataArr) { + const QString id = value.toObject()["id"].toString(); + if (!id.isEmpty() && !seen.contains(id)) { + seen.insert(id); + models.append(id); + } + } + if (models.isEmpty()) + return; + + m_availableModels = models; + Q_EMIT availableModelsChanged(); + + if (m_model.isEmpty()) { + m_model = models.first(); + Q_EMIT modelChanged(); + } + }); +} + +void LlmClient::setContextSize(int size) { + if (size <= 0 || m_contextSize == size) + return; + m_contextSize = size; + Q_EMIT contextSizeChanged(); +} + +void LlmClient::probeContextSize() { + // llama.cpp-specific endpoint; other servers fall back to 4096. + QString base = m_endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + const QUrl url = QUrl::fromUserInput(base + "/props"); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect( + reply, + &QNetworkReply::finished, + this, + [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + int size = 0; + if (error == QNetworkReply::NoError) { + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isArray()) { + for (const auto& value : doc.array()) { + const QJsonObject slot = value.toObject(); + if (slot.contains("n_ctx")) { + size = slot["n_ctx"].toInt(0); + if (size > 0) + break; + } + } + } else if (doc.isObject()) { + const QJsonObject obj = doc.object(); + size = obj["n_ctx"].toInt(0); + if (size <= 0) + size = obj["default_generation_settings"].toObject() + ["n_ctx"].toInt(0); + } + } + setContextSize(size > 0 ? size : 4096); + }); +} + +void LlmClient::updateTokenUsage(const QJsonObject& data) { + if (!m_active || m_contextSize <= 0) + return; + const QJsonObject usage = data["usage"].toObject(); + if (usage.isEmpty()) + return; + const double used = + usage.value("prompt_tokens").toDouble() + + usage.value("completion_tokens").toDouble(); + if (used > 0) + m_active->setLastTokenCount(static_cast(used)); +} + void LlmClient::shortRequest( const QString& tag, const QString& systemPrompt, @@ -340,229 +855,4 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) { }); } -void LlmClient::stop() { - if (!m_busy) - return; - if (m_reply) - m_reply->abort(); -} - -void LlmClient::endStream() { - if (!m_streaming) - return; - auto* generation = m_streaming; - auto* session = m_active; - m_streaming = nullptr; - m_active = nullptr; - generation->setStreaming(false); - if (generation->content().isEmpty() && - generation->reasoning().isEmpty()) { - if (auto* message = qobject_cast(generation->parent())) { - if (message->generationCount() <= 1) { - if (session) - session->removeMessage(message); - } else { - message->removeGeneration(generation); - } - } - } - setBusy(false); - setStreamingChatId(QString()); -} - -void LlmClient::finalize() { - if (!m_streaming) - return; - ChatSession* session = m_active; - endStream(); - if (session && m_pendingClear == session) { - session->clearMessages(); - m_pendingClear.clear(); - } - if (session) - session->persist(); -} - -void LlmClient::clearOnFinish(ChatSession* session) { - m_pendingClear = session; -} - -void LlmClient::sessionRemoved(ChatSession* session) { - if (m_pendingClear == session) - m_pendingClear.clear(); - if (m_active == session) { - stop(); - endStream(); - } -} - -void LlmClient::fail(const QString& message) { - qWarning() << "LlmClient:" << message; - - if (m_streaming) { - ChatSession* session = m_active; - endStream(); - if (session && m_pendingClear == session) { - session->clearMessages(); - m_pendingClear.clear(); - } - if (session) - session->persist(); - } - Q_EMIT errorOccurred(message); -} - -void LlmClient::drainBuffer() { - while (true) { - const qsizetype newline = m_buffer.indexOf('\n'); - if (newline < 0) - break; - const QByteArray line = m_buffer.left(newline).trimmed(); - m_buffer.remove(0, newline + 1); - handleLine(line); - } -} - -void LlmClient::handleLine(const QByteArray& line) { - if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) - return; - - const QByteArray data = line.mid(5).trimmed(); - if (data == "[DONE]") { - finalize(); - return; - } - - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isObject()) - return; - const QJsonObject obj = doc.object(); - updateTokenUsage(obj); - - if (obj.contains("error")) { - const QJsonObject error = obj["error"].toObject(); - const QString message = error["message"].toString(); - fail(message.isEmpty() - ? QStringLiteral("LLM server returned an error") - : message); - return; - } - - for (const QJsonValue& choiceValue : obj["choices"].toArray()) { - if (!m_streaming) - continue; - const QJsonObject delta = - choiceValue.toObject()["delta"].toObject(); - m_streaming->appendContent(delta["content"].toString()); - QString reasoning = delta["reasoning_content"].toString(); - if (reasoning.isEmpty()) - reasoning = delta["reasoning"].toString(); - m_streaming->appendReasoning(reasoning); - } -} - -void LlmClient::refreshModels() { - const QUrl url = - QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); - if (!url.isValid() || url.host().isEmpty()) - return; - - auto* reply = m_manager.get(QNetworkRequest(url)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QNetworkReply::NetworkError error = reply->error(); - const QByteArray data = reply->readAll(); - reply->deleteLater(); - - if (error != QNetworkReply::NoError) { - qWarning() << "LlmClient: failed to fetch models:" << error; - return; - } - const QJsonArray dataArr = - QJsonDocument::fromJson(data).object()["data"].toArray(); - - QStringList models; - QSet seen; - for (const QJsonValue& value : dataArr) { - const QString id = value.toObject()["id"].toString(); - if (!id.isEmpty() && !seen.contains(id)) { - seen.insert(id); - models.append(id); - } - } - if (models.isEmpty()) - return; - - m_availableModels = models; - Q_EMIT availableModelsChanged(); - - if (m_model.isEmpty()) { - m_model = models.first(); - Q_EMIT modelChanged(); - } - }); -} - -void LlmClient::setContextSize(int size) { - if (size <= 0 || m_contextSize == size) - return; - m_contextSize = size; - Q_EMIT contextSizeChanged(); -} - -void LlmClient::probeContextSize() { - // llama.cpp-specific endpoint; other servers fall back to 4096. - QString base = m_endpoint.trimmed(); - while (base.endsWith('/')) - base.chop(1); - const QUrl url = QUrl::fromUserInput(base + "/props"); - if (!url.isValid() || url.host().isEmpty()) - return; - - auto* reply = m_manager.get(QNetworkRequest(url)); - connect( - reply, - &QNetworkReply::finished, - this, - [this, reply]() { - const QNetworkReply::NetworkError error = reply->error(); - const QByteArray data = reply->readAll(); - reply->deleteLater(); - - int size = 0; - if (error == QNetworkReply::NoError) { - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (doc.isArray()) { - for (const auto& value : doc.array()) { - const QJsonObject slot = value.toObject(); - if (slot.contains("n_ctx")) { - size = slot["n_ctx"].toInt(0); - if (size > 0) - break; - } - } - } else if (doc.isObject()) { - const QJsonObject obj = doc.object(); - size = obj["n_ctx"].toInt(0); - if (size <= 0) - size = obj["default_generation_settings"].toObject() - ["n_ctx"].toInt(0); - } - } - setContextSize(size > 0 ? size : 4096); - }); -} - -void LlmClient::updateTokenUsage(const QJsonObject& data) { - if (!m_active || m_contextSize <= 0) - return; - const QJsonObject usage = data["usage"].toObject(); - if (usage.isEmpty()) - return; - const double used = - usage.value("prompt_tokens").toDouble() + - usage.value("completion_tokens").toDouble(); - if (used > 0) - m_active->setLastTokenCount(static_cast(used)); -} - } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index 4f3f339..1397d57 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -1,11 +1,16 @@ #pragma once +#include "segment.hpp" +#include "tool.hpp" + #include +#include #include #include #include #include #include + #include class QJsonObject; @@ -15,9 +20,12 @@ namespace ZShell::llm { class ChatGeneration; class ChatSession; +class LlmSegment; +class LlmTool; // The only component that talks to the LLM server: owns the network -// manager, the in-flight streaming state and the SSE parsing. +// manager, the in-flight streaming state, the SSE parsing and the +// tool-calling loop. class LlmClient : public QObject { Q_OBJECT @@ -32,6 +40,7 @@ class LlmClient : public QObject { return m_availableModels; } [[nodiscard]] int contextSize() const { return m_contextSize; } + [[nodiscard]] ToolRegistry* tools() const { return m_tools; } void setEndpoint(const QString& value); void setModel(const QString& value); void setTemperature(double value); @@ -39,12 +48,16 @@ class LlmClient : public QObject { void probeContextSize(); [[nodiscard]] bool busy() const { return m_busy; } + [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } + void setToolsEnabled(bool value) { m_tools->setEnabled(value); } [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } [[nodiscard]] ChatSession* streamingSession() const { return m_active; } - // Streams a new assistant reply into `target` (the active generation of - // a message of `session`). The context sent to the model is every + // Streams a new assistant reply into `target` (the active generation + // of a message of `session`). The context sent to the model is every // message of the session that is older than the target message. + // Tool calls made by the model are executed and fed back + // transparently until the model produces its final answer. void startGeneration(ChatSession* session, ChatGeneration* target); void stop(); void endStream(); @@ -63,13 +76,39 @@ class LlmClient : public QObject { void modelChanged(); void availableModelsChanged(); void contextSizeChanged(); + void toolsEnabledChanged(); void streamingChatIdChanged(); void errorOccurred(const QString& message); void titleSuggested(ZShell::llm::ChatSession* session, const QString& title); void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon); private: - void finalize(); + struct ToolCallBuilder { + QString id; + QString name; + QString arguments; + QPointer segment; + bool seen = false; + }; + + // Sends one streaming round: context + transcript so far. + void sendRound(); + // The session context for a round, oldest first, ending just before + // `stopBeforeRow` (the message of the generation being streamed). + QJsonArray buildContextMessages( + ChatSession* session, int stopBeforeRow) const; + void applyToolCallDelta(const QJsonObject& call); + // One round's stream ended; either ends the turn or executes the + // requested tool calls and sends the next round. Runs at most once + // per round ([DONE] and the reply's finished signal both reach it). + void roundFinished(); + // Dispatches every call of the round; tools run concurrently. + void executeAllCalls(); + // All results in: appends the tool messages (in call order) and + // sends the next round. + void flushCallResults(); + // Ends the current turn gracefully and persists the session. + void finishTurn(); void fail(const QString& message); void handleLine(const QByteArray& line); void drainBuffer(); @@ -86,6 +125,7 @@ class LlmClient : public QObject { const QByteArray& body, const QString& fallback); QNetworkAccessManager m_manager; + ToolRegistry* m_tools = nullptr; QNetworkReply* m_reply = nullptr; QByteArray m_buffer; ChatSession* m_active = nullptr; @@ -98,6 +138,23 @@ class LlmClient : public QObject { QStringList m_availableModels; double m_temperature = 0.7; int m_contextSize = 0; + + // State of the multi-round tool loop of the current turn. + QJsonArray m_transcript; + QList m_callBuilders; + struct ToolCallResult { + QString content; + bool success = false; + }; + QList m_callResults; + QString m_finishReason; + int m_round = 0; + qsizetype m_contentMark = 0; + qsizetype m_reasoningMark = 0; + bool m_roundDone = false; + bool m_toolPhase = false; + int m_pendingCalls = 0; + static constexpr int kMaxToolRounds = 12; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownblock.hpp b/Plugins/ZShell/Llm/markdownblock.hpp new file mode 100644 index 0000000..cbc6408 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownblock.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace ZShell::llm { + +// Marker class exposing the LlmMarkdown block type enum to QML +// (LlmMarkdown.Type.*). Blocks themselves are value maps produced by +// MarkdownParser and stored on LlmSegment. +class LlmMarkdown : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Blocks are produced by LlmSegment") + + public: + enum class Type : int { + Text = 0, // Paragraph, list, quote, table; "text" is markdown source + Heading, // "level" + "text" (markdown source of the content) + Code, // "language" + "code" + Math // "latex" (display math, without the $$ delimiters) + }; + Q_ENUM(Type) + + explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {} +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownparser.cpp b/Plugins/ZShell/Llm/markdownparser.cpp new file mode 100644 index 0000000..1203d72 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownparser.cpp @@ -0,0 +1,144 @@ +#include "markdownparser.hpp" + +#include "markdownblock.hpp" + +#include +#include + +#include +#include +#include + +namespace ZShell::llm { + +QVariantList MarkdownParser::parse(const QString& source) { + QVariantList blocks; + if (source.trimmed().isEmpty()) + return blocks; + + const QStringList lines = source.split('\n'); + + // cmark-gfm line/column numbers are 1-based and inclusive. + auto sliceSource = [&](int startLine, int endLine) -> QString { + if (startLine < 1 || endLine < startLine) + return QString(); + const int from = startLine; + const int to = qMin(endLine, static_cast(lines.size())); + return lines.mid(from - 1, to - from + 1).join('\n').trimmed(); + }; + + const QByteArray utf8 = source.toUtf8(); + cmark_node* doc = cmark_parse_document( + utf8.constData(), static_cast(utf8.size()), + CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS); + if (!doc) + return blocks; + + // cmark-gfm has no math extension, so $$...$$ parses as an ordinary + // paragraph. Re-detect display math (a $$...$$ pair) here and split it + // out as its own block. Single-$ inline math is intentionally left + // untouched (rendered raw) for now. + const QRegularExpression mathRe( + QStringLiteral("\\$\\$(.+?)\\$\\$"), + QRegularExpression::DotMatchesEverythingOption); + + auto makeBlock = [](LlmMarkdown::Type type) { + QVariantMap block; + block.insert("type", static_cast(type)); + return block; + }; + + auto appendText = [&](const QString& text) { + if (text.trimmed().isEmpty()) + return; + QVariantMap block = makeBlock(LlmMarkdown::Type::Text); + block.insert("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); + 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); + 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); + blocks.append(block); + }; + + for (cmark_node* node = cmark_node_first_child(doc); node; + node = cmark_node_next(node)) { + const cmark_node_type type = cmark_node_get_type(node); + const int startLine = cmark_node_get_start_line(node); + const int endLine = cmark_node_get_end_line(node); + + if (type == CMARK_NODE_CODE_BLOCK) { + const char* literal = cmark_node_get_literal(node); + QString code = literal ? QString::fromUtf8(literal) : QString(); + // Fenced block literals carry a trailing newline; drop one. + if (code.endsWith('\n')) + code.chop(1); + + QString language; + if (const char* info = cmark_node_get_fence_info(node); info) + language = QString::fromUtf8(info).section(' ', 0, 0).trimmed().toLower(); + + appendCode(language, code); + continue; + } + + if (type == CMARK_NODE_HEADING) { + // Inline content only (no leading #), so QML can style by + // level. + const char* content = cmark_node_get_string_content(node); + appendHeading( + cmark_node_get_heading_level(node), + content ? QString::fromUtf8(content).trimmed() : QString()); + continue; + } + + if (type == CMARK_NODE_PARAGRAPH) { + const QString text = sliceSource(startLine, endLine); + int cursor = 0; + bool anyMath = false; + for (auto it = mathRe.globalMatch(text, cursor); it.hasNext(); + it = mathRe.globalMatch(text, cursor)) { + const QRegularExpressionMatch m = it.next(); + anyMath = true; + appendText( + text.mid(cursor, static_cast(m.capturedStart() - cursor))); + appendMath(m.captured(1).trimmed()); + cursor = static_cast(m.capturedEnd()); + } + if (!anyMath) + appendText(text); + else + appendText(text.mid(cursor)); + continue; + } + + // Lists, block quotes, tables, horizontal rules, custom blocks: + // hand the raw markdown source to QML (rendered via + // Text.MarkdownText). + appendText(sliceSource(startLine, endLine)); + } + + cmark_node_free(doc); + return blocks; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownparser.hpp b/Plugins/ZShell/Llm/markdownparser.hpp new file mode 100644 index 0000000..ea739d7 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownparser.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace ZShell::llm { + +// Splits a markdown document into top-level blocks for QML rendering +// (cmark-gfm AST walk). Inline structure is not flattened; Text and +// Heading blocks carry markdown source that QML renders with +// Text.MarkdownText. +// +// Block map keys: +// "type" int (LlmMarkdown::Type) +// "level" int (Heading) +// "language" QString (Code, lowercased, empty when unknown) +// "code" QString (Code) +// "text" QString (Text/Heading, markdown source) +// "latex" QString (Math, without the $$ delimiters) +class MarkdownParser { + public: + [[nodiscard]] static QVariantList parse(const QString& source); +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/mathtext.cpp b/Plugins/ZShell/Llm/mathtext.cpp new file mode 100644 index 0000000..4b87f30 --- /dev/null +++ b/Plugins/ZShell/Llm/mathtext.cpp @@ -0,0 +1,176 @@ +#include "mathtext.hpp" + +#include "latinmodern-fonts.hpp" + +#include + +#include +#include +#include +#include + +namespace ZShell::llm { + +namespace { +// A few px of breathing room around the equation. +constexpr int kRenderMargin = 2; +constexpr unsigned int kResolutionDpi = 96; + +// Registers the embedded Latin Modern faces with the font database +// (once per process) and reports which families became available. +struct LatinModern { + bool roman = false; + bool math = false; +}; + +const LatinModern& loadLatinModern() { + static const LatinModern fonts = [] { + LatinModern result; + // addApplicationFont(QByteArray) fails in this environment, so + // the embedded bytes are staged to a per-process temp file and + // registered through the (stable) file-based API. + const auto add = [](const unsigned char* data, size_t size, + const QString& fileName, const QString& family) { + const QString path = QDir::tempPath() + QLatin1Char('/') + fileName; + { + QFile f(path); + if (!f.open(QIODevice::WriteOnly) || + f.write(reinterpret_cast(data), + static_cast(size)) != static_cast(size)) + return false; + } + const int key = QFontDatabase::addApplicationFont(path); + if (key < 0) + return false; + return QFontDatabase::applicationFontFamilies(key).contains(family); + }; + result.roman = + add(lmfont::lmroman10_regular, sizeof(lmfont::lmroman10_regular), + QStringLiteral("lmroman10-regular.otf"), QStringLiteral("LMRoman10")) + && add(lmfont::lmroman10_italic, sizeof(lmfont::lmroman10_italic), + QStringLiteral("lmroman10-italic.otf"), QStringLiteral("LMRoman10")) + && add(lmfont::lmroman10_bold, sizeof(lmfont::lmroman10_bold), + QStringLiteral("lmroman10-bold.otf"), QStringLiteral("LMRoman10")) + && add(lmfont::lmroman10_bolditalic, sizeof(lmfont::lmroman10_bolditalic), + QStringLiteral("lmroman10-bolditalic.otf"), + QStringLiteral("LMRoman10")); + result.math = add( + lmfont::latinmodern_math, sizeof(lmfont::latinmodern_math), + QStringLiteral("latinmodern-math.otf"), QStringLiteral("Latin Modern Math")); + return result; + }(); + return fonts; +} + +} // namespace + +LlmMathText::LlmMathText(QObject* parent) +: QObject(parent), m_renderer(parent, /* useFontsForGUI */ true) { + // Latin Modern is the default font of modern LaTeX; use the embedded + // faces instead of whatever the system happens to have installed. + const LatinModern& fonts = loadLatinModern(); + if (fonts.roman) + m_renderer.setFontRomanAndMath(QStringLiteral("LMRoman10"), + JKQTMathTextFontEncoding::MTFEUnicode); + if (fonts.math) { + // Same pattern as JKQTMathText's useXITS(): the OpenType math + // font supplies the math alphabet and operators from its MATH + // table. + m_renderer.setFontMathRoman(QStringLiteral("Latin Modern Math"), + JKQTMathTextFontEncoding::MTFEUnicode); + m_renderer.setFallbackFontSymbols( + QStringLiteral("Latin Modern Math"), + JKQTMathTextFontEncoding::MTFEUnicode); + } +} + +void LlmMathText::setLatex(const QString& value) { + if (m_latex == value) + return; + m_latex = value; + reRender(); +} + +void LlmMathText::setColor(const QColor& value) { + if (m_color == value) + return; + m_color = value; + reRender(); +} + +void LlmMathText::setFontPointSize(double value) { + if (qFuzzyCompare(m_fontPointSize, value)) + return; + m_fontPointSize = value; + reRender(); +} + +void LlmMathText::setDevicePixelRatio(qreal value) { + if (qFuzzyCompare(m_devicePixelRatio, value)) + return; + m_devicePixelRatio = value; + reRender(); +} + +void LlmMathText::reRender() { + if (m_latex.trimmed().isEmpty()) { + m_image = QImage(); + m_imageUrl = QUrl(); + m_width = 0; + m_height = 0; + m_ok = false; + Q_EMIT changed(); + return; + } + + m_renderer.setFontPointSize(m_fontPointSize); + m_renderer.setFontColor(m_color); + + const bool ok = m_renderer.parse( + m_latex, + JKQTMathText::LatexParser, + JKQTMathText::DefaultParseOptions); + if (!ok) { + m_image = QImage(); + m_imageUrl = QUrl(); + m_width = 0; + m_height = 0; + m_ok = false; + Q_EMIT changed(); + return; + } + + const QImage image = m_renderer.drawIntoImage( + /* drawBoxes */ false, + QColor(Qt::transparent), + kRenderMargin, + m_devicePixelRatio, + kResolutionDpi); + if (image.isNull()) { + m_image = QImage(); + m_imageUrl = QUrl(); + m_width = 0; + m_height = 0; + m_ok = false; + Q_EMIT changed(); + return; + } + + m_image = image; + QByteArray png; + { + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "PNG"); + } + m_imageUrl = QUrl( + QStringLiteral("data:image/png;base64,") + QString::fromLatin1(png.toBase64())); + // drawIntoImage renders at devicePixelRatio; convert back to + // logical pixels. + m_width = image.width() / m_devicePixelRatio; + m_height = image.height() / m_devicePixelRatio; + m_ok = true; + Q_EMIT changed(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/mathtext.hpp b/Plugins/ZShell/Llm/mathtext.hpp new file mode 100644 index 0000000..3b713fc --- /dev/null +++ b/Plugins/ZShell/Llm/mathtext.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace ZShell::llm { + +// QML wrapper around JKQTMathText (JKQtPlotter's LaTeX renderer). +// +// Parses a display-math string and renders it into a transparent +// QImage at the given device pixel ratio. QML displays the image +// (scaling it to the bubble width when needed) and falls back to the +// raw LaTeX when parsing fails. +class LlmMathText : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString latex READ latex WRITE setLatex NOTIFY changed) + Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY changed) + Q_PROPERTY(double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY changed) + Q_PROPERTY(qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio NOTIFY changed) + Q_PROPERTY(QImage image READ image NOTIFY changed) + // data: URL of the rendered equation; usable directly as + // Image.source (a raw QImage is not). + Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed) + // Logical (CSS pixel) size of the rendered equation. + Q_PROPERTY(qreal width READ width NOTIFY changed) + Q_PROPERTY(qreal height READ height NOTIFY changed) + Q_PROPERTY(bool ok READ ok NOTIFY changed) + + public: + explicit LlmMathText(QObject* parent = nullptr); + + [[nodiscard]] QString latex() const { return m_latex; } + void setLatex(const QString& value); + [[nodiscard]] QColor color() const { return m_color; } + void setColor(const QColor& value); + [[nodiscard]] double fontPointSize() const { return m_fontPointSize; } + void setFontPointSize(double value); + [[nodiscard]] qreal devicePixelRatio() const { return m_devicePixelRatio; } + void setDevicePixelRatio(qreal value); + [[nodiscard]] QImage image() const { return m_image; } + [[nodiscard]] QUrl imageUrl() const { return m_imageUrl; } + [[nodiscard]] qreal width() const { return m_width; } + [[nodiscard]] qreal height() const { return m_height; } + [[nodiscard]] bool ok() const { return m_ok; } + + Q_SIGNALS: + void changed(); + + private: + void reRender(); + + JKQTMathText m_renderer; + QString m_latex; + QColor m_color; + double m_fontPointSize = 12.0; + qreal m_devicePixelRatio = 1.0; + QImage m_image; + QUrl m_imageUrl; + qreal m_width = 0; + qreal m_height = 0; + bool m_ok = false; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index b0cde7c..6c5f2b5 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -18,16 +18,8 @@ ChatSession* sessionOf(const ChatMessage* message) { ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) : QObject(parent), m_role(role), m_timestamp(timestamp) {} -ChatGeneration* ChatMessage::addGeneration( - qint64 timestamp, - const QString& content, - const QString& reasoning, - qint64 reasoningElapsedMs, - qint64 contentElapsedMs) { +ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) { auto* generation = new ChatGeneration(timestamp, this); - generation->setContent(content); - generation->setReasoning(reasoning); - generation->setElapsedMs(reasoningElapsedMs, contentElapsedMs); m_generations.append(generation); if (m_active < 0) m_active = static_cast(m_generations.size() - 1); @@ -36,8 +28,7 @@ ChatGeneration* ChatMessage::addGeneration( } ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) { - auto* generation = - addGeneration(timestamp, QString(), QString(), 0, 0); + auto* generation = addGeneration(timestamp); setActiveInternal(static_cast(m_generations.size() - 1)); return generation; } diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp index 19abc4a..5fbaaf5 100644 --- a/Plugins/ZShell/Llm/message.hpp +++ b/Plugins/ZShell/Llm/message.hpp @@ -58,12 +58,8 @@ class ChatMessage : public QObject { Q_INVOKABLE void retry(); Q_INVOKABLE void generate(); - ChatGeneration* addGeneration( - qint64 timestamp, - const QString& content, - const QString& reasoning, - qint64 reasoningElapsedMs, - qint64 contentElapsedMs); + // Creates an empty generation; callers fill it with segments. + ChatGeneration* addGeneration(qint64 timestamp); ChatGeneration* appendGeneration(qint64 timestamp); void removeGeneration(ChatGeneration* generation); diff --git a/Plugins/ZShell/Llm/messagemodel.cpp b/Plugins/ZShell/Llm/messagemodel.cpp index 03f40b8..534c409 100644 --- a/Plugins/ZShell/Llm/messagemodel.cpp +++ b/Plugins/ZShell/Llm/messagemodel.cpp @@ -44,7 +44,8 @@ ChatMessage* ChatMessageModel::createMessage( ChatMessage* ChatMessageModel::appendNewest( ChatMessage::Role role, const QString& content, qint64 timestamp) { auto* message = new ChatMessage(role, timestamp, this); - message->addGeneration(timestamp, content, QString(), 0, 0); + auto* generation = message->addGeneration(timestamp); + generation->setContent(content); beginInsertRows(QModelIndex(), 0, 0); m_messages.prepend(message); diff --git a/Plugins/ZShell/Llm/segment.cpp b/Plugins/ZShell/Llm/segment.cpp new file mode 100644 index 0000000..c0a66a0 --- /dev/null +++ b/Plugins/ZShell/Llm/segment.cpp @@ -0,0 +1,138 @@ +#include "segment.hpp" + +#include "markdownparser.hpp" + +#include + +namespace ZShell::llm { + +namespace { +// Re-parse cadence while a segment streams; content between refreshes is +// at most this stale. +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; + m_markdownDirty = false; + parseMarkdown(); + }); +} + +qint64 LlmSegment::elapsedMs() const { + if (m_startedAt <= 0) + return 0; + const qint64 end = m_endedAt > 0 ? m_endedAt + : QDateTime::currentMSecsSinceEpoch(); + return end - m_startedAt; +} + +void LlmSegment::begin() { + if (m_running) + return; + m_running = true; + m_startedAt = QDateTime::currentMSecsSinceEpoch(); + m_endedAt = 0; + Q_EMIT runningChanged(); + Q_EMIT elapsedMsChanged(); +} + +void LlmSegment::close() { + if (m_running) { + m_running = false; + m_endedAt = QDateTime::currentMSecsSinceEpoch(); + Q_EMIT runningChanged(); + Q_EMIT elapsedMsChanged(); + } + // The segment is final; parse immediately so the UI does not wait + // out the debounce. + if (m_type == Type::Content && m_markdownDirty) { + m_markdownTimer.stop(); + parseMarkdown(); + } +} + +void LlmSegment::appendText(const QString& piece) { + if (piece.isEmpty()) + return; + m_text += piece; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmSegment::setText(const QString& value) { + if (m_text == value) + return; + m_text = value; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmSegment::setName(const QString& value) { + if (m_name == value) + return; + m_name = value; + Q_EMIT nameChanged(); +} + +void LlmSegment::setToolCallId(const QString& value) { + if (m_toolCallId == value) + return; + m_toolCallId = value; + Q_EMIT toolCallIdChanged(); +} + +void LlmSegment::appendArguments(const QString& piece) { + if (piece.isEmpty()) + return; + m_arguments += piece; + Q_EMIT argumentsChanged(); +} + +void LlmSegment::setResult(const QString& value) { + if (m_result == value) + return; + m_result = value; + Q_EMIT resultChanged(); +} + +void LlmSegment::setStatus(Status value) { + if (m_status == value) + return; + m_status = value; + Q_EMIT statusChanged(); +} + +void LlmSegment::finishTool(const QString& resultText, bool success) { + setResult(resultText); + setStatus(success ? Status::Success : Status::Error); + close(); +} + +void LlmSegment::restore(qint64 elapsedMs) { + m_startedAt = m_timestamp; + m_endedAt = m_timestamp + qMax(0, elapsedMs); +} + +void LlmSegment::scheduleMarkdown() { + // Content segments only; reasoning/tool output is never parsed. + // (User content is parsed too, so it can later be rendered as blocks + // as well; the QML currently only does that for assistant messages.) + if (m_type != Type::Content) + return; + m_markdownDirty = true; + if (!m_markdownTimer.isActive()) + m_markdownTimer.start(); +} + +void LlmSegment::parseMarkdown() { + m_markdown = MarkdownParser::parse(m_text); + Q_EMIT markdownChanged(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp new file mode 100644 index 0000000..9033e32 --- /dev/null +++ b/Plugins/ZShell/Llm/segment.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +// One unit of activity within a ChatGeneration. A generation keeps its +// segments in chronological order: zero or more reasoning bursts and +// tool calls interleaved, plus at most one content segment holding the +// final answer. +class LlmSegment : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Segments are managed by ChatGeneration") + + 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) + // Parsed markdown blocks (QVariantList of maps, see MarkdownParser). + // Only Content segments are parsed. Refreshes are debounced so a + // streaming segment re-parses at a steady cadence rather than per + // chunk. + Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged) + + public: + enum class Type : int { + Reasoning = 0, + ToolCall, + Content + }; + Q_ENUM(Type) + + enum class Status : int { + None = 0, + Running, + Success, + Error + }; + Q_ENUM(Status) + + explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); + + [[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; } + + // Starts the segment's clock; a no-op while already running. + void begin(); + // Stops the segment's clock; a no-op when not running. + 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); + // Completes a tool call with the model-facing result text. + void finishTool(const QString& resultText, bool success); + // Restores persisted timing without a live clock. + void restore(qint64 elapsedMs); + + Q_SIGNALS: + void textChanged(); + void markdownChanged(); + void nameChanged(); + void toolCallIdChanged(); + void argumentsChanged(); + void resultChanged(); + void statusChanged(); + void runningChanged(); + void elapsedMsChanged(); + + private: + void scheduleMarkdown(); + void parseMarkdown(); + + 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; + QTimer m_markdownTimer; + bool m_markdownDirty = false; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp new file mode 100644 index 0000000..15a9c25 --- /dev/null +++ b/Plugins/ZShell/Llm/tool.cpp @@ -0,0 +1,59 @@ +#include "tool.hpp" + +namespace ZShell::llm { + +LlmTool::LlmTool(QObject* parent) : QObject(parent) {} + +LlmTool::~LlmTool() = default; + +void LlmTool::cancel() {} + +QJsonObject LlmTool::specification() const { + QJsonObject function; + function[QStringLiteral("name")] = name(); + function[QStringLiteral("description")] = description(); + function[QStringLiteral("parameters")] = parameters(); + QJsonObject spec; + spec[QStringLiteral("type")] = QStringLiteral("function"); + spec[QStringLiteral("function")] = function; + return spec; +} + +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); + m_tools.append(tool); +} + +LlmTool* ToolRegistry::tool(const QString& name) const { + for (const auto* tool : m_tools) + if (tool->name() == name) + return const_cast(tool); + return nullptr; +} + +QJsonArray ToolRegistry::specifications() const { + if (!m_enabled) + return {}; + QJsonArray specs; + for (const auto* tool : m_tools) + specs.append(tool->specification()); + return specs; +} + +void ToolRegistry::cancelAll() { + for (auto* tool : m_tools) + tool->cancel(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp new file mode 100644 index 0000000..ef2195c --- /dev/null +++ b/Plugins/ZShell/Llm/tool.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace ZShell::llm { + +// A capability the model may invoke mid-turn. Tools run asynchronously +// and report exactly one result: `{"output": ...}` on success or +// `{"error": ...}` on failure. +class LlmTool : public QObject { + Q_OBJECT + + public: + explicit LlmTool(QObject* parent = nullptr); + ~LlmTool() override; + + [[nodiscard]] virtual QString name() const = 0; + [[nodiscard]] virtual QString description() const = 0; + // JSON Schema describing the tool's `arguments` object. + [[nodiscard]] virtual QJsonObject parameters() const = 0; + + // Runs the tool; `done` is invoked exactly once, with + // `{"output": ...}` on success or `{"error": ...}` on failure. + // `done` must be invoked asynchronously (on a later event loop + // iteration), never synchronously within execute(). + virtual void execute( + const QJsonObject& args, + std::function done) = 0; + // Abandons in-flight work, if any. + virtual void cancel(); + + // The OpenAI-compatible `tools` entry for this tool. + [[nodiscard]] QJsonObject specification() const; +}; + +// Owns the set of tools available to the model. +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); + + // Takes ownership; tools become children of the registry. + void registerTool(LlmTool* tool); + [[nodiscard]] LlmTool* tool(const QString& name) const; + // The request body's `tools` array; empty while disabled. + [[nodiscard]] QJsonArray specifications() const; + // Abandons in-flight work in every tool. + void cancelAll(); + + Q_SIGNALS: + void enabledChanged(); + + private: + bool m_enabled = true; + QList m_tools; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp new file mode 100644 index 0000000..bc573f8 --- /dev/null +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -0,0 +1,428 @@ +#include "webfetchtool.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +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() { + for (auto* job : m_jobs) { + if (job->timer) + job->timer->stop(); + if (job->reply) + job->reply->abort(); + } + // Pending result callbacks are dropped; the client is going away. + qDeleteAll(m_jobs); +} + +QString WebFetchTool::name() const { + return QStringLiteral("webfetch"); +} + +QString WebFetchTool::description() const { + return QStringLiteral( + "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. This tool is read-only."); +} + +QJsonObject WebFetchTool::parameters() const { + QJsonObject url; + url[QStringLiteral("type")] = QStringLiteral("string"); + url[QStringLiteral("description")] = + QStringLiteral("The HTTP or HTTPS URL to fetch content from"); + + QJsonArray formats; + formats.append(QStringLiteral("text")); + formats.append(QStringLiteral("html")); + QJsonObject format; + format[QStringLiteral("type")] = QStringLiteral("string"); + format[QStringLiteral("enum")] = formats; + format[QStringLiteral("description")] = + QStringLiteral("The format to return the content in. Defaults to " + "text."); + + QJsonObject timeout; + timeout[QStringLiteral("type")] = QStringLiteral("integer"); + timeout[QStringLiteral("minimum")] = 1; + timeout[QStringLiteral("maximum")] = MaxTimeoutSeconds; + timeout[QStringLiteral("description")] = + QStringLiteral("Optional timeout in seconds"); + + QJsonObject properties; + properties[QStringLiteral("url")] = url; + properties[QStringLiteral("format")] = format; + properties[QStringLiteral("timeout")] = timeout; + + QJsonObject schema; + schema[QStringLiteral("type")] = QStringLiteral("object"); + schema[QStringLiteral("properties")] = properties; + QJsonArray required; + required.append(QStringLiteral("url")); + schema[QStringLiteral("required")] = required; + return schema; +} + +void WebFetchTool::completeJob(Job* job, QJsonObject result) { + // Deliver on a later event loop iteration; LlmClient relies on tool + // results never arriving synchronously within execute(). + QMetaObject::invokeMethod( + this, + [this, job, result = std::move(result)]() mutable { + if (!m_jobs.contains(job)) + return; + auto done = std::move(job->done); + m_jobs.removeAll(job); + job->timer->deleteLater(); + if (job->reply) + job->reply->deleteLater(); + delete job; + done(std::move(result)); + }, + Qt::QueuedConnection); +} + +void WebFetchTool::execute( + const QJsonObject& args, std::function done) { + auto* job = new Job; + job->done = std::move(done); + m_jobs.append(job); + + auto fail = [this, job](const QString& message) { + job->timer->stop(); + completeJob(job, makeError(message)); + }; + + const QString urlText = args[QStringLiteral("url")].toString().trimmed(); + const QUrl url = QUrl::fromUserInput(urlText); + if (!url.isValid() || url.host().isEmpty()) { + fail(QStringLiteral("Invalid URL: %1").arg(urlText)); + return; + } + if (url.scheme() != QLatin1String("http") && + url.scheme() != QLatin1String("https")) { + fail(QStringLiteral("URL must use http:// or https://")); + return; + } + + job->format = + args[QStringLiteral("format")].toString(QStringLiteral("text")); + if (job->format != QLatin1String("html")) + job->format = QStringLiteral("text"); + + const int timeoutMs = qBound( + 1, + args[QStringLiteral("timeout")].toInt( + DefaultTimeoutSeconds), + MaxTimeoutSeconds) * + 1000; + + job->timer = new QTimer(this); + job->timer->setSingleShot(true); + connect( + job->timer, &QTimer::timeout, this, [this, job]() { + if (job->reply) + job->reply->abort(); + }); + job->timer->start(timeoutMs); + + QNetworkRequest request(url); + request.setRawHeader("User-Agent", QByteArray(kUserAgent)); + request.setRawHeader( + "Accept", + job->format == QLatin1String("html") + ? "text/html;q=1.0, application/xhtml+xml;q=0.9, */*;q=0.1" + : "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, " + "*/*;q=0.1"); + request.setRawHeader("Accept-Language", "en-US,en;q=0.9"); + + job->reply = m_manager.get(request); + connect(job->reply, &QNetworkReply::readyRead, this, [this, job]() { + if (!job->reply) + return; + job->body += job->reply->readAll(); + if (job->body.size() > MaxResponseBytes) { + job->tooLarge = true; + job->reply->abort(); + } + }); + connect(job->reply, &QNetworkReply::finished, this, [this, job]() { + QNetworkReply* reply = job->reply; + job->reply = nullptr; + job->timer->stop(); + if (!reply) + return; + + const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); + QByteArray body = job->body; + body += reply->readAll(); + job->body.clear(); + const QByteArray contentType = + reply->rawHeader("Content-Type").toLower(); + const int status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) + .toInt(); + + if (job->tooLarge) { + completeJob(job, makeError( + QStringLiteral("Response too large (exceeds the 5 MB " + "limit"))); + return; + } + if (error != QNetworkReply::NoError) { + completeJob( + job, + makeError( + QStringLiteral("Request failed: %1").arg(errorString))); + return; + } + if (status < 200 || status >= 300) { + completeJob( + job, + makeError( + QStringLiteral("Server returned status %1") + .arg(status))); + return; + } + + const QString mime = + QString::fromLatin1(contentType).section(QLatin1Char(';'), 0, 0) + .trimmed(); + if (mime.startsWith(QLatin1String("image/"))) { + completeJob( + job, + makeError( + QStringLiteral( + "Unsupported fetched image content type: %1") + .arg(mime))); + return; + } + const bool textual = mime.isEmpty() || + mime.startsWith(QLatin1String("text/")) || + mime == QLatin1String("application/json") || + mime.endsWith(QLatin1String("+json")) || + mime == QLatin1String("application/xml") || + mime.endsWith(QLatin1String("+xml")) || + mime.startsWith(QLatin1String("application/javascript")) || + mime.startsWith(QLatin1String("text/javascript")); + if (!textual) { + completeJob( + job, + makeError( + QStringLiteral( + "Unsupported fetched file content type: %1") + .arg(mime))); + return; + } + + QString content = QString::fromUtf8(body); + if (mime.contains(QLatin1String("text/html")) && + job->format == QLatin1String("text")) + content = extractTextFromHtml(content); + if (content.size() > MaxOutputChars) + content = content.left(MaxOutputChars) + + QStringLiteral("\n[... truncated ...]"); + completeJob(job, makeOutput(content)); + }); +} + +void WebFetchTool::cancel() { + for (auto* job : m_jobs) { + job->timer->stop(); + if (job->reply) + job->reply->abort(); + } +} + +QString WebFetchTool::decodeEntities(const QString& text) { + if (!text.contains(QLatin1Char('&'))) + return text; + QString out; + out.reserve(text.size()); + for (qsizetype i = 0; i < text.size(); ++i) { + if (text.at(i) != QLatin1Char('&')) { + out += text.at(i); + continue; + } + const qsizetype semi = text.indexOf(QLatin1Char(';'), i); + if (semi < 0 || semi - i > 12) { + out += QLatin1Char('&'); + continue; + } + const QString entity = text.mid(i + 1, semi - i - 1); + QString replacement; + if (entity == QLatin1String("amp")) + replacement = QLatin1Char('&'); + else if (entity == QLatin1String("lt")) + replacement = QLatin1Char('<'); + else if (entity == QLatin1String("gt")) + replacement = QLatin1Char('>'); + else if (entity == QLatin1String("quot")) + replacement = QLatin1Char('"'); + else if (entity == QLatin1String("apos")) + replacement = QLatin1Char('\''); + else if (entity == QLatin1String("nbsp")) + replacement = QLatin1Char(' '); + else { + bool ok = false; + const quint32 codePoint = entity.startsWith(QLatin1String("#x")) || + entity.startsWith(QLatin1String("#X")) + ? entity.mid(2).toUInt(&ok, 16) + : entity.toUInt(&ok); + if (ok && codePoint != 0) { + const char32_t ucs4[2] = { + static_cast(codePoint), + 0, + }; + replacement = QString::fromUcs4(ucs4); + } + } + if (replacement.isEmpty()) { + out += QLatin1Char('&'); + continue; + } + out += replacement; + i = semi; + } + return out; +} + +QString WebFetchTool::extractTextFromHtml(const QString& html) { + static const QSet kSkipTags = { + QStringLiteral("noscript"), + QStringLiteral("iframe"), + QStringLiteral("object"), + QStringLiteral("head"), + }; + static const QSet kRawTags = { + QStringLiteral("script"), + QStringLiteral("style"), + }; + static const QSet kVoidTags = { + QStringLiteral("area"), QStringLiteral("base"), + QStringLiteral("br"), QStringLiteral("col"), + QStringLiteral("embed"), QStringLiteral("hr"), + QStringLiteral("img"), QStringLiteral("input"), + QStringLiteral("link"), QStringLiteral("meta"), + QStringLiteral("source"), QStringLiteral("track"), + QStringLiteral("wbr"), + }; + + QString text; + text.reserve(html.size() / 2); + int skipDepth = 0; + qsizetype i = 0; + while (i < html.size()) { + const qsizetype open = html.indexOf(QLatin1Char('<'), i); + if (open < 0) { + if (skipDepth == 0) + text += html.mid(i); + break; + } + if (skipDepth == 0) + text += html.mid(i, open - i); + const qsizetype close = html.indexOf(QLatin1Char('>'), open); + if (close < 0) + break; + const QString tag = + html.mid(open + 1, close - open - 1).trimmed().toLower(); + i = close + 1; + + if (tag.startsWith(QLatin1Char('!')) || + tag.startsWith(QLatin1Char('?'))) + continue; + + QString name = tag; + if (name.startsWith(QLatin1Char('/'))) { + if (skipDepth > 0) + --skipDepth; + continue; + } + qsizetype j = 0; + while (j < name.size() && + (name.at(j).isLetterOrNumber() || + name.at(j) == QLatin1Char(':') || + name.at(j) == QLatin1Char('-'))) + ++j; + name = name.left(j); + + if (kRawTags.contains(name)) { + // Raw-text element: swallow everything up to its close tag. + const qsizetype rawEnd = + html.indexOf(QStringLiteral("'), rawEnd); + if (rawClose < 0) + break; + i = rawClose + 1; + continue; + } + if (kVoidTags.contains(name)) + continue; + if (skipDepth > 0) { + // Browsers implicitly close at ; malformed pages + // without a would otherwise swallow the whole page. + if (name == QLatin1String("body")) { + skipDepth = 0; + continue; + } + ++skipDepth; + continue; + } + if (kSkipTags.contains(name)) { + ++skipDepth; + continue; + } + // Normal tag: replace with a space so words do not merge. + text += QLatin1Char(' '); + } + + QString out = decodeEntities(text); + QStringList lines; + for (const QString& line : out.split(QLatin1Char('\n'))) { + const QString flat = line.simplified(); + if (flat.isEmpty()) { + if (!lines.isEmpty() && lines.last().isEmpty()) + continue; + lines.append(QString()); + } else { + lines.append(flat); + } + } + while (lines.size() > 1 && lines.first().isEmpty()) + lines.removeFirst(); + while (lines.size() > 1 && lines.last().isEmpty()) + lines.removeLast(); + return lines.join(QLatin1Char('\n')); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/webfetchtool.hpp b/Plugins/ZShell/Llm/webfetchtool.hpp new file mode 100644 index 0000000..70046d1 --- /dev/null +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "tool.hpp" + +#include +#include +#include + +#include + +class QNetworkReply; +class QTimer; + +namespace ZShell::llm { + +// Fetches an http(s) URL and returns its content as plain text or raw +// HTML. Read-only. Mirrors opencode's webfetch tool, without markdown +// conversion and the permission prompt. Concurrent fetches are +// supported; results are always delivered on a later event loop +// iteration, never synchronously from execute(). +class WebFetchTool : public LlmTool { + Q_OBJECT + + public: + static constexpr int MaxResponseBytes = 5 * 1024 * 1024; + static constexpr int DefaultTimeoutSeconds = 30; + static constexpr int MaxTimeoutSeconds = 120; + // Caps the characters handed to the model so a large page cannot + // blow out the context. + static constexpr int MaxOutputChars = 64 * 1024; + + explicit WebFetchTool(QObject* parent = nullptr); + ~WebFetchTool() override; + + QString name() const override; + QString description() const override; + QJsonObject parameters() const override; + void execute( + const QJsonObject& args, + std::function done) override; + void cancel() override; + + // Strips tags (skipping script/style/noscript/iframe/object/embed/ + // head) and decodes common entities. + static QString extractTextFromHtml(const QString& html); + static QString decodeEntities(const QString& text); + + private: + struct Job { + QNetworkReply* reply = nullptr; + QTimer* timer = nullptr; + QByteArray body; + bool tooLarge = false; + QString format; + std::function done; + }; + + void completeJob(Job* job, QJsonObject result); + + QNetworkAccessManager m_manager; + QList m_jobs; +}; + +} // namespace ZShell::llm