diff --git a/contents/config/config.qml b/contents/config/config.qml
new file mode 100644
index 0000000..8503c30
--- /dev/null
+++ b/contents/config/config.qml
@@ -0,0 +1,10 @@
+import QtQuick
+import org.kde.plasma.configuration
+
+ConfigModel {
+ ConfigCategory {
+ name: i18n("General")
+ icon: "configure"
+ source: "configGeneral.qml"
+ }
+}
diff --git a/contents/config/main.xml b/contents/config/main.xml
new file mode 100644
index 0000000..cb1793a
--- /dev/null
+++ b/contents/config/main.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ You are a helpful assistant. Your answers are compact.
+
+
+
diff --git a/contents/ui/configGeneral.qml b/contents/ui/configGeneral.qml
new file mode 100644
index 0000000..75038e0
--- /dev/null
+++ b/contents/ui/configGeneral.qml
@@ -0,0 +1,14 @@
+import QtQuick
+import QtQuick.Controls as QQC2
+import org.kde.kirigami as Kirigami
+
+Kirigami.FormLayout {
+ id: page
+ property alias cfg_systemPrompt: systemPrompt.text
+
+ QQC2.TextArea {
+ id: systemPrompt
+ text: page.cfg_systemPrompt
+ placeholderText: page.cfg_systemPrompt.default
+ }
+}
diff --git a/contents/ui/main.qml b/contents/ui/main.qml
new file mode 100644
index 0000000..aa4b97e
--- /dev/null
+++ b/contents/ui/main.qml
@@ -0,0 +1,307 @@
+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 = ""
+ }
+ }
+ }
+ }
+}
diff --git a/metadata.json b/metadata.json
new file mode 100644
index 0000000..ab86763
--- /dev/null
+++ b/metadata.json
@@ -0,0 +1,13 @@
+{
+ "KPlugin": {
+ "Authors": [ { "Name": "AI Assistants (Gemini 3.0 Pro)" }, { "Name": "NikkeDoy" } ],
+ "Description": "Chat with local Ollama models",
+ "Icon": "dialog-messages",
+ "Id": "fi.huitsinnevada.ai_chat",
+ "Name": "AI Chat",
+ "Version": "1.0",
+ "Website": "https://huitsinnevada.fi/projects"
+ },
+ "KPackageStructure": "Plasma/Applet",
+ "X-Plasma-API-Minimum-Version": "6.0"
+}