Compare commits

1 Commits

Author SHA1 Message Date
2e68e83be5 | Unsloth Studio (OpenAI API) implementation 2026-05-05 12:59:06 +03:00
5 changed files with 722 additions and 158 deletions

View File

@@ -1,27 +1,42 @@
# AI Chat for KDE Plasma # Unsloth AI Chat Widget for KDE Plasma
## IMPORTANT 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.
This is just a vibe-coded hobby project. If you wish to use it, then go for it!
## Intro <p align="center"><img src="assets/screenshots/image-1.webp" alt="Unsloth AI Chat Widget screenshot" /></p>
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 ## Features
- Chat with local Ollama models - Chat with local Unsloth Studio models
- Works inside Plasma Desktop - Multi-turn conversations with full chat history
- Easy installation via `kpackagetool6` - 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 ## Installation
1. **Install the widget**: 1. **Install the widget**:
```bash ```bash
git clone https://git.huitsinnevada.fi/NikkeDoy/Plasma-AI-Chat git clone https://git.huitsinnevada.fi/NikkeDoy/Plasma-unsloth-Chat
cd Plasma-AI-Chat cd Plasma-unsloth-Chat
kpackagetool6 -t Plasma/Applet -i . kpackagetool6 -t Plasma/Applet -i .
``` ```
@@ -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: After installation, add the widget to your panel:
1. Rightclick the panel → **Add Widgets…**. 1. Rightclick the panel → **Add Widgets…**.
2. Search for **AI Chat** and add it. 2. Search for **Unsloth** and add it.
3. Open the widget settings (rightclick → 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
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 |
## License ## 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
<https://huitsinnevada.fi/projects>

View File

@@ -0,0 +1,28 @@
<?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,39 +1,166 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls as QQC2 import QtQuick.Controls as QQC2
import org.kde.plasma.plasmoid
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
Kirigami.Page { Kirigami.ScrollablePage {
id: page 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 { 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 level: 2
Layout.fillWidth: true Layout.fillWidth: true
} }
QQC2.TextArea { QQC2.TextArea {
id: systemPrompt id: systemPromptField
placeholderText: "Enter the system prompt here..." placeholderText: i18n("Enter the system prompt here...")
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 240 Layout.preferredHeight: 200
wrapMode: TextEdit.Wrap wrapMode: TextEdit.Wrap
clip: true clip: true
} }
// --- Default Model section ---
Kirigami.Separator { Layout.fillWidth: true }
Kirigami.Heading { Kirigami.Heading {
text: "Default Model" text: i18n("Default Model")
level: 3 level: 2
Layout.fillWidth: true Layout.fillWidth: true
} }
QQC2.TextField { QQC2.TextField {
id: defaultModelField 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 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,20 +2,281 @@ import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Dialogs 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.plasma.components 3.0 as PlasmaComponents
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import org.kde.plasma.plasma5support as Plasma5Support
PlasmoidItem { PlasmoidItem {
id: root 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("<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 { Connections {
target: plasmoid.configuration target: plasmoid.configuration
function onSystemPromptChanged() { function onSystemPromptChanged() {
root.system_prompt = plasmoid.configuration.systemPrompt; root.systemPrompt = plasmoid.configuration.systemPrompt;
root.clearChat(); root.clearChat();
} }
} }
@@ -23,160 +284,204 @@ PlasmoidItem {
Connections { Connections {
target: plasmoid.configuration target: plasmoid.configuration
function onDefaultModelChanged() { function onDefaultModelChanged() {
if (models.includes(plasmoid.configuration.defaultModel)) { if (root.models.indexOf(plasmoid.configuration.defaultModel) >= 0) {
currentModel = plasmoid.configuration.defaultModel; currentModel = plasmoid.configuration.defaultModel;
} }
} }
} }
property string ollamaUrl: "http://localhost:11434" Connections {
property var models: [] target: plasmoid.configuration
property string currentModel: "" function onUnslothUrlChanged() {
property string selectedImagePath: "" root.unslothUrl = plasmoid.configuration.unslothUrl;
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() { function clearChat() {
chatHistory = []; chatHistory = [];
chatText = ""; chatText = "";
selectedImagePath = ""; selectedImagePath = "";
isWaiting = false; isWaiting = false;
appendMessage("system", system_prompt); if (systemPrompt !== "") {
} appendMessage("system", systemPrompt);
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() { function fetchModels() {
let xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open("GET", ollamaUrl + "/api/tags"); 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);
}
xhr.onreadystatechange = function () { xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
let res = JSON.parse(xhr.responseText); var res;
let list = []; try {
if (res.models) { res = JSON.parse(xhr.responseText);
res.models.forEach(m => list.push(m.name)); } 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);
}
models = list; models = list;
// Prefer user configured default model if present modelsFetched = true;
if (plasmoid.configuration.defaultModel && list.includes(plasmoid.configuration.defaultModel)) { if (list.length === 0) {
currentModel = plasmoid.configuration.defaultModel; root._appendChat("<font color=\"orange\">No models are available.</font>");
} else if (list.length > 0 && currentModel === "") { } else {
currentModel = list[0]; 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("<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(); xhr.send();
} }
Plasma5Support.DataSource { // ── Send message ────────────────────────────────────────────────────
id: executableSource
engine: "executable"
connectedSources: []
onNewData: (sourceName, data) => {
if (data["stdout"]) {
var base64Str = data["stdout"].toString().trim();
root.sendMessage(root.pendingMessageText, base64Str);
disconnectSource(sourceName);
}
}
}
function processAndSendMessage(msg) { function processAndSendMessage(msg) {
if (!currentModel || !msg || isWaiting) if (!currentModel || !msg.trim() || isWaiting)
return; return;
isWaiting = true; isWaiting = true;
root.lastSentMessage = msg; // preserve message text in case of failure
if (root.inputField) root.inputField.text = "";
if (selectedImagePath !== "") { if (selectedImagePath !== "") {
root.pendingMessageText = msg; root.pendingMessageText = msg;
// Robust path cleaning for Linux var mimePrefix = root.base64MimePrefix(selectedImagePath);
let cleanPath = decodeURIComponent(selectedImagePath.toString().replace("file://", "")); root.readBase64(selectedImagePath, mimePrefix);
executableSource.connectSource("base64 -w 0 \"" + cleanPath + "\"");
} else { } else {
sendMessage(msg, null); sendMessage(msg, null, "");
} }
} }
function sendMessage(msg, base64Image) { function sendMessage(msg, base64Image, mimePrefix) {
if (chatHistory.length === 0) { // isWaiting is managed by the caller (processAndSendMessage).
appendMessage("system", system_prompt); // 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); appendMessage("user", msg, base64Image ? selectedImagePath : null);
// Reset image selection immediately after UI update
selectedImagePath = ""; selectedImagePath = "";
// 2. Prepare the messages for the API call var requestMessages = [];
// We map the existing history first for (var i = 0; i < chatHistory.length; i++) {
let requestMessages = chatHistory.map(m => ({ var m = chatHistory[i];
role: m.role, var content = m.content;
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 = [
// 3. IMPORTANT: Add the image to the LAST message in the request list {
if (base64Image && requestMessages.length > 0) { "type": "text",
requestMessages[requestMessages.length - 1].images = [base64Image]; "text": msg
},
{
"type": "image_url",
"image_url": {
"url": mimePrefix + base64Image
}
}
];
}
requestMessages.push({
"role": m.role,
"content": content
});
} }
let xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open("POST", ollamaUrl + "/api/chat"); 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");
xhr.setRequestHeader("Content-Type", "application/json"); xhr.setRequestHeader("Content-Type", "application/json");
if (apiToken) {
xhr.setRequestHeader("Authorization", "Bearer " + apiToken);
}
xhr.onreadystatechange = function () { xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) { if (xhr.readyState === XMLHttpRequest.DONE) {
isWaiting = false; isWaiting = false;
if (xhr.status === 200) { if (xhr.status === 200) {
let res = JSON.parse(xhr.responseText); var res;
appendMessage("assistant", res.message.content); 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;
} else { } else {
chatText += `<br><font color="orange">Error: Check if Ollama is running.</font>`; root._appendChat("<font color=\"orange\">Error " + xhr.status + ": Check if Unsloth Studio is running.</font>");
} }
} }
}; };
// Now requestMessages contains the image data in the last 'user' object
xhr.send(JSON.stringify({ xhr.send(JSON.stringify({
model: currentModel, "model": currentModel,
messages: requestMessages, "messages": requestMessages,
stream: false "stream": false
})); }));
} }
// Hacky workaround the Kirigami theme loading bug... // ── Init timer ──────────────────────────────────────────────────────
Timer { Timer {
id: initTimer id: initTimer
interval: 50 interval: 50
running: false running: false
repeat: false repeat: false
onTriggered: { onTriggered: {
if (root.chatHistory.length === 0) { if (root.chatHistory.length === 0 && root.systemPrompt !== "") {
root.appendMessage("system", root.system_prompt); root.appendMessage("system", root.systemPrompt);
} }
} }
} }
@@ -184,35 +489,63 @@ PlasmoidItem {
Component.onCompleted: { Component.onCompleted: {
fetchModels(); fetchModels();
initTimer.start(); initTimer.start();
plasmoid.configurationRequired = true;
} }
FileDialog { // ── File dialog ─────────────────────────────────────────────────────
id: fileDialog Loader {
title: "Select an Image" id: fileDialogLoader
nameFilters: ["Images (*.png *.jpg *.jpeg *.webp)"] active: false
onAccepted: root.selectedImagePath = selectedFile 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 { fullRepresentation: ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 8 anchors.margins: 8
spacing: 8 spacing: 8
Layout.minimumWidth: Math.max(Screen.width * .20, 400) // Use numeric fallback for gridUnit since PlasmaCore.Units may not resolve
Layout.minimumHeight: Math.max(Screen.height * .20, 200) // 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 { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Kirigami.Heading { Kirigami.Heading {
text: "AI Chat" text: i18n("AI Chat")
level: 2 level: 2
Layout.fillWidth: true Layout.fillWidth: true
} }
RowLayout { RowLayout {
PlasmaComponents.Label {
visible: root.modelsFetched
text: "✓"
color: "green"
font.bold: true
font.pixelSize: 20
}
PlasmaComponents.ComboBox { PlasmaComponents.ComboBox {
id: modelCombo id: modelCombo
Layout.fillWidth: true Layout.fillWidth: true
@@ -221,11 +554,21 @@ PlasmoidItem {
} }
PlasmaComponents.Button { PlasmaComponents.Button {
icon.name: "view-refresh" icon.name: "view-refresh"
hoverEnabled: true
onClicked: root.fetchModels() onClicked: root.fetchModels()
PlasmaComponents.ToolTip {
visible: parent.hovered
text: i18n("Refresh models")
}
} }
PlasmaComponents.Button { PlasmaComponents.Button {
icon.name: "edit-clear-all" icon.name: "edit-clear-all"
hoverEnabled: true
onClicked: root.clearChat() onClicked: root.clearChat()
PlasmaComponents.ToolTip {
visible: parent.hovered
text: i18n("Clear chat")
}
} }
PlasmaComponents.Button { PlasmaComponents.Button {
@@ -233,23 +576,23 @@ PlasmoidItem {
icon.name: root.hideOnWindowDeactivate ? "window-pin" : "window-unpin" icon.name: root.hideOnWindowDeactivate ? "window-pin" : "window-unpin"
checkable: true checkable: true
checked: !root.hideOnWindowDeactivate checked: !root.hideOnWindowDeactivate
onClicked: root.hideOnWindowDeactivate = !checked onClicked: {
root.hideOnWindowDeactivate = !checked;
// Tooltip to explain the button }
PlasmaComponents.ToolTip { 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 { Kirigami.Separator {
Layout.fillWidth: true Layout.fillWidth: true
} }
// Chat View
PlasmaComponents.ScrollView { PlasmaComponents.ScrollView {
id: chatScrollView
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
@@ -259,10 +602,6 @@ PlasmoidItem {
readOnly: true readOnly: true
textFormat: TextEdit.RichText textFormat: TextEdit.RichText
wrapMode: TextEdit.WordWrap wrapMode: TextEdit.WordWrap
onTextChanged: {
// Force scroll to bottom
cursorPosition = length;
}
background: Rectangle { background: Rectangle {
color: Kirigami.Theme.backgroundColor color: Kirigami.Theme.backgroundColor
opacity: 0.3 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 { PlasmaComponents.ProgressBar {
Layout.fillWidth: true Layout.fillWidth: true
indeterminate: true indeterminate: true
visible: root.isWaiting visible: root.isWaiting
} }
// Image Preview
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 80 Layout.preferredHeight: 80
@@ -296,39 +655,55 @@ PlasmoidItem {
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
} }
PlasmaComponents.Label { PlasmaComponents.Label {
text: "Image attached" text: i18n("Image attached")
Layout.fillWidth: true Layout.fillWidth: true
} }
PlasmaComponents.Button { PlasmaComponents.Button {
icon.name: "edit-delete" icon.name: "edit-delete"
hoverEnabled: true
onClicked: root.selectedImagePath = "" onClicked: root.selectedImagePath = ""
PlasmaComponents.ToolTip {
visible: parent.hovered
text: "Remove image"
}
} }
} }
} }
// Input Area
RowLayout { RowLayout {
PlasmaComponents.Button { PlasmaComponents.Button {
icon.name: "mail-attachment" icon.name: "mail-attachment"
onClicked: fileDialog.open() onClicked: {
root.hideOnWindowDeactivate = false;
fileDialogLoader.active = true;
}
hoverEnabled: true
enabled: !root.isWaiting enabled: !root.isWaiting
PlasmaComponents.ToolTip {
visible: parent.hovered
text: i18n("Attach image")
}
} }
PlasmaComponents.TextField { PlasmaComponents.TextField {
id: inputField id: inputField
Layout.fillWidth: true Layout.fillWidth: true
placeholderText: "Type message..." placeholderText: i18n("Type message...")
enabled: !root.isWaiting enabled: !root.isWaiting
onAccepted: { onAccepted: {
root.processAndSendMessage(text); root.processAndSendMessage(text);
text = "";
} }
} }
Component.onCompleted: root.inputField = inputField
PlasmaComponents.Button { PlasmaComponents.Button {
icon.name: "mail-send" icon.name: "mail-send"
hoverEnabled: true
enabled: !root.isWaiting && inputField.text !== "" enabled: !root.isWaiting && inputField.text !== ""
onClicked: { onClicked: {
root.processAndSendMessage(inputField.text); root.processAndSendMessage(inputField.text);
inputField.text = ""; }
PlasmaComponents.ToolTip {
visible: parent.hovered
text: i18n("Send message")
} }
} }
} }

View File

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