✨ | Unsloth Studio (OpenAI API) implementation
This commit is contained in:
@@ -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.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "&")
|
||||
.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 {
|
||||
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)) {
|
||||
currentModel = plasmoid.configuration.defaultModel;
|
||||
} else if (list.length > 0 && currentModel === "") {
|
||||
currentModel = list[0];
|
||||
modelsFetched = true;
|
||||
if (list.length === 0) {
|
||||
root._appendChat("<font color=\"orange\">No models are available.</font>");
|
||||
} else {
|
||||
if (list.indexOf(plasmoid.configuration.defaultModel) >= 0) {
|
||||
currentModel = plasmoid.configuration.defaultModel;
|
||||
} else if (currentModel === "") {
|
||||
currentModel = list[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 {
|
||||
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 {
|
||||
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({
|
||||
model: currentModel,
|
||||
messages: requestMessages,
|
||||
stream: false
|
||||
"model": currentModel,
|
||||
"messages": requestMessages,
|
||||
"stream": false
|
||||
}));
|
||||
}
|
||||
|
||||
// Hacky workaround the Kirigami theme loading bug...
|
||||
// ── Init timer ──────────────────────────────────────────────────────
|
||||
Timer {
|
||||
id: initTimer
|
||||
interval: 50
|
||||
running: false
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (root.chatHistory.length === 0) {
|
||||
root.appendMessage("system", root.system_prompt);
|
||||
if (root.chatHistory.length === 0 && root.systemPrompt !== "") {
|
||||
root.appendMessage("system", root.systemPrompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,35 +489,63 @@ PlasmoidItem {
|
||||
Component.onCompleted: {
|
||||
fetchModels();
|
||||
initTimer.start();
|
||||
plasmoid.configurationRequired = true;
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: fileDialog
|
||||
title: "Select an Image"
|
||||
nameFilters: ["Images (*.png *.jpg *.jpeg *.webp)"]
|
||||
onAccepted: root.selectedImagePath = selectedFile
|
||||
// ── File dialog ─────────────────────────────────────────────────────
|
||||
Loader {
|
||||
id: fileDialogLoader
|
||||
active: false
|
||||
sourceComponent: FileDialog {
|
||||
id: fileDialog
|
||||
title: "Select an Image"
|
||||
nameFilters: [
|
||||
"Images (*.png *.jpg *.jpeg *.webp *.bmp *.tiff *.tif *.svg *.ico *.avif)"
|
||||
]
|
||||
onAccepted: {
|
||||
var path = selectedFile.toString();
|
||||
if (path.indexOf("file://") === 0) {
|
||||
path = path.substring(7);
|
||||
}
|
||||
root.selectedImagePath = path;
|
||||
fileDialogLoader.active = false
|
||||
}
|
||||
onRejected: {
|
||||
fileDialogLoader.active = false
|
||||
}
|
||||
Component.onCompleted: open()
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI ──────────────────────────────────────────────────────────────
|
||||
fullRepresentation: ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
|
||||
Layout.minimumWidth: Math.max(Screen.width * .20, 400)
|
||||
Layout.minimumHeight: Math.max(Screen.height * .20, 200)
|
||||
// Use numeric fallback for gridUnit since PlasmaCore.Units may not resolve
|
||||
// in nested component context. Default gridUnit is typically 13.
|
||||
Layout.minimumWidth: 13 * 15
|
||||
Layout.minimumHeight: 13 * 10
|
||||
Layout.preferredWidth: 13 * 20
|
||||
Layout.preferredHeight: 13 * 18
|
||||
|
||||
// New Title Bar
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Kirigami.Heading {
|
||||
text: "AI Chat"
|
||||
text: i18n("AI Chat")
|
||||
level: 2
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
PlasmaComponents.Label {
|
||||
visible: root.modelsFetched
|
||||
text: "✓"
|
||||
color: "green"
|
||||
font.bold: true
|
||||
font.pixelSize: 20
|
||||
}
|
||||
PlasmaComponents.ComboBox {
|
||||
id: modelCombo
|
||||
Layout.fillWidth: true
|
||||
@@ -221,11 +554,21 @@ PlasmoidItem {
|
||||
}
|
||||
PlasmaComponents.Button {
|
||||
icon.name: "view-refresh"
|
||||
hoverEnabled: true
|
||||
onClicked: root.fetchModels()
|
||||
PlasmaComponents.ToolTip {
|
||||
visible: parent.hovered
|
||||
text: i18n("Refresh models")
|
||||
}
|
||||
}
|
||||
PlasmaComponents.Button {
|
||||
icon.name: "edit-clear-all"
|
||||
hoverEnabled: true
|
||||
onClicked: root.clearChat()
|
||||
PlasmaComponents.ToolTip {
|
||||
visible: parent.hovered
|
||||
text: i18n("Clear chat")
|
||||
}
|
||||
}
|
||||
|
||||
PlasmaComponents.Button {
|
||||
@@ -233,23 +576,23 @@ PlasmoidItem {
|
||||
icon.name: root.hideOnWindowDeactivate ? "window-pin" : "window-unpin"
|
||||
checkable: true
|
||||
checked: !root.hideOnWindowDeactivate
|
||||
onClicked: root.hideOnWindowDeactivate = !checked
|
||||
|
||||
// Tooltip to explain the button
|
||||
onClicked: {
|
||||
root.hideOnWindowDeactivate = !checked;
|
||||
}
|
||||
PlasmaComponents.ToolTip {
|
||||
text: "Keep Open"
|
||||
visible: pinButton.hovered
|
||||
text: root.hideOnWindowDeactivate ? i18n("Keep widget open (pinned)") : i18n("Close widget when unfocused")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal Line separator
|
||||
Kirigami.Separator {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// Chat View
|
||||
PlasmaComponents.ScrollView {
|
||||
id: chatScrollView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
@@ -259,10 +602,6 @@ PlasmoidItem {
|
||||
readOnly: true
|
||||
textFormat: TextEdit.RichText
|
||||
wrapMode: TextEdit.WordWrap
|
||||
onTextChanged: {
|
||||
// Force scroll to bottom
|
||||
cursorPosition = length;
|
||||
}
|
||||
background: Rectangle {
|
||||
color: Kirigami.Theme.backgroundColor
|
||||
opacity: 0.3
|
||||
@@ -271,14 +610,34 @@ PlasmoidItem {
|
||||
}
|
||||
}
|
||||
|
||||
// Loading Indicator
|
||||
// Scroll to the bottom of the content area.
|
||||
// Guard against lazy-loaded popup: contentItem or viewportHeight may be undefined.
|
||||
function scrollToBottom() {
|
||||
if (chatScrollView.contentItem && chatScrollView.viewportHeight !== undefined) {
|
||||
var contentHeight = chatScrollView.contentItem.contentHeight;
|
||||
if (contentHeight > chatScrollView.viewportHeight) {
|
||||
chatScrollView.contentItem.contentY = contentHeight - chatScrollView.viewportHeight;
|
||||
} else {
|
||||
chatScrollView.contentItem.contentY = contentHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll timer — defined here inside fullRepresentation
|
||||
Timer {
|
||||
id: autoScrollTimer
|
||||
interval: 50
|
||||
running: false
|
||||
repeat: false
|
||||
onTriggered: scrollToBottom()
|
||||
}
|
||||
|
||||
PlasmaComponents.ProgressBar {
|
||||
Layout.fillWidth: true
|
||||
indeterminate: true
|
||||
visible: root.isWaiting
|
||||
}
|
||||
|
||||
// Image Preview
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 80
|
||||
@@ -296,39 +655,55 @@ PlasmoidItem {
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
PlasmaComponents.Label {
|
||||
text: "Image attached"
|
||||
text: i18n("Image attached")
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
PlasmaComponents.Button {
|
||||
icon.name: "edit-delete"
|
||||
hoverEnabled: true
|
||||
onClicked: root.selectedImagePath = ""
|
||||
PlasmaComponents.ToolTip {
|
||||
visible: parent.hovered
|
||||
text: "Remove image"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input Area
|
||||
RowLayout {
|
||||
PlasmaComponents.Button {
|
||||
icon.name: "mail-attachment"
|
||||
onClicked: fileDialog.open()
|
||||
onClicked: {
|
||||
root.hideOnWindowDeactivate = false;
|
||||
fileDialogLoader.active = true;
|
||||
}
|
||||
hoverEnabled: true
|
||||
enabled: !root.isWaiting
|
||||
PlasmaComponents.ToolTip {
|
||||
visible: parent.hovered
|
||||
text: i18n("Attach image")
|
||||
}
|
||||
}
|
||||
PlasmaComponents.TextField {
|
||||
id: inputField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Type message..."
|
||||
placeholderText: i18n("Type message...")
|
||||
enabled: !root.isWaiting
|
||||
onAccepted: {
|
||||
root.processAndSendMessage(text);
|
||||
text = "";
|
||||
}
|
||||
}
|
||||
Component.onCompleted: root.inputField = inputField
|
||||
PlasmaComponents.Button {
|
||||
icon.name: "mail-send"
|
||||
hoverEnabled: true
|
||||
enabled: !root.isWaiting && inputField.text !== ""
|
||||
onClicked: {
|
||||
root.processAndSendMessage(inputField.text);
|
||||
inputField.text = "";
|
||||
}
|
||||
PlasmaComponents.ToolTip {
|
||||
visible: parent.hovered
|
||||
text: i18n("Send message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user