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
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.
<p align="center"><img src="assets/screenshots/image-1.webp" alt="AI Chat Widget screenshot" /></p>
<p align="center"><img src="assets/screenshots/image-1.webp" alt="Unsloth AI Chat Widget screenshot" /></p>
## 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 .
```
@@ -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. 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
This project is licensed under the GNU General Public License v3.0. 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.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;
}
}
}
}
}

View File

@@ -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("<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.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 += "<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>`;
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("<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 () {
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("<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;
// Prefer user configured default model if present
if (plasmoid.configuration.defaultModel && list.includes(plasmoid.configuration.defaultModel)) {
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 (list.length > 0 && currentModel === "") {
} 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();
}
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("<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");
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("<font color=\"red\">Invalid response from server.</font>");
return;
}
if (res.choices && res.choices.length > 0) {
appendMessage("assistant", res.choices[0].message.content);
} else {
chatText += `<br><font color="orange">Error: Check if Ollama is running.</font>`;
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 {
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({
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 {
// ── File dialog ─────────────────────────────────────────────────────
Loader {
id: fileDialogLoader
active: false
sourceComponent: FileDialog {
id: fileDialog
title: "Select an Image"
nameFilters: ["Images (*.png *.jpg *.jpeg *.webp)"]
onAccepted: root.selectedImagePath = selectedFile
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")
}
}
}

View File

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