ai chat: latex rendering

This commit is contained in:
end-4 2025-05-09 01:07:31 +02:00
parent e56a3a591b
commit c3323da840
6 changed files with 240 additions and 57 deletions

View file

@ -33,3 +33,11 @@ function splitMarkdownBlocks(markdown) {
}
return result;
}
function trimFileProtocol(str) {
return str.startsWith("file://") ? str.slice(7) : str;
}
function escapeBackslashes(str) {
return str.replace(/\\/g, '\\\\');
}

View file

@ -83,7 +83,8 @@ Item {
## Markdown test
### Formatting
*Italic*, \`Monospace\`, **Bold**, [Link](https://example.com)
- *Italic*, \`Monospace\`, **Bold**, [Link](https://example.com)
- Arch lincox icon <img src="/home/end/.config/quickshell/assets/icons/arch-symbolic.svg" height="${Appearance.font.pixelSize.small}"/>
### Table
@ -114,7 +115,9 @@ int main(int argc, char* argv[]) {
### LaTeX
Inline: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$
- Simple inline: $\\frac{1}{2} = \\frac{2}{4}$
- Complex inline: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$
- Another complex inline: \\\\[\\int_0^\\infty \\frac{1}{x^2} dx = \\infty\\\\]
`,
Ai.interfaceRole);

View file

@ -89,19 +89,25 @@ Rectangle {
RowLayout { // Header
spacing: 15
Layout.fillWidth: true
Rectangle { // Name
id: nameWrapper
color: Appearance.m3colors.m3secondaryContainer
// color: "transparent"
radius: Appearance.rounding.small
implicitWidth: nameRowLayout.implicitWidth + 10 * 2
implicitHeight: Math.max(nameRowLayout.implicitHeight + 5 * 2, 30)
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
RowLayout {
id: nameRowLayout
anchors.centerIn: parent
spacing: 5
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 7
Item {
Layout.alignment: Qt.AlignVCenter
@ -141,6 +147,8 @@ Rectangle {
StyledText {
id: providerName
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
elide: Text.ElideRight
font.pixelSize: Appearance.font.pixelSize.normal
font.weight: Font.DemiBold
color: Appearance.m3colors.m3onSecondaryContainer
@ -172,8 +180,6 @@ Rectangle {
}
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 5
@ -240,11 +246,15 @@ Rectangle {
}
delegate: Loader {
Layout.fillWidth: true
property var segment: modelData
// property var segment: modelData
property var segmentContent: modelData.content
property var segmentLang: modelData.lang
property var messageData: root.messageData
property var editing: root.editing
property var renderMarkdown: root.renderMarkdown
property var enableMouseSelection: root.enableMouseSelection
property bool thinking: root.messageData.thinking
property bool done: root.messageData.done
source: modelData.type === "code" ? "MessageCodeBlock.qml" : "MessageTextBlock.qml"
}

View file

@ -22,7 +22,8 @@ ColumnLayout {
property bool editing: parent?.editing ?? false
property bool renderMarkdown: parent?.renderMarkdown ?? true
property bool enableMouseSelection: parent?.enableMouseSelection ?? false
property var segment: parent?.segment ?? {}
property var segmentContent: parent?.segmentContent ?? ({})
property var segmentLang: parent?.segmentLang ?? "plaintext"
property var messageData: parent?.messageData ?? {}
spacing: codeBlockComponentSpacing
@ -57,7 +58,7 @@ ColumnLayout {
font.pixelSize: Appearance.font.pixelSize.small
font.weight: Font.DemiBold
color: Appearance.colors.colOnLayer2
text: segment.lang ? Repository.definitionForName(segment.lang).name : "plain"
text: segmentLang ? Repository.definitionForName(segmentLang).name : "plain"
}
Item { Layout.fillWidth: true }
@ -66,7 +67,7 @@ ColumnLayout {
id: copyCodeButton
buttonIcon: "content_copy"
onClicked: {
Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(segment.content)}'`)
Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(segmentContent)}'`)
}
StyledToolTip {
content: qsTr("Copy code")
@ -160,7 +161,7 @@ ColumnLayout {
id: codeTextArea
Layout.fillWidth: true
readOnly: !editing
selectByMouse: enableMouseSelection || editing
// selectByMouse: enableMouseSelection || editing
renderType: Text.NativeRendering
font.family: Appearance.font.family.monospace
font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text
@ -170,9 +171,9 @@ ColumnLayout {
// wrapMode: TextEdit.Wrap
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
text: segment.content
text: segmentContent
onTextChanged: {
segment.content = text
segmentContent = text
}
Keys.onPressed: (event) => {
@ -183,7 +184,7 @@ ColumnLayout {
codeTextArea.cursorPosition = cursor + 4;
event.accepted = true;
} else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) {
messageText.copy();
codeTextArea.copy();
event.accepted = true;
}
}
@ -192,23 +193,22 @@ ColumnLayout {
id: highlighter
textEdit: codeTextArea
repository: Repository
definition: Repository.definitionForName(segment.lang || "plaintext")
// definition: Repository.definitionForName("cpp")
definition: Repository.definitionForName(segmentLang || "plaintext")
theme: Appearance.syntaxHighlightingTheme
}
}
}
// MouseArea to block scrolling
MouseArea {
id: codeBlockMouseArea
anchors.fill: parent
acceptedButtons: editing ? Qt.NoButton : Qt.LeftButton
cursorShape: (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor
onWheel: (event) => {
event.accepted = false
}
}
// MouseArea {
// id: codeBlockMouseArea
// anchors.fill: parent
// acceptedButtons: editing ? Qt.NoButton : Qt.LeftButton
// cursorShape: (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor
// onWheel: (event) => {
// event.accepted = false
// }
// }
}
}
}

View file

@ -14,50 +14,129 @@ import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Hyprland
import Qt5Compat.GraphicalEffects
TextArea {
ColumnLayout {
id: root
// These are needed on the parent loader
property bool editing: parent?.editing ?? false
property bool renderMarkdown: parent?.renderMarkdown ?? true
property bool enableMouseSelection: parent?.enableMouseSelection ?? false
property var segment: parent?.segment ?? {}
property string segmentContent: parent?.segmentContent ?? ({})
property var messageData: parent?.messageData ?? {}
property bool done: parent?.done ?? true
property list<string> renderedLatexHashes: []
property string renderedSegmentContent: ""
Layout.fillWidth: true
readOnly: !editing
selectByMouse: enableMouseSelection || editing
renderType: Text.NativeRendering
font.family: Appearance.font.family.reading
font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text
font.pixelSize: Appearance.font.pixelSize.small
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.m3colors.m3secondaryContainer
wrapMode: TextEdit.Wrap
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
textFormat: renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText
text: messageData.thinking ? qsTr("Waiting for response...") : segment.content
onTextChanged: {
segment.content = text
}
Keys.onPressed: (event) => {
if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) {
messageText.copy()
event.accepted = true
function renderLatex() {
// Regex for $...$, $$...$$, \[...\]
// Note: This is a simple approach and may need refinement for edge cases
let regex = /(\$\$([\s\S]+?)\$\$)|(\$([^\$]+?)\$)|(\\\[((?:.|\n)+?)\\\])/g;
let match;
while ((match = regex.exec(segmentContent)) !== null) {
let expression = match[1] || match[2] || match[3];
if (expression) {
// Qt.callLater(() => {
// });
const [renderHash, isNew] = LatexRenderer.requestRender(expression.trim());
if (!renderedLatexHashes.includes(renderHash)) {
renderedLatexHashes.push(renderHash);
}
}
}
}
onLinkActivated: (link) => {
Qt.openUrlExternally(link)
Hyprland.dispatch("global quickshell:sidebarLeftClose")
function handleRenderedLatex(hash, force = false) {
if (renderedLatexHashes.includes(hash) || force) {
const imagePath = LatexRenderer.renderedImagePaths[hash];
const markdownImage = `![latex](${imagePath})`;
const expression = StringUtils.escapeBackslashes(LatexRenderer.processedExpressions[hash]);
renderedSegmentContent = renderedSegmentContent.replace(expression, markdownImage);
}
}
MouseArea { // Pointing hand for links
anchors.fill: parent
acceptedButtons: Qt.NoButton // Only for hover
hoverEnabled: true
cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor :
(enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor
onDoneChanged: {
renderLatex()
for (const hash of renderedLatexHashes) {
handleRenderedLatex(hash, true);
}
}
onEditingChanged: {
if (!editing) {
renderLatex()
}
}
onSegmentContentChanged: {
// console.log("Segment content changed: " + segmentContent);
renderedSegmentContent = segmentContent;
if (!root.editing && segmentContent) {
root.renderLatex();
}
}
onRenderedSegmentContentChanged: {
// console.log("Rendered segment content changed: " + renderedSegmentContent);
if (renderedSegmentContent) {
textArea.text = renderedSegmentContent;
}
}
// When something finishes rendering
// 1. Check if the hash is in the list
// 2. If it is, replace the expression with the image path
Connections {
target: LatexRenderer
function onRenderFinished(hash, imagePath) {
const expression = LatexRenderer.processedExpressions[hash];
// console.log("Render finished: " + hash + " " + expression);
handleRenderedLatex(hash);
}
}
TextArea {
id: textArea
Layout.fillWidth: true
readOnly: !editing
selectByMouse: enableMouseSelection || editing
renderType: Text.NativeRendering
font.family: Appearance.font.family.reading
font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text
font.pixelSize: Appearance.font.pixelSize.small
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.m3colors.m3secondaryContainer
wrapMode: TextEdit.Wrap
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
textFormat: renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText
text: qsTr("Waiting for response...")
onTextChanged: {
segmentContent = text
}
Keys.onPressed: (event) => {
if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) {
messageText.copy()
event.accepted = true
}
}
onLinkActivated: (link) => {
Qt.openUrlExternally(link)
Hyprland.dispatch("global quickshell:sidebarLeftClose")
}
MouseArea { // Pointing hand for links
anchors.fill: parent
acceptedButtons: Qt.NoButton // Only for hover
hoverEnabled: true
cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor :
(enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor
}
}
}

View file

@ -0,0 +1,83 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common/functions/string_utils.js" as StringUtils
import "root:/modules/common"
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import Qt.labs.platform
/**
* Renders LaTeX snippets with MicroTeX.
* For every request:
* 1. Hash it
* 2. Check if the hash is already processed
* 3. If not, render it with MicroTeX and mark as processed
*/
Singleton {
id: root
readonly property var renderPadding: 4 // This is to prevent cutoff in the rendered images
property list<string> processedHashes: []
property var processedExpressions: ({})
property var renderedImagePaths: ({})
property string microtexBinaryPath: Qt.resolvedUrl("/opt/MicroTeX/LaTeX")
property string latexOutputPath: StringUtils.trimFileProtocol(`${StandardPaths.standardLocations(StandardPaths.CacheLocation)[0]}/latex`)
signal renderFinished(string hash, string imagePath)
Component.onCompleted: {
Hyprland.dispatch(`exec rm -rf ${latexOutputPath} && mkdir -p ${latexOutputPath}`)
}
/**
* Requests rendering of a LaTeX expression.
* Returns the [hash, isNew]
*/
function requestRender(expression) {
// 1. Hash it and initialize necessary variables
const hash = Qt.md5(expression)
const imagePath = `${latexOutputPath}/${hash}.svg`
// 2. Check if the hash is already processed
if (processedHashes.includes(hash)) {
// console.log("Already processed: " + hash)
renderFinished(hash, imagePath)
return [hash, false]
} else {
root.processedHashes.push(hash)
root.processedExpressions[hash] = expression
// console.log("Rendering expression: " + expression)
}
// 3. If not, render it with MicroTeX and mark as processed
const processQml = `
import Quickshell.Io
Process {
id: microtexProcess${hash}
running: true
command: [ "${microtexBinaryPath}", "-headless",
"-input=${StringUtils.escapeBackslashes(expression)}",
"-output=${imagePath}",
"-textsize=${Appearance.font.pixelSize.normal}",
"-padding=${renderPadding}",
"-foreground=${Appearance.colors.colOnLayer1}",
"-maxwidth=0.85" ]
// stdout: SplitParser {
// onRead: data => { console.log("MicroTeX: " + data) }
// }
onExited: (exitCode, exitStatus) => {
renderedImagePaths["${hash}"] = "${imagePath}"
root.renderFinished("${hash}", "${imagePath}")
microtexProcess${hash}.destroy()
}
}
`
// console.log("MicroTeX: " + processQml)
Qt.createQmlObject(processQml, root, `MicroTeXProcess_${hash}`)
return [hash, true]
}
}