ai chat: show message nicely, command suggestions

This commit is contained in:
end-4
2025-05-05 11:06:52 +02:00
parent 352d389cc4
commit 6e34831183
2 changed files with 257 additions and 41 deletions
+179 -27
View File
@@ -3,6 +3,7 @@ import "root:/services"
import "root:/modules/common" import "root:/modules/common"
import "root:/modules/common/widgets" import "root:/modules/common/widgets"
import "./aiChat/" 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/string_utils.js" as StringUtils
import Qt.labs.platform import Qt.labs.platform
import QtQuick import QtQuick
@@ -19,7 +20,9 @@ Item {
property var inputField: messageInputField property var inputField: messageInputField
readonly property var messages: Ai.messages readonly property var messages: Ai.messages
property string commandPrefix: "/" property string commandPrefix: "/"
property real scrollOnNewResponse: 60
property var suggestionQuery: ""
property var suggestionList: []
Connections { Connections {
target: panelWindow target: panelWindow
@@ -47,6 +50,13 @@ Item {
} }
property var allCommands: [ property var allCommands: [
{
name: "model",
description: qsTr("Choose model"),
execute: (args) => {
Ai.setModel(args[0]);
}
},
{ {
name: "clear", name: "clear",
description: qsTr("Clear chat history"), description: qsTr("Clear chat history"),
@@ -55,10 +65,10 @@ Item {
} }
}, },
{ {
name: "model", name: "test",
description: qsTr("Choose model"), description: qsTr("Markdown test message"),
execute: (args) => { execute: () => {
Ai.setModel(args[0]); Ai.addMessage("## ✏️ Markdown test\n- **Bold**, *Italic*, `Monospace`, [Link](https://example.com)\n", "interface");
} }
}, },
] ]
@@ -84,10 +94,10 @@ Item {
id: columnLayout id: columnLayout
anchors.fill: parent anchors.fill: parent
Item { Item { // Messages
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
ListView { // Messages ListView { // Message list
id: messageListView id: messageListView
anchors.fill: parent anchors.fill: parent
@@ -114,15 +124,7 @@ Item {
spacing: 10 spacing: 10
model: ScriptModel { model: ScriptModel {
values: { values: root.messages
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 { delegate: AiMessage {
messageData: modelData messageData: modelData
@@ -149,7 +151,7 @@ Item {
MaterialSymbol { MaterialSymbol {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
font.pixelSize: 55 iconSize: 55
color: Appearance.m3colors.m3outline color: Appearance.m3colors.m3outline
text: "neurology" text: "neurology"
} }
@@ -165,8 +167,115 @@ Item {
} }
} }
Rectangle { // Tag input area Item { // Suggestion description
id: tagInputContainer 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
Behavior on color {
ColorAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
}
}
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 property real columnSpacing: 5
Layout.fillWidth: true Layout.fillWidth: true
radius: Appearance.rounding.small radius: Appearance.rounding.small
@@ -208,13 +317,56 @@ Item {
background: Item {} 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() { function accept() {
root.handleInput(text) root.handleInput(text)
text = "" text = ""
} }
Keys.onPressed: (event) => { Keys.onPressed: (event) => {
if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) { 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) { if (event.modifiers & Qt.ShiftModifier) {
// Insert newline // Insert newline
messageInputField.insert(messageInputField.cursorPosition, "\n") messageInputField.insert(messageInputField.cursorPosition, "\n")
@@ -264,10 +416,11 @@ Item {
contentItem: MaterialSymbol { contentItem: MaterialSymbol {
anchors.centerIn: parent anchors.centerIn: parent
text: "send"
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
font.pixelSize: Appearance.font.pixelSize.larger iconSize: Appearance.font.pixelSize.larger
fill: sendButton.enabled ? 1 : 0
color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled
text: "arrow_upward"
} }
} }
} }
@@ -303,7 +456,7 @@ Item {
MaterialSymbol { MaterialSymbol {
text: "api" text: "api"
font.pixelSize: Appearance.font.pixelSize.large iconSize: Appearance.font.pixelSize.large
} }
StyledText { StyledText {
id: providerName id: providerName
@@ -318,7 +471,6 @@ Item {
id: toolTip id: toolTip
extraVisibleCondition: false extraVisibleCondition: false
alternativeVisibleCondition: mouseArea.containsMouse // Show tooltip when hovered 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"), content: StringUtils.format(qsTr("Current model: {0}\nSet it with {1}model MODEL"),
Ai.models[Ai.currentModel].name, root.commandPrefix) Ai.models[Ai.currentModel].name, root.commandPrefix)
} }
@@ -336,13 +488,13 @@ Item {
id: commandRepeater id: commandRepeater
model: commandButtonsRow.commandsShown model: commandButtonsRow.commandsShown
delegate: ApiCommandButton { delegate: ApiCommandButton {
id: tagButton id: commandButton
property string commandRepresentation: `${root.commandPrefix}${modelData.name}` property string commandRepresentation: `${root.commandPrefix}${modelData.name}`
buttonText: commandRepresentation buttonText: commandRepresentation
background: Rectangle { background: Rectangle {
radius: Appearance.rounding.small radius: Appearance.rounding.small
color: tagButton.down ? Appearance.colors.colLayer2Active : color: commandButton.down ? Appearance.colors.colLayer2Active :
tagButton.hovered ? Appearance.colors.colLayer2Hover : commandButton.hovered ? Appearance.colors.colLayer2Hover :
Appearance.colors.colLayer2 Appearance.colors.colLayer2
Behavior on color { Behavior on color {
@@ -43,19 +43,82 @@ Rectangle {
id: nameWrapper id: nameWrapper
color: Appearance.m3colors.m3secondaryContainer color: Appearance.m3colors.m3secondaryContainer
radius: Appearance.rounding.small radius: Appearance.rounding.small
implicitWidth: providerName.implicitWidth + 10 * 2 implicitWidth: nameRowLayout.implicitWidth + 10 * 2
implicitHeight: Math.max(providerName.implicitHeight + 5 * 2, 30) implicitHeight: Math.max(nameRowLayout.implicitHeight + 5 * 2, 30)
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
StyledText { RowLayout {
id: providerName id: nameRowLayout
anchors.centerIn: parent anchors.centerIn: parent
font.pixelSize: Appearance.font.pixelSize.large spacing: 10
font.weight: Font.DemiBold
color: Appearance.m3colors.m3onSecondaryContainer Item {
text: messageData.role == 'assistant' ? Ai.models[messageData.model].name : Layout.alignment: Qt.AlignVCenter
messageData.role == 'user' ? "User" : Layout.fillHeight: true
"System" implicitWidth: messageData.role == 'assistant' ? modelIcon.width : roleIcon.implicitWidth
implicitHeight: messageData.role == 'assistant' ? modelIcon.height : roleIcon.implicitHeight
CustomIcon {
id: modelIcon
anchors.centerIn: parent
visible: messageData.role == 'assistant' && Ai.models[messageData.model].icon
width: Appearance.font.pixelSize.large
height: Appearance.font.pixelSize.large
source: messageData.role == 'assistant' ? Ai.models[messageData.model].icon :
messageData.role == 'user' ? 'linux-symbolic' : 'desktop-symbolic'
}
ColorOverlay {
visible: modelIcon.visible
anchors.fill: modelIcon
source: modelIcon
color: Appearance.m3colors.m3onSecondaryContainer
}
MaterialSymbol {
id: roleIcon
anchors.centerIn: parent
visible: !modelIcon.visible
iconSize: Appearance.font.pixelSize.larger
color: Appearance.m3colors.m3onSecondaryContainer
text: messageData.role == 'user' ? 'person' :
messageData.role == 'interface' ? 'settings' :
messageData.role == 'assistant' ? 'neurology' :
'computer'
}
}
StyledText {
id: providerName
Layout.alignment: Qt.AlignVCenter
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' ? (SystemInfo.username ?? "User") :
"System"
}
}
}
Item { Layout.fillWidth: true }
Button { // Not visible to model
visible: messageData.role == 'interface'
implicitWidth: Math.max(notVisibleToModelText.implicitWidth + 10 * 2, 30)
implicitHeight: notVisibleToModelText.implicitHeight + 5 * 2
Layout.alignment: Qt.AlignVCenter
background: Item
MaterialSymbol {
id: notVisibleToModelText
anchors.centerIn: parent
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colSubtext
text: "visibility_off"
}
StyledToolTip {
content: qsTr("Not visible to model")
} }
} }
} }
@@ -65,12 +128,12 @@ Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.margins: messagePadding Layout.margins: messagePadding
// font.family: Appearance.font.family.reading font.family: Appearance.font.family.reading
font.pixelSize: Appearance.font.pixelSize.small font.pixelSize: Appearance.font.pixelSize.small
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
color: Appearance.colors.colOnLayer1 color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
textFormat: Text.MarkdownText textFormat: Text.MarkdownText
text: root.messageData.content text: messageData.thinking ? qsTr("Waiting for response...") : root.messageData.content
onLinkActivated: (link) => { onLinkActivated: (link) => {
Qt.openUrlExternally(link) Qt.openUrlExternally(link)
@@ -85,3 +148,4 @@ Rectangle {
} }
} }
} }