forked from Shinonome/dots-hyprland
555 lines
22 KiB
QML
555 lines
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 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: "temp",
|
||
description: qsTr("Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5."),
|
||
execute: (args) => {
|
||
// console.log(args)
|
||
if (args.length == 0 || args[0] == "get") {
|
||
Ai.printTemperature()
|
||
} else {
|
||
const temp = parseFloat(args[0]);
|
||
Ai.setTemperature(temp);
|
||
}
|
||
}
|
||
},
|
||
{
|
||
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
|
||
|
||
|
||
Inline w/ dollar signs: $\\frac{1}{2} = \\frac{2}{4}$
|
||
|
||
Inline w/ double dollar signs: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$
|
||
|
||
Inline w/ backslash and square brackets \\[\\int_0^\\infty \\frac{1}{x^2} dx = \\infty\\]
|
||
|
||
Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
|
||
`,
|
||
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
|
||
StyledListView { // Message list
|
||
id: messageListView
|
||
anchors.fill: parent
|
||
spacing: 10
|
||
popin: false
|
||
|
||
property int lastResponseLength: 0
|
||
|
||
clip: true
|
||
layer.enabled: true
|
||
layer.effect: OpacityMask {
|
||
maskSource: Rectangle {
|
||
width: swipeView.width
|
||
height: swipeView.height
|
||
radius: Appearance.rounding.small
|
||
}
|
||
}
|
||
|
||
add: null // Prevent function calls from being janky
|
||
|
||
Behavior on contentY {
|
||
NumberAnimation {
|
||
id: scrollAnim
|
||
duration: Appearance.animation.scroll.duration
|
||
easing.type: Appearance.animation.scroll.type
|
||
easing.bezierCurve: Appearance.animation.scroll.bezierCurve
|
||
}
|
||
}
|
||
|
||
model: ScriptModel {
|
||
values: Ai.messageIDs.filter(id => {
|
||
const message = Ai.messageByID[id];
|
||
return message?.visibleToUser ?? true;
|
||
})
|
||
}
|
||
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: 60
|
||
color: Appearance.m3colors.m3outline
|
||
text: "neurology"
|
||
}
|
||
StyledText {
|
||
id: widgetNameText
|
||
Layout.alignment: Qt.AlignHCenter
|
||
font.pixelSize: Appearance.font.pixelSize.larger
|
||
font.family: Appearance.font.family.title
|
||
color: Appearance.m3colors.m3outline
|
||
horizontalAlignment: Text.AlignHCenter
|
||
text: qsTr("Large language models")
|
||
}
|
||
StyledText {
|
||
id: widgetDescriptionText
|
||
Layout.fillWidth: true
|
||
font.pixelSize: Appearance.font.pixelSize.small
|
||
color: Appearance.m3colors.m3outline
|
||
horizontalAlignment: Text.AlignLeft
|
||
wrapMode: Text.Wrap
|
||
text: qsTr("Ctrl+O to expand the sidebar\nCtrl+P to detach sidebar into a window")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
StyledTextArea { // The actual TextArea
|
||
id: messageInputField
|
||
wrapMode: TextArea.Wrap
|
||
Layout.fillWidth: true
|
||
padding: 10
|
||
color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant
|
||
placeholderText: StringUtils.format(qsTr('Message the model... "{0}" for commands'), root.commandPrefix)
|
||
|
||
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 = ""
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
} |