mirror of
https://github.com/danbulant/dots-hyprland
synced 2026-05-24 12:22:09 +00:00
541 lines
No EOL
22 KiB
QML
541 lines
No EOL
22 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 "root:/modules/common/functions/file_utils.js" as FileUtils
|
||
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
|
||
property string commandPrefix: "/"
|
||
|
||
property var suggestionQuery: ""
|
||
property var suggestionList: []
|
||
|
||
onFocusChanged: (focus) => {
|
||
if (focus) {
|
||
root.inputField.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(`
|
||
<think>
|
||
A longer think block to test revealing animation
|
||
OwO wem ipsum dowo sit amet, consekituwet awipiscing ewit, sed do eiuwsmod tempow inwididunt ut wabowe et dowo mawa. Ut enim ad minim weniam, quis nostwud exeucitation uwuwamcow bowowis nisi ut awiquip ex ea commowo consequat. Duuis aute iwuwe dowo in wepwependewit in wowuptate velit esse ciwwum dowo eu fugiat nuwa pawiatuw. Excepteuw sint occaecat cupidatat non pwowoident, sunt in cuwpa qui officia desewunt mowit anim id est wabowum. Meouw! >w<
|
||
Mowe uwu wem ipsum!
|
||
</think>
|
||
## ✏️ Markdown test
|
||
### Formatting
|
||
|
||
- *Italic*, \`Monospace\`, **Bold**, [Link](https://example.com)
|
||
- Arch lincox icon <img src="/home/end/.config/quickshell/assets/icons/arch-symbolic.svg" height="${Appearance.font.pixelSize.small}"/>
|
||
|
||
### Table
|
||
|
||
Quickshell vs AGS/Astal
|
||
|
||
| | Quickshell | AGS/Astal |
|
||
|--------------------------|------------------|-------------------|
|
||
| UI Toolkit | Qt | Gtk3/Gtk4 |
|
||
| Language | QML | Js/Ts/Lua |
|
||
| Reactivity | Implied | Needs declaration |
|
||
| Widget placement | Mildly difficult | More intuitive |
|
||
| Bluetooth & Wifi support | ❌ | ✅ |
|
||
| No-delay keybinds | ✅ | ❌ |
|
||
| Development | New APIs | New syntax |
|
||
|
||
### Code block
|
||
|
||
Just a hello world...
|
||
|
||
\`\`\`cpp
|
||
#include <bits/stdc++.h>
|
||
// This is intentionally very long to test scrolling
|
||
const std::string GREETING = \"UwU\";
|
||
int main(int argc, char* argv[]) {
|
||
std::cout << GREETING;
|
||
}
|
||
\`\`\`
|
||
|
||
### LaTeX
|
||
|
||
- Simple inline: $\\frac{1}{2} = \\frac{2}{4}$
|
||
- Complex inline: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$
|
||
- Another complex inline: \\\\[\\int_0^\\infty \\frac{1}{x^2} dx = \\infty\\\\]
|
||
`,
|
||
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 {
|
||
animations: [Appearance.animation.elementMoveEnter.numberAnimation.createObject(this, {
|
||
property: "opacity",
|
||
from: 0,
|
||
to: 1
|
||
})]
|
||
}
|
||
remove: Transition {
|
||
animations: [Appearance.animation.elementMoveEnter.numberAnimation.createObject(this, {
|
||
property: "opacity",
|
||
from: 1,
|
||
to: 0
|
||
})]
|
||
}
|
||
|
||
model: ScriptModel {
|
||
values: Ai.messageIDs
|
||
}
|
||
delegate: AiMessage {
|
||
required property var modelData
|
||
required property int index
|
||
messageIndex: index
|
||
messageData: {
|
||
Ai.messageByID[modelData]
|
||
}
|
||
messageInputField: root.inputField
|
||
}
|
||
}
|
||
|
||
Item { // Placeholder when list is empty
|
||
opacity: Ai.messageIDs.length === 0 ? 1 : 0
|
||
visible: opacity > 0
|
||
anchors.fill: parent
|
||
|
||
Behavior on opacity {
|
||
animation: Appearance.animation.elementMoveEnter.numberAnimation.createObject(this)
|
||
}
|
||
|
||
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 ?? ""
|
||
}
|
||
}
|
||
}
|
||
|
||
FlowButtonGroup { // 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
|
||
colBackground: suggestions.selectedIndex === index ? Appearance.colors.colLayer2Hover : Appearance.colors.colLayer2
|
||
bounce: false
|
||
contentItem: StyledText {
|
||
font.pixelSize: Appearance.font.pixelSize.small
|
||
color: Appearance.m3colors.m3onSurface
|
||
horizontalAlignment: Text.AlignHCenter
|
||
text: modelData.displayName ?? modelData.name
|
||
}
|
||
|
||
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 {
|
||
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
|
||
}
|
||
|
||
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: null
|
||
|
||
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.visible) {
|
||
suggestions.selectedIndex = Math.max(0, suggestions.selectedIndex - 1);
|
||
event.accepted = true;
|
||
} else if (event.key === Qt.Key_Down && suggestions.visible) {
|
||
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
|
||
messageInputField.clear()
|
||
root.handleInput(inputText)
|
||
event.accepted = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
RippleButton { // Send button
|
||
id: sendButton
|
||
Layout.alignment: Qt.AlignTop
|
||
Layout.rightMargin: 5
|
||
implicitWidth: 40
|
||
implicitHeight: 40
|
||
buttonRadius: Appearance.rounding.small
|
||
enabled: messageInputField.text.length > 0
|
||
toggled: enabled
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||
onClicked: {
|
||
const inputText = messageInputField.text
|
||
root.handleInput(inputText)
|
||
messageInputField.clear()
|
||
}
|
||
}
|
||
|
||
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
|
||
color: Appearance.m3colors.m3onSurface
|
||
elide: Text.ElideRight
|
||
text: Ai.getModel().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.getModel().name, root.commandPrefix)
|
||
}
|
||
|
||
MouseArea {
|
||
id: mouseArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
}
|
||
}
|
||
|
||
Item { Layout.fillWidth: true }
|
||
|
||
ButtonGroup {
|
||
padding: 0
|
||
|
||
Repeater { // Command buttons
|
||
model: commandButtonsRow.commandsShown
|
||
delegate: ApiCommandButton {
|
||
property string commandRepresentation: `${root.commandPrefix}${modelData.name}`
|
||
buttonText: commandRepresentation
|
||
onClicked: {
|
||
if(modelData.sendDirectly) {
|
||
root.handleInput(commandRepresentation)
|
||
} else {
|
||
messageInputField.text = commandRepresentation + " "
|
||
messageInputField.cursorPosition = messageInputField.text.length
|
||
messageInputField.forceActiveFocus()
|
||
}
|
||
if (modelData.name === "clear") {
|
||
messageInputField.text = ""
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
} |