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