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 width: 500 height: 750 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 // New: Loading state 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: 10 spacing: 8 // 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 = "" } } } } }