Compare commits

1 Commits

Author SHA1 Message Date
7a9891131d 📄 | License changes 2026-05-05 13:01:59 +03:00
5 changed files with 162 additions and 726 deletions

View File

@@ -1,42 +1,27 @@
# Unsloth AI Chat Widget for KDE Plasma
# AI Chat for KDE Plasma
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.
## IMPORTANT
This is just a vibe-coded hobby project. If you wish to use it, then go for it!
<p align="center"><img src="assets/screenshots/image-1.webp" alt="Unsloth AI Chat Widget screenshot" /></p>
## Intro
An integrated AI chat widget for KDE Plasma, powered by Ollama. Interact with a local LLM directly from your desktop.
<p align="center"><img src="assets/screenshots/image-1.webp" alt="AI Chat Widget screenshot" /></p>
## Features
- 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`.
- Chat with local Ollama models
- Works inside Plasma Desktop
- Easy installation via `kpackagetool6`
## Installation
1. **Install the widget**:
```bash
git clone https://git.huitsinnevada.fi/NikkeDoy/Plasma-unsloth-Chat
cd Plasma-unsloth-Chat
git clone https://git.huitsinnevada.fi/NikkeDoy/Plasma-AI-Chat
cd Plasma-AI-Chat
kpackagetool6 -t Plasma/Applet -i .
```
@@ -48,7 +33,7 @@ This will serve your models at `http://localhost:8888`.
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
@@ -56,26 +41,10 @@ This will serve your models at `http://localhost:8888`.
After installation, add the widget to your panel:
1. Rightclick the panel → **Add Widgets…**.
2. Search for **Unsloth** and add it.
3. Open the widget settings (rightclick → Configure) and enter your Unsloth Studio URL and API token.
2. Search for **AI Chat** and add it.
The widget will connect to your Unsloth Studio instance, list available models, and let you start chatting.
## Configuration
Rightclick 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 |
The widget will launch and allow you to chat with your local Ollama models.
## License
This project is licensed under the GNU General Public License v3.0. See the `LICENSE` file for details.
## Project Website
<https://huitsinnevada.fi/projects>
This project is licensed under the GNU General Public License v3.0. See the LICENSE file for details.

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0 http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
<kcfgfile name=""/>
<group name="General">
<entry name="unslothUrl" type="String">
<default>http://localhost:8888</default>
</entry>
<entry name="apiToken" type="String">
<default></default>
</entry>
<entry name="systemPrompt" type="String">
<default></default>
</entry>
<entry name="defaultModel" type="String">
<default></default>
</entry>
<entry name="maxHistoryMessages" type="Int">
<default>50</default>
</entry>
</group>
</kcfg>
<configuration>
<page type="KirigamiPage" name="General">
<filename>configGeneral.qml</filename>
</page>
</configuration>

View File

@@ -1,166 +1,39 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.plasma.plasmoid
import org.kde.kirigami as Kirigami
Kirigami.ScrollablePage {
Kirigami.Page {
id: page
// 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_systemPrompt: systemPrompt.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 }
contentItem: ColumnLayout {
Kirigami.Heading {
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")
text: "System Prompt"
level: 2
Layout.fillWidth: true
}
QQC2.TextArea {
id: systemPromptField
placeholderText: i18n("Enter the system prompt here...")
id: systemPrompt
placeholderText: "Enter the system prompt here..."
Layout.fillWidth: true
Layout.preferredHeight: 200
Layout.preferredHeight: 240
wrapMode: TextEdit.Wrap
clip: true
}
// --- Default Model section ---
Kirigami.Separator { Layout.fillWidth: true }
Kirigami.Heading {
text: i18n("Default Model")
level: 2
text: "Default Model"
level: 3
Layout.fillWidth: true
}
QQC2.TextField {
id: defaultModelField
placeholderText: i18n("Enter the default model name (e.g., unsloth/llama-3-8b)")
placeholderText: "Enter the default model name (e.g., llama3)"
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;
}
}
}
}
}

View File

@@ -2,281 +2,20 @@ import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Window
import org.kde.plasma.core 2.0 as PlasmaCore
import org.kde.plasma.plasmoid 2.0
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
// ── Pending image info for encoding pipeline ────────────────────────
property string _pendingImagePath: ""
property string _pendingImageMime: ""
property string system_prompt: plasmoid.configuration.systemPrompt
// ── 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("<font color=\"red\">Could not load image file.</font>")
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 += "<br>";
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ── 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 += "<br>";
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 += '<i style="color:' + col + '">' + bold + ': ' + escapeHtml(m.content) + '</i>';
} else {
var msgHtml = '<b style="color:' + col + '">' + bold + ':</b> ' + escapeHtml(m.content);
// Display attached image thumbnail for user messages
if (m.role === "user" && m.imageSource) {
msgHtml += '<br><img src="' + m.imageSource + '" style="max-width:200px;max-height:150px;border-radius:4px;" />';
}
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 <br>
// because message content may contain literal <br> 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 <path>" 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.systemPrompt = plasmoid.configuration.systemPrompt;
root.system_prompt = plasmoid.configuration.systemPrompt;
root.clearChat();
}
}
@@ -284,204 +23,160 @@ PlasmoidItem {
Connections {
target: plasmoid.configuration
function onDefaultModelChanged() {
if (root.models.indexOf(plasmoid.configuration.defaultModel) >= 0) {
if (models.includes(plasmoid.configuration.defaultModel)) {
currentModel = plasmoid.configuration.defaultModel;
}
}
}
Connections {
target: plasmoid.configuration
function onUnslothUrlChanged() {
root.unslothUrl = plasmoid.configuration.unslothUrl;
}
}
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 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;
if (systemPrompt !== "") {
appendMessage("system", systemPrompt);
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 += "<br>";
}
if (role === "user") {
chatText += `<b style="color:${userColor}">You:</b> ${content}`;
if (imageSource) {
chatText += `<br><img src="${imageSource}" width="150" style="border-radius:4px;">`;
}
} else if (role === "assistant") {
chatText += `<b style="color:${assistantColor}">Ollama:</b> ${content}`;
} else if (role === "system") {
chatText += `<i style="color:${systemColor}">System: ${content}</i>`;
}
}
// ── Fetch models ────────────────────────────────────────────────────
function fetchModels() {
var xhr = new XMLHttpRequest();
xhr.timeout = requestTimeout;
modelsFetched = false;
xhr.ontimeout = function () {
root._appendChat("<font color=\"red\">Connection timed out fetching models.</font>");
};
xhr.onerror = function () {
root._appendChat("<font color=\"red\">Connection failed fetching models.</font>");
};
xhr.open("GET", unslothUrl + "/v1/models");
if (apiToken) {
xhr.setRequestHeader("Authorization", "Bearer " + apiToken);
}
let xhr = new XMLHttpRequest();
xhr.open("GET", ollamaUrl + "/api/tags");
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
var res;
try {
res = JSON.parse(xhr.responseText);
} catch (e) {
root._appendChat("<font color=\"red\">Invalid response from server.</font>");
return;
}
var list = [];
if (res.data) {
for (var i = 0; i < res.data.length; i++) {
list.push(res.data[i].id);
}
let res = JSON.parse(xhr.responseText);
let list = [];
if (res.models) {
res.models.forEach(m => list.push(m.name));
models = list;
modelsFetched = true;
if (list.length === 0) {
root._appendChat("<font color=\"orange\">No models are available.</font>");
} else {
if (list.indexOf(plasmoid.configuration.defaultModel) >= 0) {
currentModel = plasmoid.configuration.defaultModel;
} else if (currentModel === "") {
currentModel = list[0];
}
// 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];
}
}
} else if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 401) {
root._appendChat("<font color=\"red\">Authorization failed. Check your API token.</font>");
} else if (xhr.readyState === XMLHttpRequest.DONE) {
root._appendChat("<font color=\"orange\">Could not load models (status " + xhr.status + ").</font>");
}
};
xhr.send();
}
// ── Send message ────────────────────────────────────────────────────
function processAndSendMessage(msg) {
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;
var mimePrefix = root.base64MimePrefix(selectedImagePath);
root.readBase64(selectedImagePath, mimePrefix);
} else {
sendMessage(msg, null, "");
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 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.
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);
}
}
if (chatHistory.length === 0 && systemPrompt !== "") {
appendMessage("system", systemPrompt);
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 = "";
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
});
// 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 xhr = new XMLHttpRequest();
xhr.timeout = requestTimeout;
xhr.ontimeout = function () {
root._appendChat("<font color=\"orange\">Request timed out. The model may be loading.</font>");
isWaiting = false;
if (root.inputField) root.inputField.text = root.lastSentMessage;
};
xhr.onerror = function () {
root._appendChat("<font color=\"orange\">Connection error. Check if Unsloth Studio is running.</font>");
isWaiting = false;
if (root.inputField) root.inputField.text = root.lastSentMessage;
};
xhr.open("POST", unslothUrl + "/v1/chat/completions");
let xhr = new XMLHttpRequest();
xhr.open("POST", ollamaUrl + "/api/chat");
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) {
var res;
try {
res = JSON.parse(xhr.responseText);
} catch (e) {
root._appendChat("<font color=\"red\">Invalid response from server.</font>");
return;
}
if (res.choices && res.choices.length > 0) {
appendMessage("assistant", res.choices[0].message.content);
} else {
root._appendChat("<font color=\"orange\">Error: No response from model.</font>");
}
} else if (xhr.status === 401) {
root._appendChat("<font color=\"red\">Authorization failed. Check your API token.</font>");
if (root.inputField) root.inputField.text = root.lastSentMessage;
let res = JSON.parse(xhr.responseText);
appendMessage("assistant", res.message.content);
} else {
root._appendChat("<font color=\"orange\">Error " + xhr.status + ": Check if Unsloth Studio is running.</font>");
chatText += `<br><font color="orange">Error: Check if Ollama is running.</font>`;
}
}
};
// 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
}));
}
// ── Init timer ──────────────────────────────────────────────────────
// Hacky workaround the Kirigami theme loading bug...
Timer {
id: initTimer
interval: 50
running: false
repeat: false
onTriggered: {
if (root.chatHistory.length === 0 && root.systemPrompt !== "") {
root.appendMessage("system", root.systemPrompt);
if (root.chatHistory.length === 0) {
root.appendMessage("system", root.system_prompt);
}
}
}
@@ -489,63 +184,35 @@ PlasmoidItem {
Component.onCompleted: {
fetchModels();
initTimer.start();
plasmoid.configurationRequired = true;
}
// ── 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()
}
FileDialog {
id: fileDialog
title: "Select an Image"
nameFilters: ["Images (*.png *.jpg *.jpeg *.webp)"]
onAccepted: root.selectedImagePath = selectedFile
}
// ── UI ──────────────────────────────────────────────────────────────
fullRepresentation: ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
// 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
Layout.minimumWidth: Math.max(Screen.width * .20, 400)
Layout.minimumHeight: Math.max(Screen.height * .20, 200)
// New Title Bar
RowLayout {
Layout.fillWidth: true
Kirigami.Heading {
text: i18n("AI Chat")
text: "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
@@ -554,21 +221,11 @@ 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 {
@@ -576,23 +233,23 @@ PlasmoidItem {
icon.name: root.hideOnWindowDeactivate ? "window-pin" : "window-unpin"
checkable: true
checked: !root.hideOnWindowDeactivate
onClicked: {
root.hideOnWindowDeactivate = !checked;
}
onClicked: root.hideOnWindowDeactivate = !checked
// Tooltip to explain the button
PlasmaComponents.ToolTip {
visible: pinButton.hovered
text: root.hideOnWindowDeactivate ? i18n("Keep widget open (pinned)") : i18n("Close widget when unfocused")
text: "Keep Open"
}
}
}
}
// Horizontal Line separator
Kirigami.Separator {
Layout.fillWidth: true
}
// Chat View
PlasmaComponents.ScrollView {
id: chatScrollView
Layout.fillWidth: true
Layout.fillHeight: true
@@ -602,6 +259,10 @@ 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
@@ -610,34 +271,14 @@ PlasmoidItem {
}
}
// 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()
}
// Loading Indicator
PlasmaComponents.ProgressBar {
Layout.fillWidth: true
indeterminate: true
visible: root.isWaiting
}
// Image Preview
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 80
@@ -655,55 +296,39 @@ PlasmoidItem {
fillMode: Image.PreserveAspectFit
}
PlasmaComponents.Label {
text: i18n("Image attached")
text: "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: {
root.hideOnWindowDeactivate = false;
fileDialogLoader.active = true;
}
hoverEnabled: true
onClicked: fileDialog.open()
enabled: !root.isWaiting
PlasmaComponents.ToolTip {
visible: parent.hovered
text: i18n("Attach image")
}
}
PlasmaComponents.TextField {
id: inputField
Layout.fillWidth: true
placeholderText: i18n("Type message...")
placeholderText: "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);
}
PlasmaComponents.ToolTip {
visible: parent.hovered
text: i18n("Send message")
inputField.text = "";
}
}
}

View File

@@ -1,13 +1,10 @@
{
"KPlugin": {
"Authors": [
{ "Name": "AI Assistants (Gemini 3.0 Pro, Qwen3.6-35B)" },
{ "Name": "NikkeDoy" }
],
"Description": "Chat with local models using Unsloth Studio API.",
"Authors": [ { "Name": "AI Assistants (Gemini 3.0 Pro)" }, { "Name": "NikkeDoy" } ],
"Description": "Chat with local Ollama models",
"Icon": "dialog-messages",
"Id": "fi.huitsinnevada.unsloth_chat",
"Name": "Unsloth",
"Id": "fi.huitsinnevada.ai_chat",
"Name": "AI Chat",
"Version": "1.0",
"Website": "https://huitsinnevada.fi/projects"
},