diff --git a/.config/quickshell/modules/sidebarLeft/AiChat.qml b/.config/quickshell/modules/sidebarLeft/AiChat.qml new file mode 100644 index 000000000..33f3bac06 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/AiChat.qml @@ -0,0 +1,373 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "./aiChat/" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import Qt.labs.platform +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell.Io +import Quickshell +import Quickshell.Hyprland + +Item { + id: root + property var panelWindow + property var inputField: messageInputField + readonly property var messages: Ai.messages + property string commandPrefix: "/" + property real scrollOnNewResponse: 60 + + Connections { + target: panelWindow + function onVisibleChanged(visible) { + messageInputField.forceActiveFocus() + } + } + onFocusChanged: (focus) => { + if (focus) { + messageInputField.forceActiveFocus() + } + } + + Keys.onPressed: (event) => { + messageInputField.forceActiveFocus() + if (event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageUp) { + messageListView.contentY = Math.max(0, messageListView.contentY - messageListView.height / 2) + event.accepted = true + } else if (event.key === Qt.Key_PageDown) { + messageListView.contentY = Math.min(messageListView.contentHeight - messageListView.height / 2, messageListView.contentY + messageListView.height / 2) + event.accepted = true + } + } + } + + property var allCommands: [ + { + name: "clear", + description: qsTr("Clear chat history"), + execute: () => { + Ai.clearMessages(); + } + }, + { + name: "model", + description: qsTr("Choose model"), + execute: (args) => { + Ai.setModel(args[0]); + } + }, + ] + + function handleInput(inputText) { + if (inputText.startsWith(root.commandPrefix)) { + // Handle special commands + const command = inputText.split(" ")[0].substring(1); + const args = inputText.split(" ").slice(1); + const commandObj = root.allCommands.find(cmd => cmd.name === `${command}`); + if (commandObj) { + commandObj.execute(args); + } else { + Ai.addMessage(qsTr("Unknown command: ") + command, "interface"); + } + } + else { + Ai.sendUserMessage(inputText); + } + } + + ColumnLayout { + id: columnLayout + anchors.fill: parent + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + ListView { // Messages + id: messageListView + anchors.fill: parent + + property int lastResponseLength: 0 + + clip: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + Behavior on contentY { + NumberAnimation { + id: scrollAnim + duration: Appearance.animation.scroll.duration + easing.type: Appearance.animation.scroll.type + easing.bezierCurve: Appearance.animation.scroll.bezierCurve + } + } + + spacing: 10 + model: ScriptModel { + values: { + if(root.messages.length > messageListView.lastResponseLength) { + if (messageListView.lastResponseLength > 0 && root.messages[messageListView.lastResponseLength].provider != "system") + messageListView.contentY = messageListView.contentY + root.scrollOnNewResponse + messageListView.lastResponseLength = root.messages.length + } + return root.messages + } + // values: root.messages + } + delegate: AiMessage { + messageData: modelData + messageInputField: root.inputField + } + } + + Item { // Placeholder when list is empty + opacity: root.messages.length === 0 ? 1 : 0 + visible: opacity > 0 + anchors.fill: parent + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: 55 + color: Appearance.m3colors.m3outline + text: "neurology" + } + StyledText { + id: widgetNameText + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: qsTr("Large language models") + } + } + } + } + + Rectangle { // Tag input area + id: tagInputContainer + property real columnSpacing: 5 + Layout.fillWidth: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + implicitWidth: messageInputField.implicitWidth + implicitHeight: Math.max(inputFieldRowLayout.implicitHeight + inputFieldRowLayout.anchors.topMargin + + commandButtonsRow.implicitHeight + commandButtonsRow.anchors.bottomMargin + columnSpacing, 45) + clip: true + border.color: Appearance.m3colors.m3outlineVariant + border.width: 1 + + Behavior on implicitHeight { + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + + RowLayout { // Input field and send button + id: inputFieldRowLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 5 + spacing: 0 + + TextArea { // The actual TextArea + id: messageInputField + wrapMode: TextArea.Wrap + Layout.fillWidth: true + padding: 10 + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + renderType: Text.NativeRendering + selectedTextColor: Appearance.m3colors.m3onPrimary + selectionColor: Appearance.m3colors.m3primary + placeholderText: StringUtils.format(qsTr('Message the model... "{0}" for commands'), root.commandPrefix) + placeholderTextColor: Appearance.m3colors.m3outline + + background: Item {} + + function accept() { + root.handleInput(text) + text = "" + } + + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) { + if (event.modifiers & Qt.ShiftModifier) { + // Insert newline + messageInputField.insert(messageInputField.cursorPosition, "\n") + event.accepted = true + } else { // Accept text + const inputText = messageInputField.text + root.handleInput(inputText) + messageInputField.clear() + event.accepted = true + } + } + } + } + + Button { // Send button + id: sendButton + Layout.alignment: Qt.AlignTop + Layout.rightMargin: 5 + implicitWidth: 40 + implicitHeight: 40 + enabled: messageInputField.text.length > 0 + + MouseArea { + anchors.fill: parent + cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + const inputText = messageInputField.text + root.handleInput(inputText) + messageInputField.clear() + } + } + + background: Rectangle { + radius: Appearance.rounding.small + color: sendButton.enabled ? (sendButton.down ? Appearance.colors.colPrimaryActive : + sendButton.hovered ? Appearance.colors.colPrimaryHover : + Appearance.m3colors.m3primary) : Appearance.colors.colLayer2Disabled + + Behavior on color { + ColorAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + text: "send" + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.larger + color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled + } + } + } + + RowLayout { // Controls + id: commandButtonsRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 5 + anchors.leftMargin: 5 + anchors.rightMargin: 5 + spacing: 5 + + property var commandsShown: [ + { + name: "model", + sendDirectly: false, + }, + { + name: "clear", + sendDirectly: true, + }, + ] + + Item { + implicitHeight: providerRowLayout.implicitHeight + 5 * 2 + implicitWidth: providerRowLayout.implicitWidth + 10 * 2 + + RowLayout { + id: providerRowLayout + anchors.centerIn: parent + + MaterialSymbol { + text: "api" + font.pixelSize: Appearance.font.pixelSize.large + } + StyledText { + id: providerName + font.pixelSize: Appearance.font.pixelSize.small + font.weight: Font.DemiBold + color: Appearance.m3colors.m3onSurface + elide: Text.ElideRight + text: Ai.models[Ai.currentModel].name + } + } + StyledToolTip { + id: toolTip + extraVisibleCondition: false + alternativeVisibleCondition: mouseArea.containsMouse // Show tooltip when hovered + // content: qsTr("The current API used. Endpoint: ") + Booru.providers[Booru.currentProvider].url + qsTr("\nSet with /mode PROVIDER") + content: StringUtils.format(qsTr("Current model: {0}\nSet it with {1}model MODEL"), + Ai.models[Ai.currentModel].name, root.commandPrefix) + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + } + } + + Item { Layout.fillWidth: true } + + Repeater { // Command buttons + id: commandRepeater + model: commandButtonsRow.commandsShown + delegate: ApiCommandButton { + id: tagButton + property string commandRepresentation: `${root.commandPrefix}${modelData.name}` + buttonText: commandRepresentation + background: Rectangle { + radius: Appearance.rounding.small + color: tagButton.down ? Appearance.colors.colLayer2Active : + tagButton.hovered ? Appearance.colors.colLayer2Hover : + Appearance.colors.colLayer2 + + Behavior on color { + ColorAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + } + } + onClicked: { + if(modelData.sendDirectly) { + root.handleInput(commandRepresentation) + } else { + messageInputField.text = commandRepresentation + " " + messageInputField.cursorPosition = messageInputField.text.length + messageInputField.forceActiveFocus() + } + } + } + } + } + + } + + } + +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/Anime.qml b/.config/quickshell/modules/sidebarLeft/Anime.qml index 0911fed4b..40550b158 100644 --- a/.config/quickshell/modules/sidebarLeft/Anime.qml +++ b/.config/quickshell/modules/sidebarLeft/Anime.qml @@ -91,7 +91,7 @@ Item { if (commandObj) { commandObj.execute(args); } else { - root.addSystemMessage(qsTr("Unknown command: ") + command); + Booru.addSystemMessage(qsTr("Unknown command: ") + command); } } else if (inputText.trim() == "+") { @@ -121,6 +121,11 @@ Item { tagInputField.forceActiveFocus() } } + onFocusChanged: (focus) => { + if (focus) { + tagInputField.forceActiveFocus() + } + } Keys.onPressed: (event) => { tagInputField.forceActiveFocus() @@ -135,11 +140,6 @@ Item { } } - onFocusChanged: (focus) => { - if (focus) { - tagInputField.forceActiveFocus() - } - } ColumnLayout { id: columnLayout @@ -311,7 +311,7 @@ Item { tagSuggestions.selectedIndex = 0 return root.suggestionList.slice(0, 10) } - delegate: BooruTagButton { + delegate: ApiCommandButton { id: tagButton background: Rectangle { @@ -419,7 +419,7 @@ Item { background: Item {} - property Timer searchTimer: Timer { + property Timer searchTimer: Timer { // Timer for tag suggestions interval: root.tagSuggestionDelay repeat: false onTriggered: { @@ -431,7 +431,7 @@ Item { } } - onTextChanged: { + onTextChanged: { // Handle tag suggestions if(tagInputField.text.length === 0) { root.suggestionQuery = "" root.suggestionList = [] @@ -618,7 +618,6 @@ Item { anchors.centerIn: parent MouseArea { - anchors.fill: parent hoverEnabled: true PointingHandInteraction {} onClicked: { @@ -653,7 +652,7 @@ Item { Repeater { // Command buttons id: commandRepeater model: commandButtonsRow.commandsShown - delegate: BooruTagButton { + delegate: ApiCommandButton { id: tagButton property string commandRepresentation: `${root.commandPrefix}${modelData.name}` buttonText: commandRepresentation diff --git a/.config/quickshell/modules/sidebarLeft/anime/BooruTagButton.qml b/.config/quickshell/modules/sidebarLeft/ApiCommandButton.qml similarity index 100% rename from .config/quickshell/modules/sidebarLeft/anime/BooruTagButton.qml rename to .config/quickshell/modules/sidebarLeft/ApiCommandButton.qml diff --git a/.config/quickshell/modules/sidebarLeft/SidebarLeft.qml b/.config/quickshell/modules/sidebarLeft/SidebarLeft.qml index 3c02db96c..717196b53 100644 --- a/.config/quickshell/modules/sidebarLeft/SidebarLeft.qml +++ b/.config/quickshell/modules/sidebarLeft/SidebarLeft.qml @@ -140,9 +140,8 @@ Scope { // Scope } } - StyledText { - text: "To be implemented" - horizontalAlignment: Text.AlignHCenter + AiChat { + panelWindow: sidebarRoot } Anime { panelWindow: sidebarRoot diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml new file mode 100644 index 000000000..57ae0a8a2 --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml @@ -0,0 +1,87 @@ +import "root:/" +import "root:/services" +import "root:/modules/common" +import "root:/modules/common/widgets" +import "../" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects + +Rectangle { + id: root + property var messageData + property var messageInputField + + property real availableWidth: parent.width ?? 0 + property real messagePadding: 7 + property real contentSpacing: 3 + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: columnLayout.implicitHeight + root.messagePadding * 2 + + radius: Appearance.rounding.normal + color: Appearance.colors.colLayer1 + + ColumnLayout { + id: columnLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: messagePadding + spacing: root.contentSpacing + + RowLayout { // Header + Rectangle { // Name + id: nameWrapper + color: Appearance.m3colors.m3secondaryContainer + radius: Appearance.rounding.small + implicitWidth: providerName.implicitWidth + 10 * 2 + implicitHeight: Math.max(providerName.implicitHeight + 5 * 2, 30) + Layout.alignment: Qt.AlignVCenter + + StyledText { + id: providerName + anchors.centerIn: parent + font.pixelSize: Appearance.font.pixelSize.large + font.weight: Font.DemiBold + color: Appearance.m3colors.m3onSecondaryContainer + text: messageData.role == 'assistant' ? Ai.models[messageData.model].name : + messageData.role == 'user' ? "User" : + "System" + } + } + } + + StyledText { // Message + id: messageText + Layout.fillWidth: true + Layout.margins: messagePadding + + // font.family: Appearance.font.family.reading + font.pixelSize: Appearance.font.pixelSize.small + wrapMode: Text.WordWrap + color: Appearance.colors.colOnLayer1 + textFormat: Text.MarkdownText + text: root.messageData.content + + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + Hyprland.dispatch("global quickshell:sidebarLeftClose") + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton // Only for hover + hoverEnabled: true + cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/anime/BooruResponse.qml b/.config/quickshell/modules/sidebarLeft/anime/BooruResponse.qml index c43837503..7355fff11 100644 --- a/.config/quickshell/modules/sidebarLeft/anime/BooruResponse.qml +++ b/.config/quickshell/modules/sidebarLeft/anime/BooruResponse.qml @@ -2,6 +2,7 @@ import "root:/" import "root:/services" import "root:/modules/common" import "root:/modules/common/widgets" +import "../" import QtQuick import QtQuick.Controls import QtQuick.Layouts @@ -21,10 +22,6 @@ Rectangle { property string downloadPath property string nsfwPath - onResponseDataChanged: { - console.log("Response data changed:", responseData) - } - property real availableWidth: parent.width ?? 0 property real rowTooShortThreshold: 185 property real imageSpacing: 5 @@ -126,7 +123,7 @@ Rectangle { id: tagRepeater model: root.responseData.tags - BooruTagButton { + ApiCommandButton { Layout.fillWidth: false buttonText: modelData onClicked: { diff --git a/.config/quickshell/services/Ai.qml b/.config/quickshell/services/Ai.qml new file mode 100644 index 000000000..d1a3596b7 --- /dev/null +++ b/.config/quickshell/services/Ai.qml @@ -0,0 +1,136 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import Quickshell; +import Quickshell.Io; +import Qt.labs.platform +import QtQuick; + +Singleton { + id: root + + property Component aiMessageComponent: AiMessageData {} + property var messages: [] + property var modelList: ["ollama-llama-3.2", "gemini-2.0-flash"] + property var models: { // TODO: Auto-detect installed ollama models + "interface": { + "name": "System", + }, + "ollama-llama-3.2": { + "name": "Ollama - Llama 3.2", + "icon": "ollama-symbolic", + "description": "Ollama - Llama 3.2", + "endpoint": "http://localhost:11434/api/chat", + "model": "llama3.2", + }, + "gemini-2.0-flash": { + "name": "Gemini 2.0 Flash", + "icon": "gemini-symbolic", + "description": "Gemini 2.0 Flash", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent", + "model": "gemini-2.0-flash", + "messageMapFunc": function (message) { + return { + "role": message.role, + "parts": [{text: message.content}], + } + }, + }, + } + property var currentModel: "ollama-llama-3.2" + + function addMessage(message, role) { + if (message.length === 0) return; + const aiMessage = aiMessageComponent.createObject(root, { + "role": role, + "content": message, + "thinking": false, + "done": true, + }); + root.messages = [...root.messages, aiMessage]; + } + + function setModel(model) { + if (!model) model = "" + model = model.toLowerCase() + if (modelList.indexOf(model) !== -1) { + currentModel = model + root.addMessage("Model set to " + models[model].name, "interface") + } else { + root.addMessage(qsTr("Invalid model. Supported: \n- ") + modelList.join("\n- "), "interface") + } + } + + function clearMessages() { + messages = []; + } + + Process { + id: requester + property var baseCommand: ["curl", "--no-buffer"] + property var message + + function makeRequest() { + const model = models[currentModel]; + + let endpoint = model.endpoint; + + // Build request data using OpenAI's format. If the model has a custom requestDataBuilder, use that instead. + let data = model.requestDataBuilder ? model.requestDataBuilder(root.messages.filter(message => (message.role != "interface"))) : { + "model": model.model, + "messages": root.messages.filter(message => (message.role != "interface")).map(message => { + return { // Remove unecessary properties + "role": message.role, + "content": message.content, + } + }), + } + + let requestHeaders = { + "Content-Type": "application/json", + // "Authorization": model.endpoint.startsWith("http") ? "Bearer " + model.apiKey : "", + } + + requester.message = root.aiMessageComponent.createObject(root, { + "role": "assistant", + "model": currentModel, + "content": "", + "thinking": true, + "done": false, + }); + root.messages = [...root.messages, requester.message]; + requester.command = baseCommand.concat([endpoint, "-d", JSON.stringify(data)]); + console.log("Request command: ", requester.command.join(" ")); + requester.running = true + } + + stdout: SplitParser { + onRead: data => { + // console.log("Received data: ", data); + if (data.length === 0) return; + const dataJson = JSON.parse(data); + if (requester.message.thinking) requester.message.thinking = false; + + requester.message.content += dataJson.message.content + + if (dataJson.done) requester.message.done = true; + } + } + } + + function sendUserMessage(message) { + if (message.length === 0) return; + + const userMessage = aiMessageComponent.createObject(root, { + "role": "user", + "content": message, + "thinking": false, + "done": true, + }); + root.messages = [...root.messages, userMessage]; + + requester.makeRequest(); + } + +} diff --git a/.config/quickshell/services/AiMessageData.qml b/.config/quickshell/services/AiMessageData.qml new file mode 100644 index 000000000..7bc5108e7 --- /dev/null +++ b/.config/quickshell/services/AiMessageData.qml @@ -0,0 +1,10 @@ +import "root:/modules/common" +import QtQuick; + +QtObject { + property string role + property string content + property string model + property bool thinking: true + property bool done: false +}