Files
illogical-impulse/.config/quickshell/modules/sidebarLeft/AiChat.qml
T
2025-05-07 12:16:20 +02:00

570 lines
24 KiB
QML

import "root:/"
import "root:/services"
import "root:/modules/common"
import "root:/modules/common/widgets"
import "./aiChat/"
import "root:/modules/common/functions/fuzzysort.js" as Fuzzy
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 var suggestionQuery: ""
property var suggestionList: []
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: "model",
description: qsTr("Choose model"),
execute: (args) => {
Ai.setModel(args[0]);
}
},
{
name: "clear",
description: qsTr("Clear chat history"),
execute: () => {
Ai.clearMessages();
}
},
{
name: "key",
description: qsTr("Set API key"),
execute: (args) => {
if (args[0] == "get") {
Ai.printApiKey()
} else {
Ai.setApiKey(args[0]);
}
}
},
{
name: "test",
description: qsTr("Markdown test"),
execute: () => {
Ai.addMessage("## ✏️ Markdown test\n"
+ "- **Bold**, *Italic*, `Monospace`, [Link](https://example.com)\n\n"
+ "- Table:\n\n"
+ "| | Quickshell | AGS/Astal |\n"
+ "|--------------------------|------------------|-------------------|\n"
+ "| UI Toolkit | Qt | Gtk3/Gtk4 |\n"
+ "| Language | QML | Js/Ts/Lua |\n"
+ "| Reactivity | Implied | Needs declaration |\n"
+ "| Widget placement | Mildly difficult | More intuitive |\n"
+ "| Bluetooth & Wifi support | ❌ | ✅ |\n"
+ "| No-delay keybinds | ✅ | ❌ |\n"
+ "| Development | New APIs | New syntax |\n"
+ "- Code block\n"
+ "```cpp\n"
+ "#include <bits/stdc++.h>\n"
+ "const std::string GREETING = \"UwU\";\n"
+ "int main(int argc, char* argv[]) {\n"
+ " std::cout << GREETING;\n"
+ "}\n"
+ "```\n"
, Ai.interfaceRole);
}
},
]
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, Ai.interfaceRole);
}
}
else {
Ai.sendUserMessage(inputText);
}
}
ColumnLayout {
id: columnLayout
anchors.fill: parent
Item { // Messages
Layout.fillWidth: true
Layout.fillHeight: true
ListView { // Message list
id: messageListView
anchors.fill: parent
spacing: 10
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
}
}
add: Transition {
NumberAnimation {
property: "opacity"
from: 0; to: 1
duration: Appearance.animation.elementMoveEnter.duration
easing.type: Appearance.animation.elementMoveEnter.type
easing.bezierCurve: Appearance.animation.elementMoveEnter.bezierCurve
}
}
remove: Transition {
NumberAnimation {
property: "opacity"
from: 1; to: 0
duration: Appearance.animation.elementMoveEnter.duration
easing.type: Appearance.animation.elementMoveEnter.type
easing.bezierCurve: Appearance.animation.elementMoveEnter.bezierCurve
}
}
model: ScriptModel {
values: root.messages
}
delegate: AiMessage {
messageIndex: index
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
iconSize: 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")
}
}
}
}
Item { // Suggestion description
visible: descriptionText.text.length > 0
Layout.fillWidth: true
implicitHeight: descriptionBackground.implicitHeight
Rectangle {
id: descriptionBackground
color: Appearance.colors.colTooltip
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
implicitHeight: descriptionText.implicitHeight + 5 * 2
radius: Appearance.rounding.verysmall
StyledText {
id: descriptionText
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 10
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
font.pixelSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colOnTooltip
wrapMode: Text.Wrap
text: root.suggestionList[suggestions.selectedIndex]?.description ?? ""
}
}
}
Flow { // Suggestions
id: suggestions
visible: root.suggestionList.length > 0 && messageInputField.text.length > 0
property int selectedIndex: 0
Layout.fillWidth: true
spacing: 5
Repeater {
id: suggestionRepeater
model: {
suggestions.selectedIndex = 0
return root.suggestionList.slice(0, 10)
}
delegate: ApiCommandButton {
id: commandButton
background: Rectangle {
radius: Appearance.rounding.small
color: suggestions.selectedIndex === index ? Appearance.colors.colLayer2Hover :
commandButton.down ? Appearance.colors.colLayer2Active :
commandButton.hovered ? Appearance.colors.colLayer2Hover :
Appearance.colors.colLayer2
}
contentItem: RowLayout {
spacing: 5
StyledText {
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.m3colors.m3onSurface
text: modelData.displayName ?? modelData.name
}
StyledText {
visible: modelData.count !== undefined
font.pixelSize: Appearance.font.pixelSize.smaller
color: Appearance.m3colors.m3outline
text: modelData.count ?? ""
}
}
onHoveredChanged: {
if (commandButton.hovered) {
suggestions.selectedIndex = index;
}
}
onClicked: {
suggestions.acceptSuggestion(modelData.name)
}
}
}
function acceptSuggestion(word) {
const words = messageInputField.text.trim().split(/\s+/);
if (words.length > 0) {
words[words.length - 1] = word;
} else {
words.push(word);
}
const updatedText = words.join(" ") + " ";
messageInputField.text = updatedText;
messageInputField.cursorPosition = messageInputField.text.length;
messageInputField.forceActiveFocus();
}
function acceptSelectedWord() {
if (suggestions.selectedIndex >= 0 && suggestions.selectedIndex < suggestionRepeater.count) {
const word = root.suggestionList[suggestions.selectedIndex].name;
suggestions.acceptSuggestion(word);
}
}
}
Rectangle { // Input area
id: inputWrapper
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.m3onSecondaryContainer
selectionColor: Appearance.m3colors.m3secondaryContainer
placeholderText: StringUtils.format(qsTr('Message the model... "{0}" for commands'), root.commandPrefix)
placeholderTextColor: Appearance.m3colors.m3outline
background: Item {}
onTextChanged: { // Handle suggestions
if(messageInputField.text.length === 0) {
root.suggestionQuery = ""
root.suggestionList = []
return
} else if(messageInputField.text.startsWith(`${root.commandPrefix}model`)) {
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""
const modelResults = Fuzzy.go(root.suggestionQuery, Ai.modelList.map(model => {
return {
name: Fuzzy.prepare(model),
obj: model,
}
}), {
all: true,
key: "name"
})
root.suggestionList = modelResults.map(model => {
return {
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "model ") : ""}${model.target}`,
displayName: `${Ai.models[model.target].name}`,
description: `${Ai.models[model.target].description}`,
}
})
} else if(messageInputField.text.startsWith(root.commandPrefix)) {
root.suggestionQuery = messageInputField.text
root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(messageInputField.text.substring(1))).map(cmd => {
return {
name: `${root.commandPrefix}${cmd.name}`,
description: `${cmd.description}`,
}
})
}
}
function accept() {
root.handleInput(text)
text = ""
}
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Tab) {
suggestions.acceptSelectedWord();
event.accepted = true;
} else if (event.key === Qt.Key_Up) {
suggestions.selectedIndex = Math.max(0, suggestions.selectedIndex - 1);
event.accepted = true;
} else if (event.key === Qt.Key_Down) {
suggestions.selectedIndex = Math.min(root.suggestionList.length - 1, suggestions.selectedIndex + 1);
event.accepted = true;
} else 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
horizontalAlignment: Text.AlignHCenter
iconSize: Appearance.font.pixelSize.larger
// fill: sendButton.enabled ? 1 : 0
color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled
text: "send"
}
}
}
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"
iconSize: 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: 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: commandButton
property string commandRepresentation: `${root.commandPrefix}${modelData.name}`
buttonText: commandRepresentation
background: Rectangle {
radius: Appearance.rounding.small
color: commandButton.down ? Appearance.colors.colLayer2Active :
commandButton.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()
}
}
}
}
}
}
}
}