712 lines
28 KiB
QML
712 lines
28 KiB
QML
import QtQuick
|
|
import QtQuick.Layouts
|
|
import QtQuick.Controls
|
|
import QtQuick.Dialogs
|
|
import QtQuick.Window
|
|
import org.kde.plasma.core 2.0 as PlasmaCore
|
|
import org.kde.plasma.plasmoid 2.0
|
|
import org.kde.plasma.components 3.0 as PlasmaComponents
|
|
import org.kde.kirigami as Kirigami
|
|
|
|
PlasmoidItem {
|
|
id: root
|
|
|
|
// ── 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.systemPrompt = plasmoid.configuration.systemPrompt;
|
|
root.clearChat();
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: plasmoid.configuration
|
|
function onDefaultModelChanged() {
|
|
if (root.models.indexOf(plasmoid.configuration.defaultModel) >= 0) {
|
|
currentModel = plasmoid.configuration.defaultModel;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
if (systemPrompt !== "") {
|
|
appendMessage("system", systemPrompt);
|
|
}
|
|
}
|
|
|
|
// ── Fetch models ────────────────────────────────────────────────────
|
|
function fetchModels() {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.timeout = requestTimeout;
|
|
modelsFetched = false;
|
|
xhr.ontimeout = function () {
|
|
root._appendChat("<font color=\"red\">Connection timed out fetching models.</font>");
|
|
};
|
|
xhr.onerror = function () {
|
|
root._appendChat("<font color=\"red\">Connection failed fetching models.</font>");
|
|
};
|
|
xhr.open("GET", unslothUrl + "/v1/models");
|
|
if (apiToken) {
|
|
xhr.setRequestHeader("Authorization", "Bearer " + apiToken);
|
|
}
|
|
xhr.onreadystatechange = function () {
|
|
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
|
|
var res;
|
|
try {
|
|
res = JSON.parse(xhr.responseText);
|
|
} catch (e) {
|
|
root._appendChat("<font color=\"red\">Invalid response from server.</font>");
|
|
return;
|
|
}
|
|
var list = [];
|
|
if (res.data) {
|
|
for (var i = 0; i < res.data.length; i++) {
|
|
list.push(res.data[i].id);
|
|
}
|
|
models = list;
|
|
modelsFetched = true;
|
|
if (list.length === 0) {
|
|
root._appendChat("<font color=\"orange\">No models are available.</font>");
|
|
} else {
|
|
if (list.indexOf(plasmoid.configuration.defaultModel) >= 0) {
|
|
currentModel = plasmoid.configuration.defaultModel;
|
|
} else if (currentModel === "") {
|
|
currentModel = list[0];
|
|
}
|
|
}
|
|
}
|
|
} else if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 401) {
|
|
root._appendChat("<font color=\"red\">Authorization failed. Check your API token.</font>");
|
|
} else if (xhr.readyState === XMLHttpRequest.DONE) {
|
|
root._appendChat("<font color=\"orange\">Could not load models (status " + xhr.status + ").</font>");
|
|
}
|
|
};
|
|
xhr.send();
|
|
}
|
|
|
|
// ── Send message ────────────────────────────────────────────────────
|
|
function processAndSendMessage(msg) {
|
|
if (!currentModel || !msg.trim() || isWaiting)
|
|
return;
|
|
isWaiting = true;
|
|
root.lastSentMessage = msg; // preserve message text in case of failure
|
|
if (root.inputField) root.inputField.text = "";
|
|
if (selectedImagePath !== "") {
|
|
root.pendingMessageText = msg;
|
|
var mimePrefix = root.base64MimePrefix(selectedImagePath);
|
|
root.readBase64(selectedImagePath, mimePrefix);
|
|
} else {
|
|
sendMessage(msg, null, "");
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
appendMessage("user", msg, base64Image ? selectedImagePath : null);
|
|
selectedImagePath = "";
|
|
|
|
var requestMessages = [];
|
|
for (var i = 0; i < chatHistory.length; i++) {
|
|
var m = chatHistory[i];
|
|
var content = m.content;
|
|
// If this is a user message with an attached image, send multimodal
|
|
if (m.role === "user" && m.imageSource && base64Image && i === chatHistory.length - 1) {
|
|
content = [
|
|
{
|
|
"type": "text",
|
|
"text": msg
|
|
},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": mimePrefix + base64Image
|
|
}
|
|
}
|
|
];
|
|
}
|
|
requestMessages.push({
|
|
"role": m.role,
|
|
"content": content
|
|
});
|
|
}
|
|
|
|
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) {
|
|
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 {
|
|
root._appendChat("<font color=\"orange\">Error " + xhr.status + ": Check if Unsloth Studio is running.</font>");
|
|
}
|
|
}
|
|
};
|
|
xhr.send(JSON.stringify({
|
|
"model": currentModel,
|
|
"messages": requestMessages,
|
|
"stream": false
|
|
}));
|
|
}
|
|
|
|
// ── Init timer ──────────────────────────────────────────────────────
|
|
Timer {
|
|
id: initTimer
|
|
interval: 50
|
|
running: false
|
|
repeat: false
|
|
onTriggered: {
|
|
if (root.chatHistory.length === 0 && root.systemPrompt !== "") {
|
|
root.appendMessage("system", root.systemPrompt);
|
|
}
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
fetchModels();
|
|
initTimer.start();
|
|
}
|
|
|
|
// ── 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
|
|
|
|
// 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
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
|
|
Kirigami.Heading {
|
|
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
|
|
model: root.models
|
|
onActivated: root.currentModel = currentText
|
|
}
|
|
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 {
|
|
id: pinButton
|
|
icon.name: root.hideOnWindowDeactivate ? "window-pin" : "window-unpin"
|
|
checkable: true
|
|
checked: !root.hideOnWindowDeactivate
|
|
onClicked: {
|
|
root.hideOnWindowDeactivate = !checked;
|
|
}
|
|
PlasmaComponents.ToolTip {
|
|
visible: pinButton.hovered
|
|
text: root.hideOnWindowDeactivate ? i18n("Keep widget open (pinned)") : i18n("Close widget when unfocused")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Kirigami.Separator {
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
PlasmaComponents.ScrollView {
|
|
id: chatScrollView
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
|
|
PlasmaComponents.TextArea {
|
|
id: chatArea
|
|
text: root.chatText
|
|
readOnly: true
|
|
textFormat: TextEdit.RichText
|
|
wrapMode: TextEdit.WordWrap
|
|
background: Rectangle {
|
|
color: Kirigami.Theme.backgroundColor
|
|
opacity: 0.3
|
|
radius: 4
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 80
|
|
visible: root.selectedImagePath !== ""
|
|
color: Kirigami.Theme.backgroundColor
|
|
opacity: 0.8
|
|
radius: 4
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
anchors.margins: 5
|
|
Image {
|
|
source: root.selectedImagePath
|
|
Layout.fillHeight: true
|
|
Layout.preferredWidth: 70
|
|
fillMode: Image.PreserveAspectFit
|
|
}
|
|
PlasmaComponents.Label {
|
|
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"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
PlasmaComponents.Button {
|
|
icon.name: "mail-attachment"
|
|
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: i18n("Type message...")
|
|
enabled: !root.isWaiting
|
|
onAccepted: {
|
|
root.processAndSendMessage(text);
|
|
}
|
|
}
|
|
Component.onCompleted: root.inputField = inputField
|
|
PlasmaComponents.Button {
|
|
icon.name: "mail-send"
|
|
hoverEnabled: true
|
|
enabled: !root.isWaiting && inputField.text !== ""
|
|
onClicked: {
|
|
root.processAndSendMessage(inputField.text);
|
|
}
|
|
PlasmaComponents.ToolTip {
|
|
visible: parent.hovered
|
|
text: i18n("Send message")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|