import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Window
import org.kde.plasma.core 2.0 as PlasmaCore
import org.kde.plasma.plasmoid 2.0
import org.kde.plasma.components 3.0 as PlasmaComponents
import org.kde.kirigami as Kirigami
PlasmoidItem {
id: root
// ── Pending image info for encoding pipeline ────────────────────────
property string _pendingImagePath: ""
property string _pendingImageMime: ""
// ── Hidden helpers for base64 image encoding (not rendered in panel) ─
Item {
id: _helpers
// ── Hidden Image element for native file:// loading ──
Image {
id: _fileImage
visible: false
asynchronous: true
onStatusChanged: {
if (status === Image.Ready) {
_fileCanvas.width = _fileImage.sourceSize.width > 0 ? _fileImage.sourceSize.width : _fileImage.width
_fileCanvas.height = _fileImage.sourceSize.height > 0 ? _fileImage.sourceSize.height : _fileImage.height
_fileCanvas.requestPaint()
} else if (status === Image.Error) {
root.isWaiting = false
root._appendChat("Could not load image file.")
if (root.inputField) root.inputField.text = root.lastSentMessage
root._pendingImagePath = ""
}
}
}
// ── Hidden Canvas element for converting loaded image to base64 ──
Canvas {
id: _fileCanvas
visible: false
width: 1
height: 1
onPaint: {
var ctx = getContext("2d")
ctx.drawImage(_fileImage, 0, 0, _fileImage.width, _fileImage.height)
var dataUrl = _fileCanvas.toDataURL("image/png")
var commaIdx = dataUrl.indexOf(",")
if (commaIdx >= 0) {
var b64 = dataUrl.substring(commaIdx + 1)
if (root._pendingImagePath !== "") {
root.sendMessage(root.pendingMessageText, b64, root._pendingImageMime)
root._pendingImagePath = ""
}
}
}
}
}
// ── Tooltip settings ────────────────────────────────────────────────
toolTipMainText: i18n("AI Chat")
toolTipSubText: isWaiting ? i18n("Waiting...") : (currentModel ? i18n("Connected: %1", currentModel) : i18n("Loading models..."))
// ── Configuration bindings ──────────────────────────────────────────
property string systemPrompt: plasmoid.configuration.systemPrompt || ""
property string unslothUrl: plasmoid.configuration.unslothUrl || "http://localhost:8888"
property string apiToken: plasmoid.configuration.apiToken || ""
property int maxHistoryMessages: plasmoid.configuration.maxHistoryMessages || 50
property var models: []
property bool modelsFetched: false
property string currentModel: ""
property string selectedImagePath: ""
property string pendingMessageText: ""
property var chatHistory: []
property string chatText: ""
property string lastSentMessage: "" // preserve on failure so user can retry
property bool isWaiting: false
// Flag to trigger auto-scroll after chat text updates
property bool needsScroll: false
// Reference to the input field — set from inside fullRepresentation
// because inputField is nested and not directly accessible from root scope
property var inputField: null
// Connection that triggers auto-scroll when needsScroll changes.
// The scrollTarget reference is set lazily when fullRepresentation loads.
property var scrollTarget: null
Connections {
target: root
function onNeedsScrollChanged() {
if (root.needsScroll) {
root.needsScroll = false;
if (root.scrollTarget) {
root.scrollTarget.start();
}
}
}
}
// Helper: append a line to chat text and trigger auto-scroll
function _appendChat(line) {
if (chatText !== "") chatText += "
";
chatText += line;
needsScroll = true;
}
// ── Network timeout (ms) ───────────────────────────────────────────
property int requestTimeout: 60000
// ── Image MIME map ─────────────────────────────────────────────────
function imageMime(path) {
var ext = path.split(".").pop().toLowerCase();
if (ext === "png") return "image/png";
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
if (ext === "webp") return "image/webp";
return "image/jpeg"; // fallback
}
function base64MimePrefix(path) {
return "data:" + imageMime(path) + ";base64,";
}
// ── Sanitize ────────────────────────────────────────────────────────
function escapeHtml(str) {
if (typeof str !== "string") return "";
return str
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// ── Helpers ─────────────────────────────────────────────────────────
function _trimHistory() {
// Always keep the system message at index 0
if (chatHistory.length > maxHistoryMessages + 1) {
// Remove oldest non-system messages
chatHistory = [chatHistory[0]].concat(
chatHistory.slice(chatHistory.length - maxHistoryMessages)
);
}
}
function rebuildChatText() {
chatText = "";
for (var i = 0; i < chatHistory.length; i++) {
var m = chatHistory[i];
if (i > 0) chatText += "
";
var col, bold;
if (m.role === "user") {
col = Kirigami.Theme.highlightColor;
bold = "You";
} else if (m.role === "assistant") {
col = Kirigami.Theme.positiveTextColor;
bold = "Unsloth";
} else {
col = Kirigami.Theme.disabledTextColor;
bold = "System";
}
if (m.role === "system") {
if (m.content === "") {
continue;
}
chatText += '' + bold + ': ' + escapeHtml(m.content) + '';
} else {
var msgHtml = '' + bold + ': ' + escapeHtml(m.content);
// Display attached image thumbnail for user messages
if (m.role === "user" && m.imageSource) {
msgHtml += '
';
}
chatText += msgHtml;
}
}
needsScroll = true;
}
function _pruneChatText() {
// Keep chatText to a reasonable maximum length to prevent memory bloat.
// Remove oldest non-system messages from the beginning while preserving the system message.
var maxChars = 100000; // ~100KB of HTML text
// Only prune if there are enough messages (system + at least 1 user/assistant)
if (chatText.length > maxChars && chatHistory.length > 2) {
// Rebuild chatText repeatedly, removing the oldest non-system message each time,
// until the length is within bounds. We do NOT parse the HTML text for
// because message content may contain literal
tags that would corrupt
// indexOf-based slicing.
while (chatText.length > maxChars && chatHistory.length > 2) {
// Remove oldest non-system message (index 1+, never index 0 which is system)
chatHistory.shift();
rebuildChatText();
}
}
}
function appendMessage(role, content, imageSource) {
chatHistory.push({ role: role, content: content });
_trimHistory();
// Store imageSource reference for multimodal sending (not displayed in text)
if (role === "user" && imageSource) {
chatHistory[chatHistory.length - 1].imageSource = imageSource;
}
rebuildChatText();
_pruneChatText();
}
// ── Secure base64 read (no shell injection) ─────────────────────────
function readBase64(path, mimePrefix) {
// Reads the local image file and encodes it to base64 safely.
// Uses the hidden Image element for native file:// loading,
// then Canvas.toDataURL() to convert to base64 PNG.
// This avoids the previous XHR file:// restriction and the
// previous shell-injection vulnerability from
// running "base64 -w 0 " via an executable DataSource.
_sendBase64ViaHelper(path, mimePrefix);
}
function _sendBase64ViaHelper(path, mimePrefix) {
// Read the image file using the hidden Image element (native file:// loading).
// The Image element can load local files without the XHR restriction.
// Then use the hidden Canvas.toDataURL() to convert to base64 PNG.
// This is safe: no shell invocation, no path injection.
// Store the pending info so the Canvas.onPaint callback knows what to do
root._pendingImagePath = path
root._pendingImageMime = mimePrefix
// Trigger loading by setting the Image source
_fileImage.source = path
}
// ── ArrayBuffer → base64 conversion ─────────────────────────────────
function _arrayBufferToBase64(buffer) {
// Use Uint16Array to process 2 bytes at a time, much faster than
// per-byte String.fromCharCode calls in a tight loop.
var bytes = new Uint8Array(buffer);
var len = bytes.length;
var chunks = [];
var chunkSize = 8192; // smaller chunks to avoid string limits
var offset = 0;
while (offset < len) {
var end = Math.min(offset + chunkSize, len);
var arr = bytes.subarray(offset, end);
var bin = "";
for (var i = 0; i < arr.length; i++) {
bin += String.fromCharCode(arr[i]);
}
chunks.push(bin);
offset = end;
}
return btoa(chunks.join(""));
}
// ── URI unescape helper ─────────────────────────────────────────────
function _unescapeUri(s) {
try {
return decodeURIComponent(s);
} catch (_) {
return s;
}
}
// ── Connections ─────────────────────────────────────────────────────
Connections {
target: plasmoid.configuration
function onSystemPromptChanged() {
root.systemPrompt = plasmoid.configuration.systemPrompt;
root.clearChat();
}
}
Connections {
target: plasmoid.configuration
function onDefaultModelChanged() {
if (root.models.indexOf(plasmoid.configuration.defaultModel) >= 0) {
currentModel = plasmoid.configuration.defaultModel;
}
}
}
Connections {
target: plasmoid.configuration
function onUnslothUrlChanged() {
root.unslothUrl = plasmoid.configuration.unslothUrl;
}
}
Connections {
target: plasmoid.configuration
function onApiTokenChanged() {
root.apiToken = plasmoid.configuration.apiToken;
}
}
Connections {
target: plasmoid.configuration
function onMaxHistoryMessagesChanged() {
root.maxHistoryMessages = plasmoid.configuration.maxHistoryMessages;
}
}
// ── Clear chat ──────────────────────────────────────────────────────
function clearChat() {
chatHistory = [];
chatText = "";
selectedImagePath = "";
isWaiting = false;
if (systemPrompt !== "") {
appendMessage("system", systemPrompt);
}
}
// ── Fetch models ────────────────────────────────────────────────────
function fetchModels() {
var xhr = new XMLHttpRequest();
xhr.timeout = requestTimeout;
modelsFetched = false;
xhr.ontimeout = function () {
root._appendChat("Connection timed out fetching models.");
};
xhr.onerror = function () {
root._appendChat("Connection failed fetching models.");
};
xhr.open("GET", unslothUrl + "/v1/models");
if (apiToken) {
xhr.setRequestHeader("Authorization", "Bearer " + apiToken);
}
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
var res;
try {
res = JSON.parse(xhr.responseText);
} catch (e) {
root._appendChat("Invalid response from server.");
return;
}
var list = [];
if (res.data) {
for (var i = 0; i < res.data.length; i++) {
list.push(res.data[i].id);
}
models = list;
modelsFetched = true;
if (list.length === 0) {
root._appendChat("No models are available.");
} else {
if (list.indexOf(plasmoid.configuration.defaultModel) >= 0) {
currentModel = plasmoid.configuration.defaultModel;
} else if (currentModel === "") {
currentModel = list[0];
}
}
}
} else if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 401) {
root._appendChat("Authorization failed. Check your API token.");
} else if (xhr.readyState === XMLHttpRequest.DONE) {
root._appendChat("Could not load models (status " + xhr.status + ").");
}
};
xhr.send();
}
// ── Send message ────────────────────────────────────────────────────
function processAndSendMessage(msg) {
if (!currentModel || !msg.trim() || isWaiting)
return;
isWaiting = true;
root.lastSentMessage = msg; // preserve message text in case of failure
if (root.inputField) root.inputField.text = "";
if (selectedImagePath !== "") {
root.pendingMessageText = msg;
var mimePrefix = root.base64MimePrefix(selectedImagePath);
root.readBase64(selectedImagePath, mimePrefix);
} else {
sendMessage(msg, null, "");
}
}
function sendMessage(msg, base64Image, mimePrefix) {
// isWaiting is managed by the caller (processAndSendMessage).
// XHR onready/timeout/error callbacks are the sole source that
// reset it to false when the request completes.
if (chatHistory.length === 0 && systemPrompt !== "") {
appendMessage("system", systemPrompt);
}
appendMessage("user", msg, base64Image ? selectedImagePath : null);
selectedImagePath = "";
var requestMessages = [];
for (var i = 0; i < chatHistory.length; i++) {
var m = chatHistory[i];
var content = m.content;
// If this is a user message with an attached image, send multimodal
if (m.role === "user" && m.imageSource && base64Image && i === chatHistory.length - 1) {
content = [
{
"type": "text",
"text": msg
},
{
"type": "image_url",
"image_url": {
"url": mimePrefix + base64Image
}
}
];
}
requestMessages.push({
"role": m.role,
"content": content
});
}
var xhr = new XMLHttpRequest();
xhr.timeout = requestTimeout;
xhr.ontimeout = function () {
root._appendChat("Request timed out. The model may be loading.");
isWaiting = false;
if (root.inputField) root.inputField.text = root.lastSentMessage;
};
xhr.onerror = function () {
root._appendChat("Connection error. Check if Unsloth Studio is running.");
isWaiting = false;
if (root.inputField) root.inputField.text = root.lastSentMessage;
};
xhr.open("POST", unslothUrl + "/v1/chat/completions");
xhr.setRequestHeader("Content-Type", "application/json");
if (apiToken) {
xhr.setRequestHeader("Authorization", "Bearer " + apiToken);
}
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
isWaiting = false;
if (xhr.status === 200) {
var res;
try {
res = JSON.parse(xhr.responseText);
} catch (e) {
root._appendChat("Invalid response from server.");
return;
}
if (res.choices && res.choices.length > 0) {
appendMessage("assistant", res.choices[0].message.content);
} else {
root._appendChat("Error: No response from model.");
}
} else if (xhr.status === 401) {
root._appendChat("Authorization failed. Check your API token.");
if (root.inputField) root.inputField.text = root.lastSentMessage;
} else {
root._appendChat("Error " + xhr.status + ": Check if Unsloth Studio is running.");
}
}
};
xhr.send(JSON.stringify({
"model": currentModel,
"messages": requestMessages,
"stream": false
}));
}
// ── Init timer ──────────────────────────────────────────────────────
Timer {
id: initTimer
interval: 50
running: false
repeat: false
onTriggered: {
if (root.chatHistory.length === 0 && root.systemPrompt !== "") {
root.appendMessage("system", root.systemPrompt);
}
}
}
Component.onCompleted: {
fetchModels();
initTimer.start();
}
// ── File dialog ─────────────────────────────────────────────────────
Loader {
id: fileDialogLoader
active: false
sourceComponent: FileDialog {
id: fileDialog
title: "Select an Image"
nameFilters: [
"Images (*.png *.jpg *.jpeg *.webp *.bmp *.tiff *.tif *.svg *.ico *.avif)"
]
onAccepted: {
var path = selectedFile.toString();
if (path.indexOf("file://") === 0) {
path = path.substring(7);
}
root.selectedImagePath = path;
fileDialogLoader.active = false
}
onRejected: {
fileDialogLoader.active = false
}
Component.onCompleted: open()
}
}
// ── UI ──────────────────────────────────────────────────────────────
fullRepresentation: ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
// Use numeric fallback for gridUnit since PlasmaCore.Units may not resolve
// in nested component context. Default gridUnit is typically 13.
Layout.minimumWidth: 13 * 15
Layout.minimumHeight: 13 * 10
Layout.preferredWidth: 13 * 20
Layout.preferredHeight: 13 * 18
RowLayout {
Layout.fillWidth: true
Kirigami.Heading {
text: i18n("AI Chat")
level: 2
Layout.fillWidth: true
}
RowLayout {
PlasmaComponents.Label {
visible: root.modelsFetched
text: "✓"
color: "green"
font.bold: true
font.pixelSize: 20
}
PlasmaComponents.ComboBox {
id: modelCombo
Layout.fillWidth: true
model: root.models
onActivated: root.currentModel = currentText
}
PlasmaComponents.Button {
icon.name: "view-refresh"
hoverEnabled: true
onClicked: root.fetchModels()
PlasmaComponents.ToolTip {
visible: parent.hovered
text: i18n("Refresh models")
}
}
PlasmaComponents.Button {
icon.name: "edit-clear-all"
hoverEnabled: true
onClicked: root.clearChat()
PlasmaComponents.ToolTip {
visible: parent.hovered
text: i18n("Clear chat")
}
}
PlasmaComponents.Button {
id: pinButton
icon.name: root.hideOnWindowDeactivate ? "window-pin" : "window-unpin"
checkable: true
checked: !root.hideOnWindowDeactivate
onClicked: {
root.hideOnWindowDeactivate = !checked;
}
PlasmaComponents.ToolTip {
visible: pinButton.hovered
text: root.hideOnWindowDeactivate ? i18n("Keep widget open (pinned)") : i18n("Close widget when unfocused")
}
}
}
}
Kirigami.Separator {
Layout.fillWidth: true
}
PlasmaComponents.ScrollView {
id: chatScrollView
Layout.fillWidth: true
Layout.fillHeight: true
PlasmaComponents.TextArea {
id: chatArea
text: root.chatText
readOnly: true
textFormat: TextEdit.RichText
wrapMode: TextEdit.WordWrap
background: Rectangle {
color: Kirigami.Theme.backgroundColor
opacity: 0.3
radius: 4
}
}
}
// Scroll to the bottom of the content area.
// Guard against lazy-loaded popup: contentItem or viewportHeight may be undefined.
function scrollToBottom() {
if (chatScrollView.contentItem && chatScrollView.viewportHeight !== undefined) {
var contentHeight = chatScrollView.contentItem.contentHeight;
if (contentHeight > chatScrollView.viewportHeight) {
chatScrollView.contentItem.contentY = contentHeight - chatScrollView.viewportHeight;
} else {
chatScrollView.contentItem.contentY = contentHeight;
}
}
}
// Auto-scroll timer — defined here inside fullRepresentation
Timer {
id: autoScrollTimer
interval: 50
running: false
repeat: false
onTriggered: scrollToBottom()
}
PlasmaComponents.ProgressBar {
Layout.fillWidth: true
indeterminate: true
visible: root.isWaiting
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 80
visible: root.selectedImagePath !== ""
color: Kirigami.Theme.backgroundColor
opacity: 0.8
radius: 4
RowLayout {
anchors.fill: parent
anchors.margins: 5
Image {
source: root.selectedImagePath
Layout.fillHeight: true
Layout.preferredWidth: 70
fillMode: Image.PreserveAspectFit
}
PlasmaComponents.Label {
text: i18n("Image attached")
Layout.fillWidth: true
}
PlasmaComponents.Button {
icon.name: "edit-delete"
hoverEnabled: true
onClicked: root.selectedImagePath = ""
PlasmaComponents.ToolTip {
visible: parent.hovered
text: "Remove image"
}
}
}
}
RowLayout {
PlasmaComponents.Button {
icon.name: "mail-attachment"
onClicked: {
root.hideOnWindowDeactivate = false;
fileDialogLoader.active = true;
}
hoverEnabled: true
enabled: !root.isWaiting
PlasmaComponents.ToolTip {
visible: parent.hovered
text: i18n("Attach image")
}
}
PlasmaComponents.TextField {
id: inputField
Layout.fillWidth: true
placeholderText: i18n("Type message...")
enabled: !root.isWaiting
onAccepted: {
root.processAndSendMessage(text);
}
}
Component.onCompleted: root.inputField = inputField
PlasmaComponents.Button {
icon.name: "mail-send"
hoverEnabled: true
enabled: !root.isWaiting && inputField.text !== ""
onClicked: {
root.processAndSendMessage(inputField.text);
}
PlasmaComponents.ToolTip {
visible: parent.hovered
text: i18n("Send message")
}
}
}
}
}