import QtQuick import QtQuick.Layouts import QtQuick.Controls import QtQuick.Dialogs import org.kde.plasma.plasmoid import org.kde.plasma.components 3.0 as PlasmaComponents import org.kde.kirigami as Kirigami import org.kde.plasma.plasma5support as Plasma5Support PlasmoidItem { id: root property string system_prompt: plasmoid.configuration.systemPrompt Connections { target: plasmoid.configuration function onSystemPromptChanged() { root.system_prompt = plasmoid.configuration.systemPrompt; root.clearChat(); } } property string ollamaUrl: "http://localhost:11434" property var models: [] property string currentModel: "" property string selectedImagePath: "" property string pendingMessageText: "" property var chatHistory: [] property string chatText: "" property bool isWaiting: false function clearChat() { chatHistory = []; chatText = ""; selectedImagePath = ""; isWaiting = false; appendMessage("system", system_prompt); } function appendMessage(role, content, imageSource) { var msgObj = { role: role, content: content }; chatHistory.push(msgObj); let userColor = Kirigami.Theme.highlightColor; let assistantColor = Kirigami.Theme.positiveTextColor; let systemColor = Kirigami.Theme.disabledTextColor; if (chatText !== "") { chatText += "
"; } if (role === "user") { chatText += `You: ${content}`; if (imageSource) { chatText += `
`; } } else if (role === "assistant") { chatText += `Ollama: ${content}`; } else if (role === "system") { chatText += `System: ${content}`; } } function fetchModels() { let xhr = new XMLHttpRequest(); xhr.open("GET", ollamaUrl + "/api/tags"); xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { let res = JSON.parse(xhr.responseText); let list = []; if (res.models) { res.models.forEach(m => list.push(m.name)); models = list; if (list.length > 0 && currentModel === "") currentModel = list[0]; } } }; xhr.send(); } Plasma5Support.DataSource { id: executableSource engine: "executable" connectedSources: [] onNewData: (sourceName, data) => { if (data["stdout"]) { var base64Str = data["stdout"].toString().trim(); root.sendMessage(root.pendingMessageText, base64Str); disconnectSource(sourceName); } } } function processAndSendMessage(msg) { if (!currentModel || !msg || isWaiting) return; isWaiting = true; if (selectedImagePath !== "") { root.pendingMessageText = msg; // Robust path cleaning for Linux let cleanPath = decodeURIComponent(selectedImagePath.toString().replace("file://", "")); executableSource.connectSource("base64 -w 0 \"" + cleanPath + "\""); } else { sendMessage(msg, null); } } function sendMessage(msg, base64Image) { if (chatHistory.length === 0) { appendMessage("system", system_prompt); } // 1. Add to local UI history (this handles the visual part) appendMessage("user", msg, base64Image ? selectedImagePath : null); // Reset image selection immediately after UI update selectedImagePath = ""; // 2. Prepare the messages for the API call // We map the existing history first let requestMessages = chatHistory.map(m => ({ role: m.role, content: m.content })); // 3. IMPORTANT: Add the image to the LAST message in the request list if (base64Image && requestMessages.length > 0) { requestMessages[requestMessages.length - 1].images = [base64Image]; } let xhr = new XMLHttpRequest(); xhr.open("POST", ollamaUrl + "/api/chat"); xhr.setRequestHeader("Content-Type", "application/json"); xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE) { isWaiting = false; if (xhr.status === 200) { let res = JSON.parse(xhr.responseText); appendMessage("assistant", res.message.content); } else { chatText += `
Error: Check if Ollama is running.`; } } }; // Now requestMessages contains the image data in the last 'user' object xhr.send(JSON.stringify({ model: currentModel, messages: requestMessages, stream: false })); } // Hacky workaround the Kirigami theme loading bug... Timer { id: initTimer interval: 50 running: false repeat: false onTriggered: { if (root.chatHistory.length === 0) { root.appendMessage("system", root.system_prompt); } } } Component.onCompleted: { fetchModels(); initTimer.start(); plasmoid.configurationRequired = true; } FileDialog { id: fileDialog title: "Select an Image" nameFilters: ["Images (*.png *.jpg *.jpeg *.webp)"] onAccepted: root.selectedImagePath = selectedFile } fullRepresentation: ColumnLayout { anchors.fill: parent anchors.margins: 8 spacing: 8 Layout.minimumWidth: Math.max(Screen.width * .20, 400) Layout.minimumHeight: Math.max(Screen.height * .20, 200) // New Title Bar RowLayout { Layout.fillWidth: true Kirigami.Heading { text: "AI Chat" level: 2 Layout.fillWidth: true } RowLayout { PlasmaComponents.ComboBox { id: modelCombo Layout.fillWidth: true model: root.models onActivated: root.currentModel = currentText } PlasmaComponents.Button { icon.name: "view-refresh" onClicked: root.fetchModels() } PlasmaComponents.Button { icon.name: "edit-clear-all" onClicked: root.clearChat() } PlasmaComponents.Button { id: pinButton icon.name: root.hideOnWindowDeactivate ? "window-pin" : "window-unpin" checkable: true checked: !root.hideOnWindowDeactivate onClicked: root.hideOnWindowDeactivate = !checked // Tooltip to explain the button PlasmaComponents.ToolTip { text: "Keep Open" } } } } // Horizontal Line separator Kirigami.Separator { Layout.fillWidth: true } // Chat View PlasmaComponents.ScrollView { Layout.fillWidth: true Layout.fillHeight: true PlasmaComponents.TextArea { id: chatArea text: root.chatText readOnly: true textFormat: TextEdit.RichText wrapMode: TextEdit.WordWrap onTextChanged: { // Force scroll to bottom cursorPosition = length; } background: Rectangle { color: Kirigami.Theme.backgroundColor opacity: 0.3 radius: 4 } } } // Loading Indicator PlasmaComponents.ProgressBar { Layout.fillWidth: true indeterminate: true visible: root.isWaiting } // Image Preview 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: "Image attached" Layout.fillWidth: true } PlasmaComponents.Button { icon.name: "edit-delete" onClicked: root.selectedImagePath = "" } } } // Input Area RowLayout { PlasmaComponents.Button { icon.name: "mail-attachment" onClicked: fileDialog.open() enabled: !root.isWaiting } PlasmaComponents.TextField { id: inputField Layout.fillWidth: true placeholderText: "Type message..." enabled: !root.isWaiting onAccepted: { root.processAndSendMessage(text); text = ""; } } PlasmaComponents.Button { icon.name: "mail-send" enabled: !root.isWaiting && inputField.text !== "" onClicked: { root.processAndSendMessage(inputField.text); inputField.text = ""; } } } } }