mirror of
https://github.com/danbulant/dots-hyprland
synced 2026-05-24 12:22:09 +00:00
ai chat: latex rendering
This commit is contained in:
parent
e56a3a591b
commit
c3323da840
6 changed files with 240 additions and 57 deletions
|
|
@ -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, '\\\\');
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = ``;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
.config/quickshell/services/LatexRenderer.qml
Normal file
83
.config/quickshell/services/LatexRenderer.qml
Normal 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]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue