Add a feature to attach an excel spreadsheet to the chat conversation.

Signed-off-by: Adam Treat <treat.adam@gmail.com>
This commit is contained in:
Adam Treat 2024-07-11 13:52:19 -04:00
parent ea1ade8668
commit 060560cfcc
21 changed files with 724 additions and 198 deletions

3
.gitmodules vendored
View File

@ -11,3 +11,6 @@
[submodule "gpt4all-chat/deps/fmt"]
path = gpt4all-chat/deps/fmt
url = https://github.com/fmtlib/fmt.git
[submodule "gpt4all-chat/deps/QXlsx"]
path = gpt4all-chat/deps/QXlsx
url = https://github.com/QtExcel/QXlsx.git

View File

@ -135,6 +135,7 @@ endif()
set(QAPPLICATION_CLASS QGuiApplication)
add_subdirectory(deps/SingleApplication)
add_subdirectory(deps/QXlsx/QXlsx)
if (DEFINED GGML_METALLIB)
set_source_files_properties("${GGML_METALLIB}" PROPERTIES GENERATED ON)
@ -162,6 +163,7 @@ qt_add_executable(chat
src/mysettings.cpp src/mysettings.h
src/network.cpp src/network.h
src/server.cpp src/server.h
src/xlsxtomd.cpp src/xlsxtomd.h
${CHAT_EXE_RESOURCES}
)
@ -198,6 +200,8 @@ qt_add_qml_module(chat
qml/MyComboBox.qml
qml/MyDialog.qml
qml/MyDirectoryField.qml
qml/MyFileDialog.qml
qml/MyFolderDialog.qml
qml/MyFancyLink.qml
qml/MyMenu.qml
qml/MyMenuItem.qml
@ -230,9 +234,11 @@ qt_add_qml_module(chat
icons/edit.svg
icons/eject.svg
icons/email.svg
icons/file-doc.svg
icons/file-md.svg
icons/file-pdf.svg
icons/file-txt.svg
icons/file-xls.svg
icons/file.svg
icons/github.svg
icons/globe.svg
@ -250,7 +256,9 @@ qt_add_qml_module(chat
icons/network.svg
icons/nomic_logo.svg
icons/notes.svg
icons/paperclip.svg
icons/plus.svg
icons/plus_circle.svg
icons/recycle.svg
icons/regenerate.svg
icons/search.svg
@ -263,6 +271,7 @@ qt_add_qml_module(chat
icons/trash.svg
icons/twitter.svg
icons/up_down.svg
icons/webpage.svg
icons/you.svg
)
@ -335,7 +344,7 @@ target_include_directories(chat PRIVATE deps/usearch/include
target_link_libraries(chat
PRIVATE Qt6::Core Qt6::HttpServer Qt6::Pdf Qt6::Quick Qt6::Sql Qt6::Svg)
target_link_libraries(chat
PRIVATE llmodel SingleApplication fmt::fmt)
PRIVATE llmodel SingleApplication QXlsx fmt::fmt)
# -- install --

@ -0,0 +1 @@
Subproject commit fda6b806e2ceebd81c01cdded07ae84c94f5879c

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#000000" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm48-88a8,8,0,0,1-8,8H136v32a8,8,0,0,1-16,0V136H88a8,8,0,0,1,0-16h32V88a8,8,0,0,1,16,0v32h32A8,8,0,0,1,176,128Z"></path></svg>

After

Width:  |  Height:  |  Size: 340 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#000000" viewBox="0 0 256 256"><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V88H40V56Zm0,144H40V104H216v96Z"></path></svg>

After

Width:  |  Height:  |  Size: 255 B

View File

@ -89,15 +89,8 @@ Rectangle {
property alias collection: collection.text
property alias folder_path: folderEdit.text
FolderDialog {
MyFolderDialog {
id: folderDialog
title: qsTr("Please choose a directory")
}
function openFolderDialog(currentFolder, onAccepted) {
folderDialog.currentFolder = currentFolder;
folderDialog.accepted.connect(function() { onAccepted(folderDialog.selectedFolder); });
folderDialog.open();
}
Label {
@ -170,7 +163,7 @@ Rectangle {
id: browseButton
text: qsTr("Browse")
onClicked: {
root.openFolderDialog(StandardPaths.writableLocation(StandardPaths.HomeLocation), function(selectedFolder) {
folderDialog.openFolderDialog(StandardPaths.writableLocation(StandardPaths.HomeLocation), function(selectedFolder) {
root.folder_path = selectedFolder
})
}

View File

@ -394,11 +394,14 @@ MySettingsTab {
}
}
}
MyFolderDialog {
id: folderDialog
}
MySettingsButton {
text: qsTr("Browse")
Accessible.description: qsTr("Choose where to save model files")
onClicked: {
openFolderDialog("file://" + MySettings.modelPath, function(selectedFolder) {
folderDialog.openFolderDialog("file://" + MySettings.modelPath, function(selectedFolder) {
MySettings.modelPath = selectedFolder
})
}

View File

@ -3,6 +3,7 @@ import QtCore
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Basic
import QtQuick.Dialogs
import QtQuick.Layouts
import chatlistmodel
@ -893,6 +894,64 @@ Rectangle {
Layout.row: 1
Layout.column: 1
Layout.fillWidth: true
Flow {
id: attachedUrlsFlow
Layout.fillWidth: true
spacing: 10
visible: promptAttachments.length !== 0
Repeater {
model: promptAttachments
delegate: Rectangle {
width: attachmentFileIcon.width + attachmentFileText.width + 20
height: 50
radius: 5
color: theme.attachmentBackground
border.color: theme.controlBorder
Row {
spacing: 5
anchors.fill: parent
anchors.margins: 5
Item {
id: attachmentFileIcon
width: 40
height: 40
Image {
id: fileIcon
anchors.fill: parent
visible: false
sourceSize.width: 40
sourceSize.height: 40
mipmap: true
source: {
return "qrc:/gpt4all/icons/file-xls.svg"
}
}
ColorOverlay {
anchors.fill: fileIcon
source: fileIcon
color: theme.textColor
}
}
Text {
id: attachmentFileText
height: 40
text: modelData.file
color: theme.textColor
horizontalAlignment: Text.AlignHLeft
verticalAlignment: Text.AlignVCenter
font.pixelSize: theme.fontSizeSmall
font.bold: true
wrapMode: Text.WrapAnywhere
}
}
}
}
}
TextArea {
id: myTextArea
Layout.fillWidth: true
@ -1434,17 +1493,7 @@ Rectangle {
var chat = window.currentChat
var followup = modelData
chat.stopGenerating()
chat.newPromptResponsePair(followup);
chat.prompt(followup,
MySettings.promptTemplate,
MySettings.maxLength,
MySettings.topK,
MySettings.topP,
MySettings.minP,
MySettings.temperature,
MySettings.promptBatchSize,
MySettings.repeatPenalty,
MySettings.repeatPenaltyTokens)
chat.prompt(followup)
}
}
Item {
@ -1825,23 +1874,162 @@ Rectangle {
opacity: 0.1
}
ScrollView {
ListModel {
id: attachmentModel
function getAttachmentUrls() {
var urls = [];
for (var i = 0; i < attachmentModel.count; i++) {
var item = attachmentModel.get(i);
urls.push(item.url);
}
return urls;
}
}
Rectangle {
id: textInputView
color: theme.controlBackground
border.width: 1
border.color: theme.controlBorder
radius: 10
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 30
anchors.leftMargin: Math.max((parent.width - 1310) / 2, 30)
anchors.rightMargin: Math.max((parent.width - 1310) / 2, 30)
height: Math.min(contentHeight, 200)
height: textInputViewLayout.implicitHeight
visible: !currentChat.isServer && ModelList.selectableModels.count !== 0
MouseArea {
id: textInputViewMouseArea
anchors.fill: parent
onClicked: (mouse) => {
if (textInput.enabled)
textInput.forceActiveFocus();
}
}
GridLayout {
id: textInputViewLayout
anchors.left: parent.left
anchors.right: parent.right
rows: 2
columns: 3
rowSpacing: 0
columnSpacing: 0
Flow {
id: attachmentsFlow
visible: attachmentModel.count
Layout.row: 0
Layout.column: 1
Layout.topMargin: 15
Layout.leftMargin: 15
Layout.rightMargin: 15
spacing: 10
Repeater {
model: attachmentModel
Rectangle {
width: attachmentFileIcon2.width + attachmentFileText2.width + 40
height: 50
radius: 5
color: theme.attachmentBackground
border.color: theme.controlBorder
Row {
spacing: 5
anchors.fill: parent
anchors.margins: 5
Item {
id: attachmentFileIcon2
width: 40
height: 40
Image {
id: fileIcon2
anchors.fill: parent
visible: false
sourceSize.width: 40
sourceSize.height: 40
mipmap: true
source: {
return "qrc:/gpt4all/icons/file-xls.svg"
}
}
ColorOverlay {
anchors.fill: fileIcon2
source: fileIcon2
color: theme.textColor
}
}
Text {
id: attachmentFileText2
height: 40
text: model.file
color: theme.textColor
horizontalAlignment: Text.AlignHLeft
verticalAlignment: Text.AlignVCenter
font.pixelSize: theme.fontSizeSmall
font.bold: true
wrapMode: Text.WrapAnywhere
}
}
MyMiniButton {
id: removeAttachmentButton
anchors.top: parent.top
anchors.right: parent.right
backgroundColor: theme.textColor
backgroundColorHovered: theme.iconBackgroundDark
source: "qrc:/gpt4all/icons/close.svg"
onClicked: {
attachmentModel.remove(index)
if (textInput.enabled)
textInput.forceActiveFocus();
}
}
}
}
}
MyToolButton {
id: plusButton
Layout.row: 1
Layout.column: 0
Layout.leftMargin: 15
Layout.alignment: Qt.AlignCenter
backgroundColor: theme.conversationInputButtonBackground
backgroundColorHovered: theme.conversationInputButtonBackgroundHovered
imageWidth: theme.fontSizeLargest
imageHeight: theme.fontSizeLargest
visible: !currentChat.isServer && ModelList.selectableModels.count !== 0 && currentChat.isModelLoaded
enabled: !currentChat.responseInProgress
source: "qrc:/gpt4all/icons/paperclip.svg"
Accessible.name: qsTr("Add media")
Accessible.description: qsTr("Adds media to the prompt")
onClicked: (mouse) => {
addMediaMenu.open()
}
}
ScrollView {
id: textInputScrollView
Layout.row: 1
Layout.column: 1
Layout.fillWidth: true
Layout.leftMargin: plusButton.visible ? 5 : 15
Layout.margins: 15
height: Math.min(contentHeight, 200)
MyTextArea {
id: textInput
color: theme.textColor
topPadding: 15
bottomPadding: 15
leftPadding: 20
rightPadding: 40
padding: 0
enabled: currentChat.isModelLoaded && !currentChat.isServer
onEnabledChanged: {
if (textInput.enabled)
@ -1861,21 +2049,12 @@ Rectangle {
}
}
function sendMessage() {
if (textInput.text === "" || currentChat.responseInProgress || currentChat.restoringFromText)
if ((textInput.text === "" && attachmentModel.count === 0) || currentChat.responseInProgress || currentChat.restoringFromText)
return
currentChat.stopGenerating()
currentChat.newPromptResponsePair(textInput.text);
currentChat.prompt(textInput.text,
MySettings.promptTemplate,
MySettings.maxLength,
MySettings.topK,
MySettings.topP,
MySettings.minP,
MySettings.temperature,
MySettings.promptBatchSize,
MySettings.repeatPenalty,
MySettings.repeatPenaltyTokens)
currentChat.prompt(textInput.text, attachmentModel.getAttachmentUrls())
attachmentModel.clear();
textInput.text = ""
}
@ -1893,6 +2072,11 @@ Rectangle {
}
}
background: Rectangle {
implicitWidth: 150
color: "transparent"
}
MyMenu {
id: textInputContextMenu
MyMenuItem {
@ -1919,14 +2103,16 @@ Rectangle {
}
}
Row {
Layout.row: 1
Layout.column: 2
Layout.rightMargin: 15
Layout.alignment: Qt.AlignCenter
MyToolButton {
id: stopButton
backgroundColor: theme.conversationInputButtonBackground
backgroundColorHovered: theme.conversationInputButtonBackgroundHovered
anchors.right: textInputView.right
anchors.verticalCenter: textInputView.verticalCenter
anchors.rightMargin: 15
visible: currentChat.responseInProgress && !currentChat.isServer
background: Item {
@ -1975,9 +2161,6 @@ Rectangle {
id: sendButton
backgroundColor: theme.conversationInputButtonBackground
backgroundColorHovered: theme.conversationInputButtonBackgroundHovered
anchors.right: textInputView.right
anchors.verticalCenter: textInputView.verticalCenter
anchors.rightMargin: 15
imageWidth: theme.fontSizeLargest
imageHeight: theme.fontSizeLargest
visible: !currentChat.responseInProgress && !currentChat.isServer && ModelList.selectableModels.count !== 0
@ -1993,4 +2176,38 @@ Rectangle {
}
}
}
}
MyFileDialog {
id: fileDialog
nameFilters: ["Excel files (*.xlsx)"]
}
MyMenu {
id: addMediaMenu
x: textInputView.x
y: textInputView.y - addMediaMenu.height - 10;
title: qsTr("Attach")
MyMenuItem {
text: qsTr("Single File")
icon.source: "qrc:/gpt4all/icons/file.svg"
icon.width: 24
icon.height: 24
onClicked: {
fileDialog.openFileDialog(StandardPaths.writableLocation(StandardPaths.HomeLocation), function(selectedFile) {
if (selectedFile) {
var file = selectedFile.toString().split("/").pop()
attachmentModel.append({
file: file,
url: selectedFile
})
}
if (textInput.enabled)
textInput.forceActiveFocus();
})
}
}
}
}
}
}

View File

@ -0,0 +1,19 @@
import QtCore
import QtQuick
import QtQuick.Dialogs
FileDialog {
id: fileDialog
title: qsTr("Please choose a file")
property var acceptedConnection: null
function openFileDialog(currentFolder, onAccepted) {
fileDialog.currentFolder = currentFolder;
if (acceptedConnection !== null) {
fileDialog.accepted.disconnect(acceptedConnection);
}
acceptedConnection = function() { onAccepted(fileDialog.selectedFile); };
fileDialog.accepted.connect(acceptedConnection);
fileDialog.open();
}
}

View File

@ -0,0 +1,14 @@
import QtCore
import QtQuick
import QtQuick.Dialogs
FolderDialog {
id: folderDialog
title: qsTr("Please choose a directory")
function openFolderDialog(currentFolder, onAccepted) {
folderDialog.currentFolder = currentFolder;
folderDialog.accepted.connect(function() { onAccepted(folderDialog.selectedFolder); });
folderDialog.open();
}
}

View File

@ -22,12 +22,30 @@ Menu {
contentItem: Rectangle {
implicitWidth: myListView.contentWidth
implicitHeight: myListView.contentHeight
implicitHeight: (myTitle.visible ? myTitle.contentHeight + 10: 0) + myListView.contentHeight
color: "transparent"
Text {
id: myTitle
visible: menu.title !== ""
text: menu.title
anchors.margins: 10
anchors.top: parent.top
anchors.right: parent.right
anchors.left: parent.left
leftPadding: 15
rightPadding: 10
padding: 5
color: theme.styledTextColor
font.pixelSize: theme.fontSizeSmall
}
ListView {
id: myListView
anchors.margins: 10
anchors.fill: parent
anchors.top: title.bottom
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.left: parent.left
implicitHeight: contentHeight
model: menu.contentModel
interactive: Window.window

View File

@ -1,7 +1,9 @@
import Qt5Compat.GraphicalEffects
import QtCore
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Basic
import QtQuick.Layouts
MenuItem {
id: item
@ -11,12 +13,40 @@ MenuItem {
color: item.highlighted ? theme.menuHighlightColor : theme.menuBackgroundColor
}
contentItem: Text {
leftPadding: 10
rightPadding: 10
contentItem: RowLayout {
spacing: 0
Item {
visible: item.icon.source.toString() !== ""
Layout.leftMargin: 6
Layout.preferredWidth: item.icon.width
Layout.preferredHeight: item.icon.height
Image {
id: image
anchors.centerIn: parent
visible: false
fillMode: Image.PreserveAspectFit
mipmap: true
sourceSize.width: item.icon.width
sourceSize.height: item.icon.height
source: item.icon.source
}
ColorOverlay {
anchors.fill: image
source: image
color: theme.textColor
}
}
Text {
Layout.alignment: Qt.AlignLeft
padding: 5
text: item.text
color: theme.textColor
font.pixelSize: theme.fontSizeLarge
}
Rectangle {
color: "transparent"
Layout.fillWidth: true
height: 1
}
}
}

View File

@ -61,17 +61,6 @@ Item {
color: theme.settingsDivider
}
FolderDialog {
id: folderDialog
title: qsTr("Please choose a directory")
}
function openFolderDialog(currentFolder, onAccepted) {
folderDialog.currentFolder = currentFolder;
folderDialog.accepted.connect(function() { onAccepted(folderDialog.selectedFolder); });
folderDialog.open();
}
StackLayout {
id: stackLayout
anchors.top: tabTitlesModel.count > 1 ? dividerTabBar.bottom : parent.top
@ -88,7 +77,6 @@ Item {
sourceComponent: model.modelData
onLoaded: {
settingsStack.tabTitlesModel.append({ "title": loader.item.title });
item.openFolderDialog = settingsStack.openFolderDialog;
}
}
}

View File

@ -9,7 +9,6 @@ Item {
property string title: ""
property Item contentItem: null
property bool showRestoreDefaultsButton: true
property var openFolderDialog
signal restoreDefaultsClicked
onContentItemChanged: function() {

View File

@ -177,6 +177,17 @@ QtObject {
}
}
property color attachmentBackground: {
switch (MySettings.chatTheme) {
case MySettingsEnums.ChatTheme.LegacyDark:
return blue1000
case MySettingsEnums.ChatTheme.Dark:
return darkgray200
default:
return gray200
}
}
property color disabledControlBackground: {
switch (MySettings.chatTheme) {
case MySettingsEnums.ChatTheme.LegacyDark:

View File

@ -4,6 +4,7 @@
#include "mysettings.h"
#include "network.h"
#include "server.h"
#include "xlsxtomd.h"
#include <QDataStream>
#include <QDateTime>
@ -124,9 +125,27 @@ void Chat::resetResponseState()
emit responseStateChanged();
}
void Chat::prompt(const QString &prompt)
void Chat::prompt(const QString &prompt, const QList<QUrl> &attachedUrls)
{
newPromptResponsePairInternal(prompt, attachedUrls);
emit resetResponseRequested();
resetResponseState();
QStringList attachedContexts;
for (const QUrl &url : attachedUrls) {
Q_ASSERT(url.isLocalFile());
const QString localFilePath = url.toLocalFile();
const QFileInfo info(localFilePath);
Q_ASSERT(info.suffix() == "xlsx"); // We only support excel right now
Q_ASSERT(info.exists());
Q_ASSERT(info.isFile());
attachedContexts << XLSXToMD::toMarkdown(info.canonicalFilePath());
}
if (!attachedContexts.isEmpty())
emit promptRequested(m_collections, attachedContexts.join("\n\n") + "\n\n" + prompt);
else
emit promptRequested(m_collections, prompt);
}
@ -234,21 +253,17 @@ void Chat::setModelInfo(const ModelInfo &modelInfo)
emit modelChangeRequested(modelInfo);
}
void Chat::newPromptResponsePair(const QString &prompt)
// the server needs to block until response is reset, so it calls resetResponse on its own m_llmThread
void Chat::serverNewPromptResponsePair(const QString &prompt, const QList<QUrl> &attachedUrls)
{
resetResponseState();
m_chatModel->updateCurrentResponse(m_chatModel->count() - 1, false);
m_chatModel->appendPrompt("Prompt: ", prompt);
m_chatModel->appendResponse("Response: ", QString());
emit resetResponseRequested();
newPromptResponsePairInternal(prompt, attachedUrls);
}
// the server needs to block until response is reset, so it calls resetResponse on its own m_llmThread
void Chat::serverNewPromptResponsePair(const QString &prompt)
void Chat::newPromptResponsePairInternal(const QString &prompt, const QList<QUrl> &attachedUrls)
{
resetResponseState();
m_chatModel->updateCurrentResponse(m_chatModel->count() - 1, false);
m_chatModel->appendPrompt("Prompt: ", prompt);
m_chatModel->appendPrompt("Prompt: ", prompt, attachedUrls);
m_chatModel->appendResponse("Response: ", QString());
}

View File

@ -76,10 +76,9 @@ public:
bool isModelLoaded() const { return m_modelLoadingPercentage == 1.0f; }
bool isCurrentlyLoading() const { return m_modelLoadingPercentage > 0.0f && m_modelLoadingPercentage < 1.0f; }
float modelLoadingPercentage() const { return m_modelLoadingPercentage; }
Q_INVOKABLE void prompt(const QString &prompt);
Q_INVOKABLE void prompt(const QString &prompt, const QList<QUrl> &attachedUrls = QList<QUrl>());
Q_INVOKABLE void regenerateResponse();
Q_INVOKABLE void stopGenerating();
Q_INVOKABLE void newPromptResponsePair(const QString &prompt);
QList<ResultInfo> databaseResults() const { return m_databaseResults; }
@ -124,7 +123,7 @@ public:
QList<QString> generatedQuestions() const { return m_generatedQuestions; }
public Q_SLOTS:
void serverNewPromptResponsePair(const QString &prompt);
void serverNewPromptResponsePair(const QString &prompt, const QList<QUrl> &attachedUrls = QList<QUrl>());
Q_SIGNALS:
void idChanged(const QString &id);
@ -173,6 +172,9 @@ private Q_SLOTS:
void handleModelInfoChanged(const ModelInfo &modelInfo);
void handleTrySwitchContextOfLoadedModelCompleted(int value);
private:
void newPromptResponsePairInternal(const QString &prompt, const QList<QUrl> &attachedUrls);
private:
QString m_id;
QString m_name;

View File

@ -16,6 +16,24 @@
#include <Qt>
#include <QtGlobal>
struct PromptAttachment {
Q_GADGET
Q_PROPERTY(QUrl url MEMBER url)
Q_PROPERTY(QString file MEMBER file)
public:
QUrl url;
QString file;
bool operator==(const PromptAttachment &other) const {
return url == other.url &&
file == other.file;
}
bool operator!=(const PromptAttachment &other) const {
return !(*this == other);
}
};
Q_DECLARE_METATYPE(PromptAttachment)
struct ChatItem
{
Q_GADGET
@ -30,6 +48,7 @@ struct ChatItem
Q_PROPERTY(bool thumbsDownState MEMBER thumbsDownState)
Q_PROPERTY(QList<ResultInfo> sources MEMBER sources)
Q_PROPERTY(QList<ResultInfo> consolidatedSources MEMBER consolidatedSources)
Q_PROPERTY(QList<PromptAttachment> promptAttachments MEMBER promptAttachments);
public:
// TODO: Maybe we should include the model name here as well as timestamp?
@ -40,6 +59,7 @@ public:
QString newResponse;
QList<ResultInfo> sources;
QList<ResultInfo> consolidatedSources;
QList<PromptAttachment> promptAttachments;
bool currentResponse = false;
bool stopped = false;
bool thumbsUpState = false;
@ -66,7 +86,8 @@ public:
ThumbsUpStateRole,
ThumbsDownStateRole,
SourcesRole,
ConsolidatedSourcesRole
ConsolidatedSourcesRole,
PromptAttachmentsRole
};
int rowCount(const QModelIndex &parent = QModelIndex()) const override
@ -104,6 +125,8 @@ public:
return QVariant::fromValue(item.sources);
case ConsolidatedSourcesRole:
return QVariant::fromValue(item.consolidatedSources);
case PromptAttachmentsRole:
return QVariant::fromValue(item.promptAttachments);
}
return QVariant();
@ -123,14 +146,29 @@ public:
roles[ThumbsDownStateRole] = "thumbsDownState";
roles[SourcesRole] = "sources";
roles[ConsolidatedSourcesRole] = "consolidatedSources";
roles[PromptAttachmentsRole] = "promptAttachments";
return roles;
}
void appendPrompt(const QString &name, const QString &value)
void appendPrompt(const QString &name, const QString &value, const QList<QUrl> &attachedUrls)
{
ChatItem item;
item.name = name;
item.value = value;
for (const QUrl &url : attachedUrls) {
Q_ASSERT(url.isLocalFile());
const QString localFilePath = url.toLocalFile();
const QFileInfo info(localFilePath);
Q_ASSERT(info.suffix() == "xlsx"); // We only support excel right now
Q_ASSERT(info.exists());
Q_ASSERT(info.isFile());
PromptAttachment attached;
attached.url = url;
attached.file = info.fileName();
item.promptAttachments << attached;
}
beginInsertRows(QModelIndex(), m_chatItems.size(), m_chatItems.size());
m_chatItems.append(item);
endInsertRows();

View File

@ -32,7 +32,7 @@ public Q_SLOTS:
void start();
Q_SIGNALS:
void requestServerNewPromptResponsePair(const QString &prompt);
void requestServerNewPromptResponsePair(const QString &prompt, const QList<QUrl> &attachedUrls = QList<QUrl>());
private:
auto handleCompletionRequest(const CompletionRequest &request) -> std::pair<QHttpServerResponse, std::optional<QJsonObject>>;

View File

@ -0,0 +1,150 @@
#include "xlsxtomd.h"
#include <xlsxcellrange.h>
#include <xlsxdocument.h>
#include <xlsxworkbook.h>
QString formatCellText(const QXlsx::Cell* cell)
{
if (!cell) return QString();
QVariant value = cell->value();
QXlsx::Format format = cell->format();
QString cellText;
// Determine the cell type based on format
if (format.isDateTimeFormat()) {
// Handle DateTime
QDateTime dateTime = value.toDateTime();
cellText = dateTime.isValid() ? dateTime.toString("yyyy-MM-dd") : value.toString();
} else {
cellText = value.toString();
}
if (cellText.isEmpty())
return QString();
// Apply Markdown and HTML formatting based on font styles
QString formattedText = cellText;
if (format.fontBold() && format.fontItalic())
formattedText = "***" + formattedText + "***";
else if (format.fontBold())
formattedText = "**" + formattedText + "**";
else if (format.fontItalic())
formattedText = "*" + formattedText + "*";
if (format.fontStrikeOut())
formattedText = "~~" + formattedText + "~~";
// Escape pipe characters to prevent Markdown table issues
formattedText.replace("|", "\\|");
return formattedText;
}
QString getCellValue(QXlsx::Worksheet* sheet, int row, int col) {
if (!sheet)
return QString();
// Attempt to retrieve the cell directly
std::shared_ptr<QXlsx::Cell> cell = sheet->cellAt(row, col);
// If the cell is part of a merged range and not directly available
if (!cell) {
for (const QXlsx::CellRange& range : sheet->mergedCells()) {
if (row >= range.firstRow() && row <= range.lastRow() &&
col >= range.firstColumn() && col <= range.lastColumn()) {
cell = sheet->cellAt(range.firstRow(), range.firstColumn());
break;
}
}
}
// Format and return the cell text if available
if (cell)
return formatCellText(cell.get());
// Return empty string if cell is not found
return QString();
}
QString XLSXToMD::toMarkdown(const QString &xlsxFilePath)
{
// Load the Excel document
QXlsx::Document xlsx(xlsxFilePath);
if (!xlsx.load()) {
qCritical() << "Failed to load the Excel file:" << xlsxFilePath;
return QString();
}
QString markdown;
// Retrieve all sheet names
QStringList sheetNames = xlsx.sheetNames();
if (sheetNames.isEmpty()) {
qWarning() << "No sheets found in the Excel document.";
return QString();
}
// Iterate through each worksheet by name
for (const QString &sheetName : sheetNames) {
QXlsx::Worksheet* sheet = dynamic_cast<QXlsx::Worksheet*>(xlsx.sheet(sheetName));
if (!sheet) {
qWarning() << "Failed to load sheet:" << sheetName;
continue;
}
markdown += QString("## %1\n\n").arg(sheetName);
// Determine the used range
QXlsx::CellRange range = sheet->dimension();
int firstRow = range.firstRow();
int lastRow = range.lastRow();
int firstCol = range.firstColumn();
int lastCol = range.lastColumn();
if (firstRow > lastRow || firstCol > lastCol) {
qWarning() << "Sheet" << sheetName << "is empty.";
markdown += "*No data available.*\n\n";
continue;
}
// Assume the first row is the header
int headerRow = firstRow;
// Collect headers
QStringList headers;
for (int col = firstCol; col <= lastCol; ++col) {
QString header = getCellValue(sheet, headerRow, col);
headers << header;
}
// Create Markdown header row
QString headerRowMarkdown = "|" + headers.join("|") + "|";
markdown += headerRowMarkdown + "\n";
// Create Markdown separator row
QStringList separators;
for (int i = 0; i < headers.size(); ++i) {
separators << "---";
}
QString separatorRow = "|" + separators.join("|") + "|";
markdown += separatorRow + "\n";
// Iterate through data rows (starting from the row after header)
for (int row = headerRow + 1; row <= lastRow; ++row) {
QStringList rowData;
for (int col = firstCol; col <= lastCol; ++col) {
QString cellText = getCellValue(sheet, row, col);
rowData << cellText;
}
QString dataRow = "|" + rowData.join("|") + "|";
markdown += dataRow + "\n";
}
markdown += "\n"; // Add an empty line between sheets
}
return markdown;
}

View File

@ -0,0 +1,14 @@
#ifndef XLSXTOMD_H
#define XLSXTOMD_H
#include <QObject>
#include <QString>
#include <QtGlobal>
class XLSXToMD
{
public:
static QString toMarkdown(const QString &xlsxFilePath);
};
#endif // XLSXTOMD_H