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") } } } } }