refactor: Reworking attaching files (#280)

chore: Upgrade chat format version
This commit is contained in:
Petr Mironychev
2025-11-28 16:17:25 +01:00
committed by GitHub
parent 22377c8f6a
commit 2d5667d8ca
8 changed files with 264 additions and 70 deletions

View File

@ -78,11 +78,26 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
return message.content;
}
case Roles::Attachments: {
QStringList filenames;
QVariantList attachmentsList;
for (const auto &attachment : message.attachments) {
filenames << attachment.filename;
QVariantMap attachmentMap;
attachmentMap["fileName"] = attachment.filename;
attachmentMap["storedPath"] = attachment.content;
if (!m_chatFilePath.isEmpty()) {
QFileInfo fileInfo(m_chatFilePath);
QString baseName = fileInfo.completeBaseName();
QString dirPath = fileInfo.absolutePath();
QString contentFolder = QDir(dirPath).filePath(baseName + "_content");
QString fullPath = QDir(contentFolder).filePath(attachment.content);
attachmentMap["filePath"] = fullPath;
} else {
attachmentMap["filePath"] = QString();
}
attachmentsList.append(attachmentMap);
}
return filenames;
return attachmentsList;
}
case Roles::IsRedacted: {
return message.isRedacted;
@ -99,8 +114,8 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
QFileInfo fileInfo(m_chatFilePath);
QString baseName = fileInfo.completeBaseName();
QString dirPath = fileInfo.absolutePath();
QString imagesFolder = QDir(dirPath).filePath(baseName + "_images");
QString fullPath = QDir(imagesFolder).filePath(image.storedPath);
QString contentFolder = QDir(dirPath).filePath(baseName + "_content");
QString fullPath = QDir(contentFolder).filePath(image.storedPath);
imageMap["imageUrl"] = QUrl::fromLocalFile(fullPath).toString();
} else {
imageMap["imageUrl"] = QString();
@ -135,15 +150,6 @@ void ChatModel::addMessage(
bool isRedacted,
const QString &signature)
{
QString fullContent = content;
if (!attachments.isEmpty()) {
fullContent += "\n\nAttached files list:";
for (const auto &attachment : attachments) {
fullContent += QString("\nname: %1\nfile content:\n%2")
.arg(attachment.filename, attachment.content);
}
}
if (!m_messages.isEmpty() && !id.isEmpty() && m_messages.last().id == id
&& m_messages.last().role == role) {
Message &lastMessage = m_messages.last();

View File

@ -30,7 +30,7 @@
namespace QodeAssist::Chat {
const QString ChatSerializer::VERSION = "0.1";
const QString ChatSerializer::VERSION = "0.2";
SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QString &filePath)
{
@ -38,11 +38,11 @@ SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QSt
return {false, "Failed to create directory structure"};
}
QString imagesFolder = getChatImagesFolder(filePath);
QString contentFolder = getChatContentFolder(filePath);
QDir dir;
if (!dir.exists(imagesFolder)) {
if (!dir.mkpath(imagesFolder)) {
LOG_MESSAGE(QString("Warning: Failed to create images folder: %1").arg(imagesFolder));
if (!dir.exists(contentFolder)) {
if (!dir.mkpath(contentFolder)) {
LOG_MESSAGE(QString("Warning: Failed to create content folder: %1").arg(contentFolder));
}
}
@ -103,6 +103,17 @@ QJsonObject ChatSerializer::serializeMessage(const ChatModel::Message &message,
messageObj["signature"] = message.signature;
}
if (!message.attachments.isEmpty()) {
QJsonArray attachmentsArray;
for (const auto &attachment : message.attachments) {
QJsonObject attachmentObj;
attachmentObj["fileName"] = attachment.filename;
attachmentObj["storedPath"] = attachment.content;
attachmentsArray.append(attachmentObj);
}
messageObj["attachments"] = attachmentsArray;
}
if (!message.images.isEmpty()) {
QJsonArray imagesArray;
for (const auto &image : message.images) {
@ -127,6 +138,17 @@ ChatModel::Message ChatSerializer::deserializeMessage(const QJsonObject &json, c
message.isRedacted = json["isRedacted"].toBool(false);
message.signature = json["signature"].toString();
if (json.contains("attachments")) {
QJsonArray attachmentsArray = json["attachments"].toArray();
for (const auto &attachmentValue : attachmentsArray) {
QJsonObject attachmentObj = attachmentValue.toObject();
Context::ContentFile attachment;
attachment.filename = attachmentObj["fileName"].toString();
attachment.content = attachmentObj["storedPath"].toString();
message.attachments.append(attachment);
}
}
if (json.contains("images")) {
QJsonArray imagesArray = json["images"].toArray();
for (const auto &imageValue : imagesArray) {
@ -192,27 +214,36 @@ bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
bool ChatSerializer::validateVersion(const QString &version)
{
return version == VERSION;
if (version == VERSION) {
return true;
}
if (version == "0.1") {
LOG_MESSAGE("Loading chat from old format 0.1 - images folder structure has changed from _images to _content");
return true;
}
return false;
}
QString ChatSerializer::getChatImagesFolder(const QString &chatFilePath)
QString ChatSerializer::getChatContentFolder(const QString &chatFilePath)
{
QFileInfo fileInfo(chatFilePath);
QString baseName = fileInfo.completeBaseName();
QString dirPath = fileInfo.absolutePath();
return QDir(dirPath).filePath(baseName + "_images");
return QDir(dirPath).filePath(baseName + "_content");
}
bool ChatSerializer::saveImageToStorage(const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath)
bool ChatSerializer::saveContentToStorage(const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath)
{
QString imagesFolder = getChatImagesFolder(chatFilePath);
QString contentFolder = getChatContentFolder(chatFilePath);
QDir dir;
if (!dir.exists(imagesFolder)) {
if (!dir.mkpath(imagesFolder)) {
LOG_MESSAGE(QString("Failed to create images folder: %1").arg(imagesFolder));
if (!dir.exists(contentFolder)) {
if (!dir.mkpath(contentFolder)) {
LOG_MESSAGE(QString("Failed to create content folder: %1").arg(contentFolder));
return false;
}
}
@ -225,43 +256,43 @@ bool ChatSerializer::saveImageToStorage(const QString &chatFilePath,
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8))
.arg(extension);
QString fullPath = QDir(imagesFolder).filePath(uniqueName);
QString fullPath = QDir(contentFolder).filePath(uniqueName);
QByteArray imageData = QByteArray::fromBase64(base64Data.toUtf8());
QByteArray contentData = QByteArray::fromBase64(base64Data.toUtf8());
QFile file(fullPath);
if (!file.open(QIODevice::WriteOnly)) {
LOG_MESSAGE(QString("Failed to open file for writing: %1").arg(fullPath));
return false;
}
if (file.write(imageData) == -1) {
LOG_MESSAGE(QString("Failed to write image data: %1").arg(file.errorString()));
if (file.write(contentData) == -1) {
LOG_MESSAGE(QString("Failed to write content data: %1").arg(file.errorString()));
return false;
}
file.close();
storedPath = uniqueName;
LOG_MESSAGE(QString("Saved image: %1 to %2").arg(fileName, fullPath));
LOG_MESSAGE(QString("Saved content: %1 to %2").arg(fileName, fullPath));
return true;
}
QString ChatSerializer::loadImageFromStorage(const QString &chatFilePath, const QString &storedPath)
QString ChatSerializer::loadContentFromStorage(const QString &chatFilePath, const QString &storedPath)
{
QString imagesFolder = getChatImagesFolder(chatFilePath);
QString fullPath = QDir(imagesFolder).filePath(storedPath);
QString contentFolder = getChatContentFolder(chatFilePath);
QString fullPath = QDir(contentFolder).filePath(storedPath);
QFile file(fullPath);
if (!file.open(QIODevice::ReadOnly)) {
LOG_MESSAGE(QString("Failed to open image file: %1").arg(fullPath));
LOG_MESSAGE(QString("Failed to open content file: %1").arg(fullPath));
return QString();
}
QByteArray imageData = file.readAll();
QByteArray contentData = file.readAll();
file.close();
return imageData.toBase64();
return contentData.toBase64();
}
} // namespace QodeAssist::Chat

View File

@ -45,13 +45,13 @@ public:
static QJsonObject serializeChat(const ChatModel *model, const QString &chatFilePath);
static bool deserializeChat(ChatModel *model, const QJsonObject &json, const QString &chatFilePath);
// Image management
static QString getChatImagesFolder(const QString &chatFilePath);
static bool saveImageToStorage(const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath);
static QString loadImageFromStorage(const QString &chatFilePath, const QString &storedPath);
// Content management (images and text files)
static QString getChatContentFolder(const QString &chatFilePath);
static bool saveContentToStorage(const QString &chatFilePath,
const QString &fileName,
const QString &base64Data,
QString &storedPath);
static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath);
private:
static const QString VERSION;

View File

@ -88,7 +88,24 @@ void ClientInterface::sendMessage(
}
}
auto attachFiles = m_contextManager->getContentFiles(textFiles);
QList<Context::ContentFile> storedAttachments;
if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
auto attachFiles = m_contextManager->getContentFiles(textFiles);
for (const auto &file : attachFiles) {
QString storedPath;
if (ChatSerializer::saveContentToStorage(
m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) {
Context::ContentFile storedFile;
storedFile.filename = file.filename;
storedFile.content = storedPath;
storedAttachments.append(storedFile);
LOG_MESSAGE(QString("Stored text file %1 as %2").arg(file.filename, storedPath));
}
}
} else if (!textFiles.isEmpty()) {
LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 text file(s)")
.arg(textFiles.size()));
}
QList<ChatModel::ImageAttachment> imageAttachments;
if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
@ -100,7 +117,7 @@ void ClientInterface::sendMessage(
QString storedPath;
QFileInfo fileInfo(imagePath);
if (ChatSerializer::saveImageToStorage(
if (ChatSerializer::saveContentToStorage(
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
ChatModel::ImageAttachment imageAttachment;
imageAttachment.fileName = fileInfo.fileName();
@ -116,7 +133,7 @@ void ClientInterface::sendMessage(
.arg(imageFiles.size()));
}
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", attachFiles, imageAttachments);
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", storedAttachments, imageAttachments);
auto &chatAssistantSettings = Settings::chatAssistantSettings();
@ -182,6 +199,19 @@ void ClientInterface::sendMessage(
LLMCore::Message apiMessage;
apiMessage.role = msg.role == ChatModel::ChatRole::User ? "user" : "assistant";
apiMessage.content = msg.content;
if (!msg.attachments.isEmpty() && !m_chatFilePath.isEmpty()) {
apiMessage.content += "\n\nAttached files:";
for (const auto &attachment : msg.attachments) {
QString fileContent = ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.content);
if (!fileContent.isEmpty()) {
QString decodedContent = QString::fromUtf8(QByteArray::fromBase64(fileContent.toUtf8()));
apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```")
.arg(attachment.filename, decodedContent);
}
}
}
apiMessage.isThinking = (msg.role == ChatModel::ChatRole::Thinking);
apiMessage.isRedacted = msg.isRedacted;
apiMessage.signature = msg.signature;
@ -499,7 +529,7 @@ QVector<LLMCore::ImageAttachment> ClientInterface::loadImagesFromStorage(
for (const auto &storedImage : storedImages) {
QString base64Data
= ChatSerializer::loadImageFromStorage(m_chatFilePath, storedImage.storedPath);
= ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
if (base64Data.isEmpty()) {
LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath));
continue;

View File

@ -121,24 +121,11 @@ Rectangle {
Repeater {
id: attachmentsModel
delegate: Rectangle {
delegate: AttachmentComponent {
required property int index
required property var modelData
height: attachText.implicitHeight + 8
width: attachText.implicitWidth + 16
radius: 4
color: palette.button
border.width: 1
border.color: palette.mid
Text {
id: attachText
anchors.centerIn: parent
text: modelData
color: palette.text
}
itemData: modelData
}
}
}
@ -239,6 +226,68 @@ Rectangle {
codeFontSize: root.codeFontSize
}
component AttachmentComponent : Rectangle {
required property var itemData
height: attachFileText.implicitHeight + 8
width: attachFileText.implicitWidth + 16
radius: 4
color: attachFileMouseArea.containsMouse ? Qt.lighter(palette.button, 1.1) : palette.button
border.width: 1
border.color: palette.mid
Behavior on color { ColorAnimation { duration: 100 } }
FileItem {
id: fileItem
filePath: itemData.filePath || ""
}
Text {
id: attachFileText
anchors.centerIn: parent
text: (itemData.fileName || "")
color: palette.buttonText
font.pointSize: root.textFontSize - 1
}
MouseArea {
id: attachFileMouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (mouse.button === Qt.LeftButton) {
fileItem.openFileInEditor()
} else if (mouse.button === Qt.RightButton) {
attachmentContextMenu.popup()
}
}
ToolTip.visible: containsMouse
ToolTip.text: qsTr("Left click: Open in Qt Creator\nRight click: More options")
ToolTip.delay: 500
}
Menu {
id: attachmentContextMenu
MenuItem {
text: qsTr("Open in Qt Creator")
onTriggered: fileItem.openFileInEditor()
}
MenuItem {
text: qsTr("Open in System Editor")
onTriggered: fileItem.openFileInExternalEditor()
}
}
}
component ImageComponent : Rectangle {
required property var itemData
@ -248,10 +297,17 @@ Rectangle {
width: Math.min(imageDisplay.implicitWidth, maxImageWidth) + 16
height: imageDisplay.implicitHeight + fileNameText.implicitHeight + 16
radius: 4
color: palette.base
color: imageMouseArea.containsMouse ? Qt.lighter(palette.base, 1.05) : palette.base
border.width: 1
border.color: palette.mid
Behavior on color { ColorAnimation { duration: 100 } }
FileItem {
id: imageFileItem
filePath: itemData.imageUrl ? itemData.imageUrl.toString().replace("file://", "") : ""
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
@ -259,6 +315,7 @@ Rectangle {
Image {
id: imageDisplay
Layout.alignment: Qt.AlignHCenter
Layout.maximumWidth: parent.parent.maxImageWidth
Layout.maximumHeight: parent.parent.maxImageHeight
@ -289,6 +346,7 @@ Rectangle {
Text {
id: fileNameText
Layout.fillWidth: true
text: itemData.fileName || ""
color: palette.text
@ -297,5 +355,40 @@ Rectangle {
horizontalAlignment: Text.AlignHCenter
}
}
MouseArea {
id: imageMouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (mouse.button === Qt.LeftButton) {
imageFileItem.openFileInEditor()
} else if (mouse.button === Qt.RightButton) {
imageContextMenu.popup()
}
}
ToolTip.visible: containsMouse
ToolTip.text: qsTr("Left click: Open in System\nRight click: More options")
ToolTip.delay: 500
}
Menu {
id: imageContextMenu
MenuItem {
text: qsTr("Open in Qt Creator")
onTriggered: imageFileItem.openFileInEditor()
}
MenuItem {
text: qsTr("Open in System Viewer")
onTriggered: imageFileItem.openFileInExternalEditor()
}
}
}
}

View File

@ -43,11 +43,17 @@ ContextManager::ContextManager(QObject *parent)
QString ContextManager::readFile(const QString &filePath) const
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
LOG_MESSAGE(QString("Failed to open file for reading: %1 - %2")
.arg(filePath, file.errorString()));
return QString();
}
QTextStream in(&file);
return in.readAll();
QString content = in.readAll();
file.close();
return content;
}
QList<ContentFile> ContextManager::getContentFiles(const QStringList &filePaths) const

View File

@ -33,6 +33,31 @@ If issues persist, you can reset settings to their default values:
- API keys are preserved during reset
- You will need to re-select your model after reset
## Chat History Migration
### Images not showing in old chats (version 0.5.x → 0.6.x)
If you have chat histories from QodeAssist version 0.5.x or earlier, images may not display correctly due to a storage structure change.
**Solution:** Rename the content folder for each affected chat:
```bash
# Navigate to your chat history folder
cd ~/path/to/chat_history
# For each chat file, rename its folder
mv chat_name_images chat_name_content
```
**Example:**
```bash
mv my_conversation_2024-11-28_images my_conversation_2024-11-28_content
```
**What changed:**
- Old format (v0.1): Stored files in `chat_name_images/`
- New format (v0.2): Stores all content in `chat_name_content/` (both images and text files)
## Common Issues
### Plugin doesn't appear after installation

View File

@ -155,6 +155,9 @@ void QuickRefactorDialog::setupUi()
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
mainLayout->addWidget(buttonBox);
setTabOrder(m_commandsComboBox, m_textEdit);
setTabOrder(m_textEdit, buttonBox);
}
void QuickRefactorDialog::createActionButtons()