From 2e68e83be55eb724aa5db391e831e0c9a73e52f3 Mon Sep 17 00:00:00 2001 From: NikkeDoy Date: Tue, 5 May 2026 12:59:06 +0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20|=20Unsloth=20Studio=20(OpenAI=20AP?= =?UTF-8?q?I)=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 65 +++- contents/config/main.xml | 28 ++ contents/ui/configGeneral.qml | 149 +++++++- contents/ui/main.qml | 627 +++++++++++++++++++++++++++------- metadata.json | 11 +- 5 files changed, 722 insertions(+), 158 deletions(-) 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. - -

AI Chat Widget screenshot

+

Unsloth AI Chat Widget screenshot

## 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" },