add markdown parsing + latex + tree-sitter highlighting for codeblocks in llm responses
This commit is contained in:
+10
-16
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ Item {
|
||||
fadeAmount: 0.05
|
||||
fadeThreshold: Tokens.padding.medium
|
||||
model: root.chatData.messagesModel
|
||||
spacing: Tokens.spacing.medium
|
||||
spacing: 0
|
||||
verticalLayoutDirection: VerticalFadeListView.BottomToTop
|
||||
|
||||
add: Transition {
|
||||
@@ -127,26 +127,38 @@ Item {
|
||||
visible: !root.chatData
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: scrollToBottom
|
||||
|
||||
Item {
|
||||
anchors.bottom: input.top
|
||||
anchors.bottomMargin: Tokens.spacing.extraLarge
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
font.pointSize: Tokens.font.size.large
|
||||
icon: "arrow_downward"
|
||||
isRound: true
|
||||
padding: Tokens.padding.extraSmall
|
||||
implicitHeight: scrollToBottom.implicitHeight
|
||||
implicitWidth: scrollToBottom.implicitWidth
|
||||
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0
|
||||
type: IconButton.Filled
|
||||
visible: scale > 0
|
||||
|
||||
Behavior on scale {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
list.positionViewAtBeginning();
|
||||
Elevation {
|
||||
anchors.fill: parent
|
||||
level: 2
|
||||
radius: scrollToBottom.radius
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: scrollToBottom
|
||||
|
||||
anchors.centerIn: parent
|
||||
font.pointSize: Tokens.font.size.large
|
||||
icon: "arrow_downward"
|
||||
isRound: true
|
||||
padding: Tokens.padding.extraSmall
|
||||
type: IconButton.Tonal
|
||||
|
||||
onClicked: {
|
||||
list.positionViewAtBeginning();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ Item {
|
||||
required property ChatGeneration current
|
||||
required property TextEdit edit
|
||||
required property bool hovered
|
||||
readonly property bool isUser: message.role === ChatMessage.Role.User
|
||||
required property bool isUser
|
||||
required property ChatMessage message
|
||||
required property LlmSegment segment
|
||||
|
||||
implicitHeight: root.current.content !== "" ? editButton.implicitHeight : 0
|
||||
implicitHeight: editButton.implicitHeight
|
||||
implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small
|
||||
|
||||
ButtonRow {
|
||||
@@ -66,8 +67,7 @@ Item {
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
@@ -78,8 +78,7 @@ Item {
|
||||
visible: !root.isUser
|
||||
|
||||
Behavior on scale {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
@@ -94,8 +93,7 @@ Item {
|
||||
scale: root.edit.readOnly ? 1 : 0
|
||||
|
||||
Behavior on scale {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: Quickshell.clipboardText = root.current.content
|
||||
@@ -107,8 +105,11 @@ Item {
|
||||
icon: root.edit.readOnly ? "edit" : "check"
|
||||
inactiveColor: Colors.palette.m3tertiary
|
||||
inactiveOnColor: Colors.palette.m3onTertiary
|
||||
visible: root.isUser
|
||||
|
||||
onClicked: root.edit.readOnly = !root.edit.readOnly
|
||||
onClicked: {
|
||||
root.edit.readOnly = !root.edit.readOnly;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
CustomClippingRect {
|
||||
id: root
|
||||
|
||||
required property string language
|
||||
required property string code
|
||||
property bool copied: false
|
||||
property color codeColor: Colors.palette.m3onSurfaceVariant
|
||||
property color codeBackgroundColor: Colors.palette.m3surfaceContainerLow
|
||||
property color codeHeaderColor: Colors.palette.m3outline
|
||||
property color codeAccentColor: Colors.palette.m3primary
|
||||
|
||||
// Highlighter spans for the current code; refreshed when the code or
|
||||
// its language changes.
|
||||
property var codeSpans: []
|
||||
|
||||
function refresh() {
|
||||
codeSpans = CodeHighlighter.highlight(root.code, root.language);
|
||||
}
|
||||
|
||||
function roleColor(kind) {
|
||||
switch (kind) {
|
||||
case "comment":
|
||||
return Colors.palette.m3outline;
|
||||
case "string":
|
||||
return Colors.palette.m3tertiary;
|
||||
case "string.key":
|
||||
return Colors.palette.m3secondary;
|
||||
case "number":
|
||||
case "constant":
|
||||
return Colors.palette.m3tertiaryFixed;
|
||||
case "keyword":
|
||||
return root.codeAccentColor;
|
||||
case "type":
|
||||
return Colors.palette.m3secondary;
|
||||
case "function":
|
||||
case "method":
|
||||
return Colors.palette.m3onSurface;
|
||||
case "macro":
|
||||
case "preproc":
|
||||
return Colors.palette.m3secondaryContainer;
|
||||
case "operator":
|
||||
case "property":
|
||||
return root.codeColor;
|
||||
case "label":
|
||||
case "attribute":
|
||||
return Colors.palette.m3tertiaryFixedDim;
|
||||
default:
|
||||
return root.codeColor;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Wraps the code in <font color> tags following the highlighter spans.
|
||||
function highlightedHtml(code, spans) {
|
||||
let out;
|
||||
if (!spans.length) {
|
||||
out = escapeHtml(code);
|
||||
} else {
|
||||
out = "";
|
||||
let pos = 0;
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
const span = spans[i];
|
||||
if (span.start > pos)
|
||||
out += escapeHtml(code.slice(pos, span.start));
|
||||
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(span.start, span.start + span.length)) + "</font>";
|
||||
pos = span.start + span.length;
|
||||
}
|
||||
if (pos < code.length)
|
||||
out += escapeHtml(code.slice(pos));
|
||||
}
|
||||
// RichText collapses HTML whitespace: break newlines, and protect
|
||||
// line-leading indentation with nbsp (internal spaces stay normal
|
||||
// so long lines can still wrap).
|
||||
return out
|
||||
.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, " "))
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin
|
||||
implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeText.implicitHeight + codeText.anchors.margins * 2
|
||||
color: root.codeBackgroundColor
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
onLanguageChanged: refresh()
|
||||
onCodeChanged: refresh()
|
||||
|
||||
CustomText {
|
||||
id: codeText
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.top: headerRow.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.padding.small
|
||||
color: root.codeColor
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
textFormat: Text.RichText
|
||||
text: root.highlightedHtml(root.code, root.codeSpans)
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: headerRow
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: Tokens.padding.small
|
||||
anchors.leftMargin: Tokens.padding.small
|
||||
anchors.rightMargin: Tokens.padding.small
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: root.codeHeaderColor
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.language
|
||||
visible: root.language.length > 0
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
IconButton {
|
||||
icon: root.copied ? "check" : "content_copy"
|
||||
inactiveColor: "transparent"
|
||||
inactiveOnColor: root.codeHeaderColor
|
||||
type: IconButton.Text
|
||||
|
||||
onClicked: {
|
||||
Quickshell.clipboardText = root.code;
|
||||
root.copied = true;
|
||||
copyResetTimer.restart();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: copyResetTimer
|
||||
|
||||
interval: 1500
|
||||
|
||||
onTriggered: root.copied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Llm
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property bool isUser
|
||||
required property LlmSegment segment
|
||||
required property bool hovered
|
||||
required property Repeater repeater
|
||||
required property int index
|
||||
required property ChatMessage message
|
||||
required property ChatGeneration current
|
||||
|
||||
// Widest the bubble may grow to; assistant bubbles always use it so
|
||||
// the markdown blocks have a deterministic width to wrap against.
|
||||
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
|
||||
|
||||
signal edit(text: string)
|
||||
|
||||
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
|
||||
|
||||
CustomRect {
|
||||
id: bubble
|
||||
|
||||
radius: Tokens.rounding.medium
|
||||
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
||||
implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
||||
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + Tokens.padding.medium * 2
|
||||
anchors.right: root.isUser ? parent.right : undefined
|
||||
|
||||
// User messages stay a plain editable text field.
|
||||
TextEditBase {
|
||||
id: msgText
|
||||
|
||||
visible: root.isUser
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
animateCursor: false
|
||||
color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
||||
cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
readOnly: true
|
||||
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
|
||||
text: root.isUser ? root.segment.text : ""
|
||||
textFormat: CustomText.MarkdownText
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key == Qt.Key_Return) {
|
||||
if (!(event.modifiers & Qt.ShiftModifier)) {
|
||||
readOnly = true;
|
||||
event.accepted = true;
|
||||
}
|
||||
} else if (event.key == Qt.Key_Escape) {
|
||||
text = root.segment.text;
|
||||
readOnly = true;
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
onEditingFinished: {
|
||||
const old = root.segment.text;
|
||||
if (old !== text)
|
||||
root.edit(text);
|
||||
|
||||
readOnly = true;
|
||||
}
|
||||
onReadOnlyChanged: {
|
||||
if (readOnly) {
|
||||
animateCursor = false;
|
||||
root.forceActiveFocus();
|
||||
textFormat = CustomText.MarkdownText;
|
||||
} else {
|
||||
var raw = root.segment.text;
|
||||
textFormat = CustomText.PlainText;
|
||||
text = raw;
|
||||
forceActiveFocus();
|
||||
cursorPosition = text.length;
|
||||
animateCursor = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assistant content: parsed markdown blocks.
|
||||
MarkdownBlocks {
|
||||
id: blocks
|
||||
|
||||
visible: !root.isUser
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
blocks: root.segment.markdown
|
||||
}
|
||||
}
|
||||
|
||||
Actions {
|
||||
id: actionsRow
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
|
||||
anchors.top: bubble.bottom
|
||||
visible: {
|
||||
return (root.segment.type === LlmSegment.Type.Content) && root.segment.status !== LlmSegment.Status.Running && root.repeater.count === (root.index + 1);
|
||||
}
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
edit: msgText
|
||||
hovered: root.hovered
|
||||
isUser: root.isUser
|
||||
segment: root.segment
|
||||
current: root.current
|
||||
message: root.message
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// Top-level markdown blocks (see MarkdownParser / LlmSegment.markdown).
|
||||
required property var blocks
|
||||
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
Repeater {
|
||||
id: blockRep
|
||||
|
||||
model: root.blocks
|
||||
|
||||
delegate: DelegateChooser {
|
||||
role: "type"
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Code
|
||||
|
||||
delegate: CodeBlockView {
|
||||
required property var modelData
|
||||
|
||||
language: modelData.language
|
||||
code: modelData.code
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Math
|
||||
|
||||
delegate: MathBlockView {
|
||||
required property var modelData
|
||||
|
||||
latex: modelData.latex
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Heading
|
||||
|
||||
delegate: CustomText {
|
||||
required property var modelData
|
||||
|
||||
color: Colors.palette.m3onSurface
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
text: modelData.text
|
||||
textFormat: Text.MarkdownText
|
||||
font.bold: true
|
||||
font.pointSize: {
|
||||
if (modelData.level <= 1)
|
||||
return Tokens.font.size.larger;
|
||||
if (modelData.level === 2)
|
||||
return Tokens.font.size.normal;
|
||||
return Tokens.font.size.smaller;
|
||||
}
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Text
|
||||
|
||||
delegate: CustomText {
|
||||
required property var modelData
|
||||
|
||||
color: Colors.palette.m3onSurface
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
text: modelData.text
|
||||
textFormat: Text.MarkdownText
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property string latex
|
||||
property color mathColor: Colors.palette.m3onSurface
|
||||
property real fontSize: Tokens.font.size.large
|
||||
|
||||
implicitWidth: Math.max(fallbackText.implicitWidth, root.width)
|
||||
implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
|
||||
|
||||
LlmMathText {
|
||||
id: math
|
||||
|
||||
latex: root.latex
|
||||
color: root.mathColor
|
||||
fontPointSize: root.fontSize
|
||||
devicePixelRatio: Screen.devicePixelRatio
|
||||
}
|
||||
|
||||
Image {
|
||||
id: equationImage
|
||||
|
||||
anchors.centerIn: parent
|
||||
source: math.imageUrl
|
||||
asynchronous: true
|
||||
visible: math.ok
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
width: Math.min(math.width, root.width)
|
||||
height: math.width > 0 ? width * (math.height / math.width) : 0
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: fallbackText
|
||||
|
||||
anchors.centerIn: parent
|
||||
color: root.mathColor
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.latex
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
visible: !math.ok
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
@@ -14,6 +16,7 @@ MouseArea {
|
||||
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
||||
required property ChatMessage modelData
|
||||
property bool reasoningExpanded: false
|
||||
readonly property var blocks: blockify(current.segments)
|
||||
property real savedContentY: -1
|
||||
|
||||
function handleReasoningToggle(expanded: bool): void {
|
||||
@@ -34,14 +37,36 @@ MouseArea {
|
||||
}
|
||||
}
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: root.isUser ? 0 : Tokens.spacing.extraSmall
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: root.isUser ? Tokens.spacing.extraSmall : 0
|
||||
function blockify(segs) {
|
||||
const blocks = [];
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const seg = segs[i];
|
||||
if (seg.type === LlmSegment.Type.Content) {
|
||||
blocks.push({
|
||||
kind: "content",
|
||||
id: i,
|
||||
segments: [seg]
|
||||
});
|
||||
} else {
|
||||
const last = blocks.length ? blocks[blocks.length - 1] : null;
|
||||
if (last && last.kind === "process") {
|
||||
last.segments.push(seg);
|
||||
} else {
|
||||
blocks.push({
|
||||
kind: "process",
|
||||
id: i,
|
||||
segments: [seg]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
hoverEnabled: true
|
||||
// Explicit, synchronous height — sum of children, no Layout polish involved.
|
||||
implicitHeight: reasoningLoader.implicitHeight + (reasoningLoader.active ? Tokens.spacing.medium : 0) + bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
|
||||
preventStealing: true
|
||||
implicitHeight: layout.implicitHeight + Tokens.padding.medium
|
||||
|
||||
Binding {
|
||||
property: "contentY"
|
||||
@@ -59,106 +84,61 @@ MouseArea {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: reasoningLoader
|
||||
Column {
|
||||
id: layout
|
||||
|
||||
readonly property bool shouldBeActive: root.current.reasoningActive || root.current.content === ""
|
||||
|
||||
active: !root.isUser
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Tokens.spacing.extraLarge
|
||||
anchors.top: parent.top
|
||||
|
||||
sourceComponent: Reasoning {
|
||||
current: root.current
|
||||
Repeater {
|
||||
id: segmentRep
|
||||
|
||||
onExpandedChanged: {
|
||||
root.handleReasoningToggle(expanded);
|
||||
model: ScriptModel {
|
||||
values: root.blocks
|
||||
objectProp: "id"
|
||||
}
|
||||
}
|
||||
}
|
||||
delegate: DelegateChooser {
|
||||
role: "kind"
|
||||
|
||||
CustomRect {
|
||||
id: bubble
|
||||
DelegateChoice {
|
||||
roleValue: "process"
|
||||
|
||||
anchors.left: root.isUser ? undefined : parent.left
|
||||
anchors.right: root.isUser ? parent.right : undefined
|
||||
anchors.top: reasoningLoader.active ? reasoningLoader.bottom : parent.top
|
||||
anchors.topMargin: reasoningLoader.active ? Tokens.spacing.medium : 0
|
||||
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
||||
implicitHeight: root.current.content !== "" ? msgText.contentHeight + Tokens.padding.medium * 2 : 0
|
||||
implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge)
|
||||
radius: Tokens.rounding.medium
|
||||
visible: !root.current.reasoningActive
|
||||
delegate: ProcessBlock {
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
TextEditBase {
|
||||
id: msgText
|
||||
segments: modelData.segments
|
||||
isActive: index === root.blocks.length - 1
|
||||
width: root.width
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
animateCursor: false
|
||||
color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
||||
cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
readOnly: true
|
||||
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
|
||||
text: root.current.content
|
||||
textFormat: CustomText.MarkdownText
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key == Qt.Key_Return) {
|
||||
if (!(event.modifiers & Qt.ShiftModifier)) {
|
||||
readOnly = true;
|
||||
event.accepted = true;
|
||||
onExpandedChanged: root.handleReasoningToggle(expanded)
|
||||
}
|
||||
} else if (event.key == Qt.Key_Escape) {
|
||||
text = root.current.content;
|
||||
readOnly = true;
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
onEditingFinished: {
|
||||
const old = root.current.content;
|
||||
if (old !== text) {
|
||||
root.modelData.edit(text);
|
||||
|
||||
if (root.isUser)
|
||||
root.modelData.generate();
|
||||
}
|
||||
|
||||
readOnly = true;
|
||||
}
|
||||
onReadOnlyChanged: {
|
||||
if (readOnly) {
|
||||
animateCursor = false;
|
||||
root.forceActiveFocus();
|
||||
textFormat = CustomText.MarkdownText;
|
||||
} else {
|
||||
var raw = root.current.content;
|
||||
textFormat = CustomText.PlainText;
|
||||
text = raw;
|
||||
forceActiveFocus();
|
||||
cursorPosition = text.length;
|
||||
animateCursor = true;
|
||||
DelegateChoice {
|
||||
roleValue: "content"
|
||||
|
||||
delegate: ContentBubble {
|
||||
required property var modelData
|
||||
|
||||
isUser: root.isUser
|
||||
segment: modelData.segments[0]
|
||||
width: root.width
|
||||
repeater: segmentRep
|
||||
message: root.modelData
|
||||
current: root.current
|
||||
hovered: root.containsMouse
|
||||
|
||||
onEdit: text => {
|
||||
root.modelData.edit(text);
|
||||
|
||||
if (root.isUser)
|
||||
root.modelData.generate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Actions {
|
||||
id: actionsRow
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
|
||||
anchors.top: bubble.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
current: root.current
|
||||
edit: msgText
|
||||
hovered: root.containsMouse
|
||||
message: root.modelData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var segments
|
||||
required property bool isActive
|
||||
property bool expanded: false
|
||||
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1]
|
||||
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
|
||||
|
||||
implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin
|
||||
|
||||
LoadingIndicator {
|
||||
id: spinnerReasoning
|
||||
|
||||
anchors.centerIn: expandBtn
|
||||
implicitSize: collapsedText.implicitHeight
|
||||
opacity: root.isActive ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: expandBtn
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: collapsedText.verticalCenter
|
||||
font.pointSize: Tokens.font.size.large
|
||||
anchors.topMargin: 0
|
||||
icon: "keyboard_arrow_down"
|
||||
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||
opacity: root.isActive ? 0 : 1
|
||||
rotation: root.expanded ? 180 : 0
|
||||
type: IconButton.Text
|
||||
|
||||
Behavior on rotation {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: collapsedText
|
||||
|
||||
anchors.left: expandBtn.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 0
|
||||
color: Colors.palette.m3outline
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: {
|
||||
if (root.isActive) {
|
||||
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
|
||||
return qsTr("Using %1...").arg(root.lastSegment.name);
|
||||
return qsTr("Thinking...");
|
||||
}
|
||||
return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1));
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
id: expandedRect
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: collapsedText.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
color: Colors.palette.m3surfaceContainerLow
|
||||
implicitHeight: root.expanded ? expandedContent.contentHeight + expandedContent.anchors.margins * 2 : 0
|
||||
opacity: root.expanded ? 1 : 0
|
||||
radius: Tokens.rounding.medium
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: expandedContent
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.medium
|
||||
model: root.segments
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
delegate: Item {
|
||||
id: segment
|
||||
|
||||
required property LlmSegment modelData
|
||||
required property int index
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
implicitHeight: header.implicitHeight + content.implicitHeight + content.anchors.topMargin
|
||||
|
||||
RowLayout {
|
||||
id: header
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
MaterialIcon {
|
||||
text: segment.modelData.type === LlmSegment.Type.Reasoning ? "cognition" : "build"
|
||||
fill: 1
|
||||
}
|
||||
|
||||
CustomText {
|
||||
Layout.fillWidth: true
|
||||
text: segment.modelData.type === LlmSegment.Type.Reasoning ? qsTr("Thought for %1s").arg((segment.modelData.elapsedMs / 1000).toFixed(1)) : qsTr("Used %1 for %2s").arg(segment.modelData.name).arg((segment.modelData.elapsedMs / 1000).toFixed(1))
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: content
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Tokens.padding.small
|
||||
anchors.top: header.bottom
|
||||
anchors.topMargin: Tokens.spacing.small
|
||||
anchors.right: parent.right
|
||||
color: Colors.palette.m3outline
|
||||
font.pointSize: Tokens.font.size.small
|
||||
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
|
||||
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property ChatGeneration current
|
||||
property bool expanded: false
|
||||
readonly property bool isReasoning: root.current.reasoningActive || root.current.content === ""
|
||||
|
||||
implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin
|
||||
|
||||
LoadingIndicator {
|
||||
id: spinnerReasoning
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitSize: collapsedText.implicitHeight
|
||||
opacity: root.isReasoning ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: expandBtn
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.verticalCenter: collapsedText.verticalCenter
|
||||
font.pointSize: Tokens.font.size.large
|
||||
icon: "keyboard_arrow_down"
|
||||
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||
opacity: root.isReasoning ? 0 : 1
|
||||
rotation: root.expanded ? 180 : 0
|
||||
type: IconButton.Text
|
||||
|
||||
Behavior on rotation {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: collapsedText
|
||||
|
||||
anchors.left: root.current.reasoningActive ? spinnerReasoning.right : expandBtn.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 0
|
||||
color: Colors.palette.m3outline
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.current.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((root.current.reasoningElapsedMs / 1000).toFixed(1))
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
id: expandedRect
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: collapsedText.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
color: Colors.palette.m3surfaceContainerLow
|
||||
implicitHeight: root.expanded ? expandedText.implicitHeight + expandedText.anchors.margins * 2 : -anchors.topMargin
|
||||
implicitWidth: expandedText.implicitWidth + expandedText.anchors.margins * 2
|
||||
opacity: root.expanded ? 1 : 0
|
||||
radius: Tokens.rounding.medium
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: expandedText
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.medium
|
||||
color: Colors.palette.m3onSurface
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.current.reasoning
|
||||
textFormat: CustomText.MarkdownText
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "config.hpp"
|
||||
#include "llm.hpp"
|
||||
#include "llmclient.hpp"
|
||||
#include "webfetchtool.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "llmclient.hpp"
|
||||
#include "message.hpp"
|
||||
#include "segment.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
@@ -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<int>(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<LlmSegment::Status>(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
#include "codehighlighter.hpp"
|
||||
|
||||
#include "highlight-queries.hpp"
|
||||
|
||||
#include <tree_sitter/api.h>
|
||||
|
||||
#include <QHash>
|
||||
#include <QMap>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <dlfcn.h>
|
||||
|
||||
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<std::string> 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<QString, CodeHighlighter::Grammar>& grammars() {
|
||||
static const QHash<QString, CodeHighlighter::Grammar> grammars = [] {
|
||||
const auto& qs = querySources();
|
||||
QHash<QString, CodeHighlighter::Grammar> 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<QString, QString>& CodeHighlighter::aliases() {
|
||||
// Language tags as written in code fences (and common variants) to
|
||||
// grammar id.
|
||||
static const QHash<QString, QString> aliases = [] {
|
||||
QHash<QString, QString> 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<std::string>& 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<hl::Role>(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<size_t>(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<hl::LanguageFn>(
|
||||
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<size_t>(candidate)];
|
||||
TSQueryError errorType = TSQueryErrorNone;
|
||||
uint32_t errorOffset = 0;
|
||||
TSQuery* query = ts_query_new(
|
||||
static_cast<const TSLanguage*>(state.lang),
|
||||
source.data(),
|
||||
static_cast<uint32_t>(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<const TSLanguage*>(state.lang));
|
||||
TSTree* tree = ts_parser_parse_string(
|
||||
parser, nullptr, utf8.constData(), static_cast<uint32_t>(utf8.size()));
|
||||
if (!tree) {
|
||||
ts_parser_delete(parser);
|
||||
return spans;
|
||||
}
|
||||
|
||||
TSQuery* query = static_cast<TSQuery*>(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<uint32_t>(utf8.size());
|
||||
std::vector<uint8_t> 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<uint32_t> cu(size + 1, 0);
|
||||
for (uint32_t b = 0; b < size; ++b) {
|
||||
cu[b + 1] = cu[b];
|
||||
const unsigned char c = static_cast<unsigned char>(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<int>(cu[start]));
|
||||
span.insert("length", static_cast<int>(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
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
#include <QtQml>
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
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-<lang>.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_<lang>() entry point
|
||||
// Candidate query sources, first wins. Lets typescript reuse
|
||||
// the javascript query when it compiles against the grammar.
|
||||
std::vector<int> 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<QString, QString>& aliases();
|
||||
[[nodiscard]] const std::vector<std::string>& 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<QString, State> m_states;
|
||||
static CodeHighlighter* s_instance;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -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-<fontid>.txt, where <fontid> is some unique identification
|
||||
% of the font family. If a separate "readme" file accompanies the Work,
|
||||
% we recommend a name of the form README-<fontid>.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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "segment.hpp"
|
||||
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
@@ -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<ZShell::llm::LlmSegment*> 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<LlmSegment*> 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<LlmSegment*> 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
|
||||
|
||||
@@ -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 "^-")
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+540
-250
@@ -1,7 +1,9 @@
|
||||
#include "llmclient.hpp"
|
||||
|
||||
#include "generation.hpp"
|
||||
#include "message.hpp"
|
||||
#include "messagemodel.hpp"
|
||||
#include "segment.hpp"
|
||||
#include "session.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
@@ -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<ChatMessage*>(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<ChatMessage*>(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<const LlmSegment*> 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<ToolCallResult>(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<ChatMessage*>(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<QString> 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<int>(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<ChatMessage*>(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<QString> 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<int>(used));
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include "segment.hpp"
|
||||
#include "tool.hpp"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonArray>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include <functional>
|
||||
|
||||
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<LlmSegment> 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<ToolCallBuilder> m_callBuilders;
|
||||
struct ToolCallResult {
|
||||
QString content;
|
||||
bool success = false;
|
||||
};
|
||||
QList<ToolCallResult> m_callResults;
|
||||
QString m_finishReason;
|
||||
int m_round = 0;
|
||||
qsizetype m_contentMark = 0;
|
||||
qsizetype m_reasoningMark = 0;
|
||||
bool m_roundDone = false;
|
||||
bool m_toolPhase = false;
|
||||
int m_pendingCalls = 0;
|
||||
static constexpr int kMaxToolRounds = 12;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QtQml>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,144 @@
|
||||
#include "markdownparser.hpp"
|
||||
|
||||
#include "markdownblock.hpp"
|
||||
|
||||
#include <cmark-gfm-extension_api.h>
|
||||
#include <cmark-gfm.h>
|
||||
|
||||
#include <QRegularExpression>
|
||||
#include <QStringList>
|
||||
#include <QVariantMap>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
QVariantList MarkdownParser::parse(const QString& source) {
|
||||
QVariantList blocks;
|
||||
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<int>(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<size_t>(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<int>(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<int>(m.capturedStart() - cursor)));
|
||||
appendMath(m.captured(1).trimmed());
|
||||
cursor = static_cast<int>(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
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,176 @@
|
||||
#include "mathtext.hpp"
|
||||
|
||||
#include "latinmodern-fonts.hpp"
|
||||
|
||||
#include <jkqtmathtext/jkqtmathtext.h>
|
||||
|
||||
#include <QBuffer>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFontDatabase>
|
||||
|
||||
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<const char*>(data),
|
||||
static_cast<qint64>(size)) != static_cast<qint64>(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
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
#include <QImage>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QtQml>
|
||||
|
||||
#include <jkqtmathtext/jkqtmathtext.h>
|
||||
|
||||
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
|
||||
@@ -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<int>(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<int>(m_generations.size() - 1));
|
||||
return generation;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#include "segment.hpp"
|
||||
|
||||
#include "markdownparser.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
|
||||
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<qint64>(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
|
||||
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
#include <QtQml>
|
||||
|
||||
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
|
||||
@@ -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<LlmTool*>(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
|
||||
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
#include <functional>
|
||||
|
||||
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<void(const QJsonObject& result)> 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<LlmTool*> m_tools;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -0,0 +1,428 @@
|
||||
#include "webfetchtool.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
|
||||
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<void(const QJsonObject&)> 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<char32_t>(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<QString> kSkipTags = {
|
||||
QStringLiteral("noscript"),
|
||||
QStringLiteral("iframe"),
|
||||
QStringLiteral("object"),
|
||||
QStringLiteral("head"),
|
||||
};
|
||||
static const QSet<QString> kRawTags = {
|
||||
QStringLiteral("script"),
|
||||
QStringLiteral("style"),
|
||||
};
|
||||
static const QSet<QString> 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("</") + name, i,
|
||||
Qt::CaseInsensitive);
|
||||
if (rawEnd < 0)
|
||||
break;
|
||||
const qsizetype rawClose = html.indexOf(QLatin1Char('>'), rawEnd);
|
||||
if (rawClose < 0)
|
||||
break;
|
||||
i = rawClose + 1;
|
||||
continue;
|
||||
}
|
||||
if (kVoidTags.contains(name))
|
||||
continue;
|
||||
if (skipDepth > 0) {
|
||||
// Browsers implicitly close <head> at <body>; malformed pages
|
||||
// without a </head> 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
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include "tool.hpp"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QList>
|
||||
#include <QNetworkAccessManager>
|
||||
|
||||
#include <functional>
|
||||
|
||||
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<void(const QJsonObject& result)> 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<void(const QJsonObject& result)> done;
|
||||
};
|
||||
|
||||
void completeJob(Job* job, QJsonObject result);
|
||||
|
||||
QNetworkAccessManager m_manager;
|
||||
QList<Job*> m_jobs;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
Reference in New Issue
Block a user