This commit is contained in:
end-4
2025-05-05 01:13:41 +02:00
parent e02875890b
commit 94ef226b92
8 changed files with 620 additions and 19 deletions
@@ -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()
}
}
}
}
}
}
}
}
@@ -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
@@ -140,9 +140,8 @@ Scope { // Scope
}
}
StyledText {
text: "To be implemented"
horizontalAlignment: Text.AlignHCenter
AiChat {
panelWindow: sidebarRoot
}
Anime {
panelWindow: sidebarRoot
@@ -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
}
}
}
}
@@ -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: {
+136
View File
@@ -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();
}
}
@@ -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
}