dots-hyprland/.config/ags/modules/sideleft/apis/ai_chatmessage.js
end-4 9d33e8a404 ai: actually make chat messages update incrementally
(instead of destroying and recreating every update)
MUCH better performance and no more hundreds of latex files for one integration by parts work
2025-04-08 11:28:31 +02:00

491 lines
20 KiB
JavaScript

const { GLib, Gtk } = imports.gi;
import GtkSource from "gi://GtkSource?version=3.0";
import App from 'resource:///com/github/Aylur/ags/app.js';
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
import * as Utils from 'resource:///com/github/Aylur/ags/utils.js';
const { Box, Button, Label, Icon, Revealer, Scrollable, Stack } = Widget;
const { execAsync, exec } = Utils;
import { MaterialIcon } from '../../.commonwidgets/materialicon.js';
import md2pango, { replaceInlineLatexWithCodeBlocks } from '../../.miscutils/md2pango.js';
import { darkMode } from "../../.miscutils/system.js";
import { setupCursorHover } from "../../.widgetutils/cursorhover.js";
const LATEX_DIR = `${GLib.get_user_cache_dir()}/ags/media/latex`;
const USERNAME = GLib.get_user_name();
function substituteLang(str) {
const subs = [
{ from: 'javascript', to: 'js' },
{ from: 'bash', to: 'sh' },
];
for (const { from, to } of subs) {
if (from === str) return to;
}
return str;
}
const HighlightedCode = (content, lang) => {
const buffer = new GtkSource.Buffer();
const sourceView = new GtkSource.View({
buffer: buffer,
wrap_mode: Gtk.WrapMode.NONE,
insertSpacesInsteadOfTabs: true,
indentWidth: 4,
tabWidth: 4,
smartHomeEnd: true,
smartBackspace: true,
});
const langManager = GtkSource.LanguageManager.get_default();
let displayLang = langManager.get_language(substituteLang(lang)); // Set your preferred language
if (displayLang) {
buffer.set_language(displayLang);
}
const schemeManager = GtkSource.StyleSchemeManager.get_default();
buffer.set_style_scheme(schemeManager.get_scheme(`custom${darkMode.value ? '' : '-light'}`));
buffer.set_text(content, -1);
return sourceView;
}
const TextBlock = (content = '') => {
const widget = Label({
attribute: {
'text': content,
'updateText': (text) => {
widget.attribute.text = text;
widget.label = md2pango(widget.attribute.text)
},
'appendText': (text) => {
widget.attribute.text += text;
widget.label = md2pango(widget.attribute.text)
},
},
hpack: 'fill',
className: 'txt sidebar-chat-txtblock sidebar-chat-txt',
useMarkup: true,
xalign: 0,
wrap: true,
selectable: true,
label: content,
});
return widget;
}
const ThinkBlock = (content = '', revealChild = true) => {
const revealThought = Variable(revealChild);
const mainText = Label({
hpack: 'fill',
className: `txt sidebar-chat-txtblock-think sidebar-chat-txt`,
useMarkup: true,
xalign: 0,
wrap: true,
selectable: true,
label: content,
});
const mainTextRevealer = Revealer({
transition: 'slide_down',
revealChild: revealThought.value,
child: mainText,
setup: (self) => self.hook(revealThought, (self) => {
self.revealChild = revealThought.value;
})
})
const expandIcon = MaterialIcon(revealThought.value ? 'expand_less' : 'expand_more', 'norm', {
setup: (self) => self.hook(revealThought, (self) => {
self.label = revealThought.value ? 'expand_less' : 'expand_more';
})
});
const widget = Box({
attribute: {
'text': content,
'updateText': (text) => {
widget.attribute.text = text;
mainText.label = md2pango(widget.attribute.text);
},
'appendText': (text) => {
widget.attribute.text += text;
mainText.label = md2pango(widget.attribute.text);
},
'done': () => {
revealThought.value = false;
}
},
className: 'sidebar-chat-thinkblock',
vertical: true,
children: [
Button({
onClicked: (self) => {
revealThought.value = !revealThought.value;
},
child: Box({
className: 'spacing-h-10 padding-10',
children: [
Box({
homogeneous: true,
valign: 'center',
className: 'sidebar-chat-thinkblock-icon',
children: [MaterialIcon('neurology', 'large')]
}),
Label({
valign: 'center',
hexpand: true,
xalign: 0,
label: 'Chain of Thought',
className: 'txt sidebar-chat-thinkblock-txt',
}),
Box({
className: 'sidebar-chat-thinkblock-btn-arrow',
homogeneous: true,
children: [expandIcon],
}),
]
}),
setup: setupCursorHover,
}),
mainTextRevealer,
]
});
return widget;
}
Utils.execAsync(['bash', '-c', `rm -rf ${LATEX_DIR}`])
.then(() => Utils.execAsync(['bash', '-c', `mkdir -p ${LATEX_DIR}`]))
.catch(print);
const LatexBlock = (content = '') => {
const latexViewArea = Box({
// vscroll: 'never',
// hscroll: 'automatic',
// homogeneous: true,
attribute: {
'render': async (self, text) => {
if (text.length == 0) return;
const styleContext = self.get_style_context();
const fontSize = styleContext.get_property('font-size', Gtk.StateFlags.NORMAL);
const timeSinceEpoch = Date.now();
const fileName = `${timeSinceEpoch}.tex`;
const outFileName = `${timeSinceEpoch}-symbolic.svg`;
const outIconName = `${timeSinceEpoch}-symbolic`;
const scriptFileName = `${timeSinceEpoch}-render.sh`;
const filePath = `${LATEX_DIR}/${fileName}`;
const outFilePath = `${LATEX_DIR}/${outFileName}`;
const scriptFilePath = `${LATEX_DIR}/${scriptFileName}`;
Utils.writeFile(text, filePath).catch(print);
// Since MicroTex doesn't support file path input properly, we gotta cat it
// And escaping such a command is a fucking pain so I decided to just generate a script
// Note: MicroTex doesn't support `&=`
// You can add this line in the middle for debugging: echo "$text" > ${filePath}.tmp
const renderScript = `#!/usr/bin/env bash
text=$(cat ${filePath} | sed 's/$/ \\\\\\\\/g' | sed 's/&=/=/g')
cd /opt/MicroTeX
./LaTeX -headless -input="$text" -output=${outFilePath} -textsize=${fontSize * 1.1} -padding=0 -maxwidth=${latexViewArea.get_allocated_width() * 0.85} > /dev/null 2>&1
sed -i 's/fill="rgb(0%, 0%, 0%)"/style="fill:#000000"/g' ${outFilePath}
sed -i 's/stroke="rgb(0%, 0%, 0%)"/stroke="${darkMode.value ? '#ffffff' : '#000000'}"/g' ${outFilePath}
`;
Utils.writeFile(renderScript, scriptFilePath).catch(print);
Utils.exec(`chmod a+x ${scriptFilePath}`)
Utils.timeout(100, () => {
Utils.exec(`bash ${scriptFilePath}`);
Gtk.IconTheme.get_default().append_search_path(LATEX_DIR);
self.child?.destroy();
self.child = Gtk.Image.new_from_icon_name(outIconName, 0);
})
}
},
setup: (self) => self.attribute.render(self, content).catch(print),
});
const wholeThing = Box({
className: 'sidebar-chat-latex',
homogeneous: true,
attribute: {
'text': content,
'updateText': (text) => {
wholeThing.attribute.text = text;
latexViewArea.attribute.render(latexViewArea, wholeThing.attribute.text).catch(print);
},
'appendText': (text) => {
wholeThing.attribute.text += text;
latexViewArea.attribute.render(latexViewArea, wholeThing.attribute.text).catch(print);
},
},
children: [Scrollable({
vscroll: 'never',
hscroll: 'automatic',
child: latexViewArea
})]
})
return wholeThing;
}
const CodeBlock = (content = '', lang = 'txt') => {
if (lang == 'tex' || lang == 'latex') {
return LatexBlock(content);
}
const topBar = Box({
className: 'sidebar-chat-codeblock-topbar',
children: [
Label({
label: lang,
className: 'sidebar-chat-codeblock-topbar-txt',
}),
Box({
hexpand: true,
}),
Button({
className: 'sidebar-chat-codeblock-topbar-btn',
child: Box({
className: 'spacing-h-5',
children: [
MaterialIcon('content_copy', 'small'),
Label({
label: 'Copy',
})
]
}),
onClicked: (self) => {
const buffer = sourceView.get_buffer();
const copyContent = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), false); // TODO: fix this
execAsync([`wl-copy`, `${copyContent}`]).catch(print);
},
}),
]
})
// Source view
const sourceView = HighlightedCode(content, lang);
const codeBlock = Box({
attribute: {
'updateText': (text) => {
// Enable useful features for multi-line code
if (text.split('\n').length > 1) {
sourceView.autoIndent = true;
sourceView.highlightCurrentLine = true;
sourceView.showLineNumbers = true;
sourceView.showLineMarks = true;
}
sourceView.get_buffer().set_text(text, -1);
},
'appendText': (text) => {
codeBlock.attribute.updateText(sourceView.get_buffer().text + text);
},
},
className: 'sidebar-chat-codeblock',
vertical: true,
children: [
topBar,
Box({
className: 'sidebar-chat-codeblock-code',
homogeneous: true,
children: [Scrollable({
vscroll: 'never',
hscroll: 'automatic',
child: sourceView,
})],
})
],
setup: (self) => self.hook(darkMode, (self) => {
const schemeManager = GtkSource.StyleSchemeManager.get_default();
Utils.timeout(1000, () => { // Wait for the theme to be loaded
sourceView.buffer.set_style_scheme(schemeManager.get_scheme(`custom${darkMode.value ? '' : '-light'}`));
});
}, "changed"),
})
// const schemeIds = styleManager.get_scheme_ids();
// print("Available Style Schemes:");
// for (let i = 0; i < schemeIds.length; i++) {
// print(schemeIds[i]);
// }
return codeBlock;
}
const Divider = () => Box({
className: 'sidebar-chat-divider',
})
const MessageContent = (content) => {
const contentBox = Box({
vertical: true,
attribute: {
'lastUpdateTextLength': 0,
'inCode': false,
'fullUpdate': (self, content, useCursor = false) => {
// First text widget
if (contentBox.attribute.lastUpdateTextLength === 0
&& contentBox.get_children().length === 0
) {
contentBox.add(TextBlock())
}
const codeBlockRegex = /^\s*```([a-zA-Z0-9]+)?\n?/;
const thinkBlockStartRegex = /^\s*<think>/; // Start: <think>
const thinkBlockEndRegex = /<\/think>\s*$/; // End: </think>
const dividerRegex = /^\s*---/;
const newContent = content.slice(contentBox.attribute.lastUpdateTextLength);
// print("CONTENT:'" + content + "'")
// print("LAST UPDATE LENGTH:" + contentBox.attribute.lastUpdateTextLength)
// print("NEW CONTENT:" + newContent)
if (newContent.length == 0) return;
let lines = replaceInlineLatexWithCodeBlocks(newContent).split('\n');
// let lines = newContent.split('\n');
// Process each line except the last line (potentially incomplete)
let lastProcessed = 0;
for (let [index, line] of lines.entries()) {
if (index == lines.length - 1) break;
// Code blocks
if (codeBlockRegex.test(line)) {
const kids = self.get_children();
const lastLabel = kids[kids.length - 1];
const blockContent = lines.slice(lastProcessed, index).join('\n');
if (!contentBox.attribute.inCode) {
lastLabel.attribute.appendText(blockContent);
if (lastLabel.label === '') lastLabel.destroy();
contentBox.add(CodeBlock('', codeBlockRegex.exec(line)[1]));
}
else {
lastLabel.attribute.appendText(blockContent);
contentBox.add(TextBlock());
}
lastProcessed = index + 1;
contentBox.attribute.inCode = !contentBox.attribute.inCode;
}
// Think block
if (!contentBox.attribute.inCode && (thinkBlockStartRegex.test(line) || thinkBlockEndRegex.test(line))) {
const kids = self.get_children();
const lastLabel = kids[kids.length - 1];
const blockContent = lines.slice(lastProcessed, index).join('\n');
lastLabel.attribute.appendText(blockContent);
if (lastLabel.label === '') lastLabel.destroy();
if (thinkBlockStartRegex.test(line)) contentBox.add(ThinkBlock());
else {
lastLabel.attribute.done();
contentBox.add(TextBlock());
}
lastProcessed = index + 1;
}
// Breaks
if (!contentBox.attribute.inCode && dividerRegex.test(line)) {
const kids = self.get_children();
const lastLabel = kids[kids.length - 1];
const blockContent = lines.slice(lastProcessed, index).join('\n');
lastLabel.attribute.appendText(blockContent);
contentBox.add(Divider());
contentBox.add(TextBlock());
lastProcessed = index + 1;
}
}
if (lastProcessed < lines.length - 1) {
const kids = self.get_children();
const lastLabel = kids[kids.length - 1];
let blockContent = lines.slice(lastProcessed, lines.length - 1).join('\n') + '\n';
lastLabel.attribute.appendText(blockContent);
}
// Debug: plain text
// contentBox.add(Label({
// hpack: 'fill',
// className: 'txt sidebar-chat-txtblock sidebar-chat-txt',
// useMarkup: false,
// xalign: 0,
// wrap: true,
// selectable: true,
// label: '------------------------------\n' + md2pango(content),
// }))
contentBox.show_all();
contentBox.attribute.lastUpdateTextLength = content.length - lines[lines.length - 1].length;
}
}
});
contentBox.attribute.fullUpdate(contentBox, content, false);
return contentBox;
}
export const ChatMessage = (message, modelName = 'Model') => {
const TextSkeleton = (extraClassName = '') => Box({
className: `sidebar-chat-message-skeletonline ${extraClassName}`,
})
const messageContentBox = MessageContent(message.content);
const messageLoadingSkeleton = Box({
vertical: true,
className: 'spacing-v-5',
children: Array.from({ length: 3 }, (_, id) => TextSkeleton(`sidebar-chat-message-skeletonline-offset${id}`)),
})
const messageArea = Stack({
homogeneous: message.role !== 'user',
transition: 'crossfade',
transitionDuration: userOptions.animations.durationLarge,
children: {
'thinking': messageLoadingSkeleton,
'message': messageContentBox,
},
shown: message.thinking ? 'thinking' : 'message',
});
const thisMessage = Box({
className: 'sidebar-chat-message',
homogeneous: true,
children: [
Box({
vertical: true,
children: [
Label({
hpack: 'start',
xalign: 0,
className: `txt txt-bold sidebar-chat-name sidebar-chat-name-${message.role == 'user' ? 'user' : 'bot'}`,
wrap: true,
useMarkup: true,
label: (message.role === 'user' ? USERNAME : modelName),
}),
Box({
homogeneous: true,
className: 'sidebar-chat-messagearea',
children: [messageArea]
})
],
setup: (self) => self
.hook(message, (self, isThinking) => {
messageArea.shown = message.thinking ? 'thinking' : 'message';
}, 'notify::thinking')
.hook(message, (self) => { // Message update
messageContentBox.attribute.fullUpdate(messageContentBox, message.content, message.role != 'user');
}, 'notify::content')
.hook(message, (label, isDone) => { // Remove the cursor
if (!isDone && message.role !== 'user') return;
messageContentBox.attribute.fullUpdate(messageContentBox, message.content + '\n', false);
// print('----------------')
// print(message.content)
}, 'notify::done')
,
})
]
});
return thisMessage;
}
export const SystemMessage = (content, commandName, scrolledWindow) => {
const messageContentBox = MessageContent(content + '\n'); // Add newline so everything is added
const thisMessage = Box({
className: 'sidebar-chat-message',
children: [
Box({
vertical: true,
children: [
Label({
xalign: 0,
hpack: 'start',
className: 'txt txt-bold sidebar-chat-name sidebar-chat-name-system',
wrap: true,
label: `System • ${commandName}`,
}),
messageContentBox,
],
})
],
});
return thisMessage;
}