diff --git a/README.md b/README.md
index be00348..4fa94de 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,42 @@
-# AI Chat for KDE Plasma
+# Unsloth AI Chat Widget for KDE Plasma
-## IMPORTANT
-This is just a vibe-coded hobby project. If you wish to use it, then go for it!
+An integrated AI chat widget for KDE Plasma, powered by [Unsloth Studio](https://github.com/unslothai/unsloth). Interact with a local LLM directly from your desktop.
-## Intro
-
-An integrated AI chat widget for KDE Plasma, powered by Ollama. Interact with a local LLM directly from your desktop.
-
-

+
## Features
-- Chat with local Ollama models
-- Works inside Plasma Desktop
-- Easy installation via `kpackagetool6`
+- Chat with local Unsloth Studio models
+- Multi-turn conversations with full chat history
+- Image attachments for vision-capable models (PNG, JPG, JPEG, WEBP)
+- Configurable system prompts
+- Static API token authentication
+- Selectable default model
+- Clean, native KDE Plasma UI using Kirigami
+
+## Requirements
+
+- KDE Plasma 6.0 or later
+- [Unsloth Studio](https://github.com/unslothai/unsloth) running locally (see [Setup](#setup))
+
+## Setup
+
+Before using the widget, start Unsloth Studio:
+
+```bash
+pip install unsloth
+unsloth studio -H 0.0.0.0 -p 8888
+```
+
+This will serve your models at `http://localhost:8888`.
## Installation
1. **Install the widget**:
```bash
- git clone https://git.huitsinnevada.fi/NikkeDoy/Plasma-AI-Chat
- cd Plasma-AI-Chat
+ git clone https://git.huitsinnevada.fi/NikkeDoy/Plasma-unsloth-Chat
+ cd Plasma-unsloth-Chat
kpackagetool6 -t Plasma/Applet -i .
```
@@ -33,7 +48,7 @@ An integrated AI chat widget for KDE Plasma, powered by Ollama. Interact with a
plasmashell --replace &
```
- > Restarting is recommended to fully activate the new widget.
+ > Restarting is recommended to fully activate the new widget.
> Alternatively, log out and log back in.
## Usage
@@ -41,10 +56,26 @@ An integrated AI chat widget for KDE Plasma, powered by Ollama. Interact with a
After installation, add the widget to your panel:
1. Right‑click the panel → **Add Widgets…**.
-2. Search for **AI Chat** and add it.
+2. Search for **Unsloth** and add it.
+3. Open the widget settings (right‑click → Configure) and enter your Unsloth Studio URL and API token.
-The widget will launch and allow you to chat with your local Ollama models.
+The widget will connect to your Unsloth Studio instance, list available models, and let you start chatting.
+
+## Configuration
+
+Right‑click the widget → **Configure** to set:
+
+| Setting | Description |
+|---------|-------------|
+| **Unsloth Studio URL** | API endpoint (e.g., `http://localhost:8888`) |
+| **API Token** | Authentication token for the API |
+| **System Prompt** | Custom system prompt for every conversation |
+| **Default Model** | Model to select on startup |
## License
-This project is licensed under the MIT license. See the `LICENSE` file for details.
+This project is licensed under the GNU General Public License v3.0. See the `LICENSE` file for details.
+
+## Project Website
+
+
diff --git a/contents/config/main.xml b/contents/config/main.xml
index e69de29..ab19912 100644
--- a/contents/config/main.xml
+++ b/contents/config/main.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ http://localhost:8888
+
+
+
+
+
+
+
+
+
+
+
+ 50
+
+
+
+
+
+
+ configGeneral.qml
+
+
diff --git a/contents/ui/configGeneral.qml b/contents/ui/configGeneral.qml
index 0d7f33f..34bfee2 100644
--- a/contents/ui/configGeneral.qml
+++ b/contents/ui/configGeneral.qml
@@ -1,39 +1,166 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
+import org.kde.plasma.plasmoid
import org.kde.kirigami as Kirigami
-Kirigami.Page {
+Kirigami.ScrollablePage {
id: page
- property alias cfg_systemPrompt: systemPrompt.text
- property alias cfg_defaultModel: defaultModelField.text
- contentItem: ColumnLayout {
+ // Core configuration aliases (Two-way binding)
+ property alias cfg_unslothUrl: unslothUrlField.text
+ property alias cfg_apiToken: apiTokenField.text
+ property alias cfg_systemPrompt: systemPromptField.text
+ property alias cfg_defaultModel: defaultModelField.text
+ property alias cfg_maxHistoryMessages: maxHistorySpinBox.value
+
+ // Default values passed by the Plasma config loader
+ // These resolve the "failed to set initial properties" errors
+ property string cfg_unslothUrlDefault
+ property string cfg_apiTokenDefault
+ property string cfg_systemPromptDefault
+ property string cfg_defaultModelDefault
+ property int cfg_maxHistoryMessagesDefault
+
+ ColumnLayout {
+ anchors.margins: Kirigami.Units.largeSpacing
+ spacing: Kirigami.Units.largeSpacing
+
+ // Help button in the header area
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Kirigami.Units.largeSpacing
+
+ QQC2.Label {
+ text: i18n("Configure Unsloth Chat")
+ Layout.fillWidth: true
+ font.bold: true
+ font.pointSize: Kirigami.Theme.defaultFont.pointSize
+ }
+
+ QQC2.Button {
+ text: i18n("Help →")
+ icon.name: "help-external-link"
+ onClicked: Qt.openUrlExternally("https://huitsinnevada.fi/projects")
+ }
+ }
+
+ // --- Unsloth Studio section ---
+ Kirigami.Separator { Layout.fillWidth: true }
+
Kirigami.Heading {
- text: "System Prompt"
+ text: i18n("Unsloth Studio")
+ level: 2
+ Layout.fillWidth: true
+ }
+
+ QQC2.TextField {
+ id: unslothUrlField
+ placeholderText: i18n("Unsloth Studio URL (e.g., http://localhost:8888)")
+ Layout.fillWidth: true
+ }
+
+ QQC2.TextField {
+ id: apiTokenField
+ placeholderText: i18n("API Token")
+ echoMode: TextInput.Password
+ Layout.fillWidth: true
+ }
+
+ // --- System Prompt section ---
+ Kirigami.Separator { Layout.fillWidth: true }
+
+ Kirigami.Heading {
+ text: i18n("System Prompt")
level: 2
Layout.fillWidth: true
}
QQC2.TextArea {
- id: systemPrompt
- placeholderText: "Enter the system prompt here..."
+ id: systemPromptField
+ placeholderText: i18n("Enter the system prompt here...")
Layout.fillWidth: true
- Layout.preferredHeight: 240
+ Layout.preferredHeight: 200
wrapMode: TextEdit.Wrap
clip: true
}
+ // --- Default Model section ---
+ Kirigami.Separator { Layout.fillWidth: true }
+
Kirigami.Heading {
- text: "Default Model"
- level: 3
+ text: i18n("Default Model")
+ level: 2
Layout.fillWidth: true
}
QQC2.TextField {
id: defaultModelField
- placeholderText: "Enter the default model name (e.g., llama3)"
+ placeholderText: i18n("Enter the default model name (e.g., unsloth/llama-3-8b)")
Layout.fillWidth: true
}
+
+ // --- Chat History section ---
+ Kirigami.Separator { Layout.fillWidth: true }
+
+ Kirigami.Heading {
+ text: i18n("Chat History")
+ level: 2
+ Layout.fillWidth: true
+ }
+
+ QQC2.Label {
+ text: i18n("Maximum number of messages to keep in chat history:")
+ wrapMode: TextEdit.WordWrap
+ Layout.fillWidth: true
+ }
+
+ QQC2.SpinBox {
+ id: maxHistorySpinBox
+ from: 1
+ to: 500
+ value: 50
+ enabled: true
+ Layout.fillWidth: true
+ }
+
+ Kirigami.Separator { Layout.fillWidth: true }
+
+ // --- Buttons ---
+ RowLayout {
+ Layout.alignment: Qt.AlignRight
+ spacing: Kirigami.Units.smallSpacing
+
+ QQC2.Button {
+ text: i18n("Reset")
+ onClicked: {
+ unslothUrlField.text = "http://localhost:8888";
+ apiTokenField.text = "";
+ systemPromptField.text = "";
+ defaultModelField.text = "";
+ maxHistorySpinBox.value = 50;
+ // Explicitly reset configuration values
+ plasmoid.configuration.unslothUrl = "http://localhost:8888";
+ plasmoid.configuration.apiToken = "";
+ plasmoid.configuration.systemPrompt = "";
+ plasmoid.configuration.defaultModel = "";
+ plasmoid.configuration.maxHistoryMessages = 50;
+ }
+ }
+
+ QQC2.Button {
+ text: i18n("Save")
+ onClicked: {
+ // Save all values explicitly (the cfg_ aliases update automatically
+ // via onTextChanged, but this ensures the values are written even
+ // if the user never edited a field)
+ plasmoid.configuration.unslothUrl = unslothUrlField.text;
+ plasmoid.configuration.apiToken = apiTokenField.text;
+ plasmoid.configuration.systemPrompt = systemPromptField.text;
+ plasmoid.configuration.defaultModel = defaultModelField.text;
+ plasmoid.configuration.maxHistoryMessages = maxHistorySpinBox.value;
+ }
+ }
+ }
}
}
diff --git a/contents/ui/main.qml b/contents/ui/main.qml
index a1b3067..ccaa91d 100644
--- a/contents/ui/main.qml
+++ b/contents/ui/main.qml
@@ -2,20 +2,281 @@ import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Dialogs
-import org.kde.plasma.plasmoid
+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
-import org.kde.plasma.plasma5support as Plasma5Support
PlasmoidItem {
id: root
- property string system_prompt: plasmoid.configuration.systemPrompt
+ // ── 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.system_prompt = plasmoid.configuration.systemPrompt;
+ root.systemPrompt = plasmoid.configuration.systemPrompt;
root.clearChat();
}
}
@@ -23,160 +284,204 @@ PlasmoidItem {
Connections {
target: plasmoid.configuration
function onDefaultModelChanged() {
- if (models.includes(plasmoid.configuration.defaultModel)) {
+ if (root.models.indexOf(plasmoid.configuration.defaultModel) >= 0) {
currentModel = plasmoid.configuration.defaultModel;
}
}
}
- 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
+ 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;
- 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}`;
+ if (systemPrompt !== "") {
+ appendMessage("system", systemPrompt);
}
}
+ // ── Fetch models ────────────────────────────────────────────────────
function fetchModels() {
- let xhr = new XMLHttpRequest();
- xhr.open("GET", ollamaUrl + "/api/tags");
+ 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) {
- let res = JSON.parse(xhr.responseText);
- let list = [];
- if (res.models) {
- res.models.forEach(m => list.push(m.name));
+ 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;
- // Prefer user configured default model if present
- if (plasmoid.configuration.defaultModel && list.includes(plasmoid.configuration.defaultModel)) {
- currentModel = plasmoid.configuration.defaultModel;
- } else if (list.length > 0 && currentModel === "") {
- currentModel = list[0];
+ 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();
}
- 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);
- }
- }
- }
-
+ // ── Send message ────────────────────────────────────────────────────
function processAndSendMessage(msg) {
- if (!currentModel || !msg || isWaiting)
+ 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;
- // Robust path cleaning for Linux
- let cleanPath = decodeURIComponent(selectedImagePath.toString().replace("file://", ""));
- executableSource.connectSource("base64 -w 0 \"" + cleanPath + "\"");
+ var mimePrefix = root.base64MimePrefix(selectedImagePath);
+ root.readBase64(selectedImagePath, mimePrefix);
} else {
- sendMessage(msg, null);
+ sendMessage(msg, null, "");
}
}
- function sendMessage(msg, base64Image) {
- if (chatHistory.length === 0) {
- appendMessage("system", system_prompt);
+ 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);
}
- // 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];
+ 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
+ });
}
- let xhr = new XMLHttpRequest();
- xhr.open("POST", ollamaUrl + "/api/chat");
+ 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) {
- let res = JSON.parse(xhr.responseText);
- appendMessage("assistant", res.message.content);
+ 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 {
- chatText += `
Error: Check if Ollama is running.`;
+ root._appendChat("Error " + xhr.status + ": Check if Unsloth Studio is running.");
}
}
};
-
- // Now requestMessages contains the image data in the last 'user' object
xhr.send(JSON.stringify({
- model: currentModel,
- messages: requestMessages,
- stream: false
+ "model": currentModel,
+ "messages": requestMessages,
+ "stream": false
}));
}
- // Hacky workaround the Kirigami theme loading bug...
+ // ── Init timer ──────────────────────────────────────────────────────
Timer {
id: initTimer
interval: 50
running: false
repeat: false
onTriggered: {
- if (root.chatHistory.length === 0) {
- root.appendMessage("system", root.system_prompt);
+ if (root.chatHistory.length === 0 && root.systemPrompt !== "") {
+ root.appendMessage("system", root.systemPrompt);
}
}
}
@@ -184,35 +489,63 @@ PlasmoidItem {
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
+ // ── 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
- Layout.minimumWidth: Math.max(Screen.width * .20, 400)
- Layout.minimumHeight: Math.max(Screen.height * .20, 200)
+ // 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
- // New Title Bar
RowLayout {
Layout.fillWidth: true
Kirigami.Heading {
- text: "AI Chat"
+ 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
@@ -221,11 +554,21 @@ PlasmoidItem {
}
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 {
@@ -233,23 +576,23 @@ PlasmoidItem {
icon.name: root.hideOnWindowDeactivate ? "window-pin" : "window-unpin"
checkable: true
checked: !root.hideOnWindowDeactivate
- onClicked: root.hideOnWindowDeactivate = !checked
-
- // Tooltip to explain the button
+ onClicked: {
+ root.hideOnWindowDeactivate = !checked;
+ }
PlasmaComponents.ToolTip {
- text: "Keep Open"
+ visible: pinButton.hovered
+ text: root.hideOnWindowDeactivate ? i18n("Keep widget open (pinned)") : i18n("Close widget when unfocused")
}
}
}
}
- // Horizontal Line separator
Kirigami.Separator {
Layout.fillWidth: true
}
- // Chat View
PlasmaComponents.ScrollView {
+ id: chatScrollView
Layout.fillWidth: true
Layout.fillHeight: true
@@ -259,10 +602,6 @@ PlasmoidItem {
readOnly: true
textFormat: TextEdit.RichText
wrapMode: TextEdit.WordWrap
- onTextChanged: {
- // Force scroll to bottom
- cursorPosition = length;
- }
background: Rectangle {
color: Kirigami.Theme.backgroundColor
opacity: 0.3
@@ -271,14 +610,34 @@ PlasmoidItem {
}
}
- // Loading Indicator
+ // 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
}
- // Image Preview
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 80
@@ -296,39 +655,55 @@ PlasmoidItem {
fillMode: Image.PreserveAspectFit
}
PlasmaComponents.Label {
- text: "Image attached"
+ 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"
+ }
}
}
}
- // Input Area
RowLayout {
PlasmaComponents.Button {
icon.name: "mail-attachment"
- onClicked: fileDialog.open()
+ 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: "Type message..."
+ placeholderText: i18n("Type message...")
enabled: !root.isWaiting
onAccepted: {
root.processAndSendMessage(text);
- text = "";
}
}
+ Component.onCompleted: root.inputField = inputField
PlasmaComponents.Button {
icon.name: "mail-send"
+ hoverEnabled: true
enabled: !root.isWaiting && inputField.text !== ""
onClicked: {
root.processAndSendMessage(inputField.text);
- inputField.text = "";
+ }
+ PlasmaComponents.ToolTip {
+ visible: parent.hovered
+ text: i18n("Send message")
}
}
}
diff --git a/metadata.json b/metadata.json
index ab86763..f66b2fe 100644
--- a/metadata.json
+++ b/metadata.json
@@ -1,10 +1,13 @@
{
"KPlugin": {
- "Authors": [ { "Name": "AI Assistants (Gemini 3.0 Pro)" }, { "Name": "NikkeDoy" } ],
- "Description": "Chat with local Ollama models",
+ "Authors": [
+ { "Name": "AI Assistants (Gemini 3.0 Pro, Qwen3.6-35B)" },
+ { "Name": "NikkeDoy" }
+ ],
+ "Description": "Chat with local models using Unsloth Studio API.",
"Icon": "dialog-messages",
- "Id": "fi.huitsinnevada.ai_chat",
- "Name": "AI Chat",
+ "Id": "fi.huitsinnevada.unsloth_chat",
+ "Name": "Unsloth",
"Version": "1.0",
"Website": "https://huitsinnevada.fi/projects"
},