✨ | Unsloth Studio (OpenAI API) implementation
This commit is contained in:
63
README.md
63
README.md
@@ -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. Right‑click the panel → **Add Widgets…**.
|
1. Right‑click the panel → **Add Widgets…**.
|
||||||
2. Search for **AI Chat** and add it.
|
2. Search for **Unsloth** and add it.
|
||||||
|
3. Open the widget settings (right‑click → Configure) and enter your Unsloth Studio URL and API token.
|
||||||
|
|
||||||
The widget will launch and allow you to chat with your local Ollama models.
|
The widget will connect to your Unsloth Studio instance, list available models, and let you start chatting.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Right‑click the widget → **Configure** to set:
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Unsloth Studio URL** | API endpoint (e.g., `http://localhost:8888`) |
|
||||||
|
| **API Token** | Authentication token for the API |
|
||||||
|
| **System Prompt** | Custom system prompt for every conversation |
|
||||||
|
| **Default Model** | Model to select on startup |
|
||||||
|
|
||||||
## License
|
## 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
function _trimHistory() {
|
||||||
|
// Always keep the system message at index 0
|
||||||
|
if (chatHistory.length > maxHistoryMessages + 1) {
|
||||||
|
// Remove oldest non-system messages
|
||||||
|
chatHistory = [chatHistory[0]].concat(
|
||||||
|
chatHistory.slice(chatHistory.length - maxHistoryMessages)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildChatText() {
|
||||||
|
chatText = "";
|
||||||
|
for (var i = 0; i < chatHistory.length; i++) {
|
||||||
|
var m = chatHistory[i];
|
||||||
|
if (i > 0) chatText += "<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) {
|
||||||
|
root._appendChat("<font color=\"orange\">No models are available.</font>");
|
||||||
|
} else {
|
||||||
|
if (list.indexOf(plasmoid.configuration.defaultModel) >= 0) {
|
||||||
currentModel = plasmoid.configuration.defaultModel;
|
currentModel = plasmoid.configuration.defaultModel;
|
||||||
} else if (list.length > 0 && currentModel === "") {
|
} else if (currentModel === "") {
|
||||||
currentModel = list[0];
|
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 {
|
} 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({
|
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 ─────────────────────────────────────────────────────
|
||||||
|
Loader {
|
||||||
|
id: fileDialogLoader
|
||||||
|
active: false
|
||||||
|
sourceComponent: FileDialog {
|
||||||
id: fileDialog
|
id: fileDialog
|
||||||
title: "Select an Image"
|
title: "Select an Image"
|
||||||
nameFilters: ["Images (*.png *.jpg *.jpeg *.webp)"]
|
nameFilters: [
|
||||||
onAccepted: root.selectedImagePath = selectedFile
|
"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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user