From 22377c8f6a9c8b95076db7abc31e46bff9a7bb42 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:59:43 +0100 Subject: [PATCH] feat: Add temp file storage for chat (#279) * fix: Add signature to chat history * feat: Add file storage for chat --- ChatView/CMakeLists.txt | 1 + ChatView/ChatFileManager.cpp | 206 ++++++++++++++++++++++++ ChatView/ChatFileManager.hpp | 59 +++++++ ChatView/ChatRootView.cpp | 84 ++++++---- ChatView/ChatRootView.hpp | 2 + ChatView/qml/RootItem.qml | 1 + ChatView/qml/controls/SplitDropZone.qml | 144 ++++++++++++----- qodeassist.cpp | 5 + 8 files changed, 426 insertions(+), 76 deletions(-) create mode 100644 ChatView/ChatFileManager.cpp create mode 100644 ChatView/ChatFileManager.hpp diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index 4cb0c29..9c449fd 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -63,6 +63,7 @@ qt_add_qml_module(QodeAssistChatView ChatView.hpp ChatView.cpp ChatData.hpp FileItem.hpp FileItem.cpp + ChatFileManager.hpp ChatFileManager.cpp ) target_link_libraries(QodeAssistChatView diff --git a/ChatView/ChatFileManager.cpp b/ChatView/ChatFileManager.cpp new file mode 100644 index 0000000..b5fa907 --- /dev/null +++ b/ChatView/ChatFileManager.cpp @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2024-2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#include "ChatFileManager.hpp" +#include "Logger.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace QodeAssist::Chat { + +ChatFileManager::ChatFileManager(QObject *parent) + : QObject(parent) + , m_intermediateStorageDir(getIntermediateStorageDir()) +{} + +ChatFileManager::~ChatFileManager() = default; + +QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths) +{ + QStringList processedPaths; + processedPaths.reserve(filePaths.size()); + + for (const QString &filePath : filePaths) { + if (!isFileAccessible(filePath)) { + const QString error = tr("File is not accessible: %1").arg(filePath); + LOG_MESSAGE(error); + emit fileOperationFailed(error); + continue; + } + + QString copiedPath = copyToIntermediateStorage(filePath); + if (!copiedPath.isEmpty()) { + processedPaths.append(copiedPath); + emit fileCopiedToStorage(filePath, copiedPath); + LOG_MESSAGE(QString("File copied to storage: %1 -> %2").arg(filePath, copiedPath)); + } else { + const QString error = tr("Failed to copy file: %1").arg(filePath); + LOG_MESSAGE(error); + emit fileOperationFailed(error); + } + } + + return processedPaths; +} + +void ChatFileManager::setChatFilePath(const QString &chatFilePath) +{ + m_chatFilePath = chatFilePath; +} + +QString ChatFileManager::chatFilePath() const +{ + return m_chatFilePath; +} + +void ChatFileManager::clearIntermediateStorage() +{ + QDir dir(m_intermediateStorageDir); + if (!dir.exists()) { + return; + } + + const QFileInfoList files = dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot); + for (const QFileInfo &fileInfo : files) { + QFile file(fileInfo.absoluteFilePath()); + file.setPermissions(QFile::WriteUser | QFile::ReadUser); + if (file.remove()) { + LOG_MESSAGE(QString("Removed intermediate file: %1").arg(fileInfo.fileName())); + } else { + LOG_MESSAGE(QString("Failed to remove intermediate file: %1") + .arg(fileInfo.fileName())); + } + } +} + +bool ChatFileManager::isFileAccessible(const QString &filePath) +{ + QFileInfo fileInfo(filePath); + return fileInfo.exists() && fileInfo.isFile() && fileInfo.isReadable(); +} + +void ChatFileManager::cleanupGlobalIntermediateStorage() +{ + const QString basePath = Core::ICore::userResourcePath().toFSPathString(); + const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files"); + + QDir dir(intermediatePath); + if (!dir.exists()) { + return; + } + + const QFileInfoList files = dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot); + int removedCount = 0; + int failedCount = 0; + + for (const QFileInfo &fileInfo : files) { + QFile file(fileInfo.absoluteFilePath()); + file.setPermissions(QFile::WriteUser | QFile::ReadUser); + if (file.remove()) { + removedCount++; + } else { + failedCount++; + } + } + + if (removedCount > 0 || failedCount > 0) { + LOG_MESSAGE(QString("ChatFileManager global cleanup: removed=%1, failed=%2") + .arg(removedCount) + .arg(failedCount)); + } +} + +QString ChatFileManager::copyToIntermediateStorage(const QString &filePath) +{ + QFileInfo fileInfo(filePath); + if (!fileInfo.exists() || !fileInfo.isFile()) { + LOG_MESSAGE(QString("Source file does not exist or is not a file: %1").arg(filePath)); + return QString(); + } + + if (fileInfo.size() == 0) { + LOG_MESSAGE(QString("Source file is empty: %1").arg(filePath)); + } + + const QString newFileName = generateIntermediateFileName(filePath); + const QString destinationPath = QDir(m_intermediateStorageDir).filePath(newFileName); + + if (QFileInfo::exists(destinationPath)) { + QFile::remove(destinationPath); + } + + if (!QFile::copy(filePath, destinationPath)) { + LOG_MESSAGE(QString("Failed to copy file: %1 -> %2").arg(filePath, destinationPath)); + return QString(); + } + + QFile copiedFile(destinationPath); + if (!copiedFile.exists()) { + LOG_MESSAGE(QString("Copied file does not exist after copy: %1").arg(destinationPath)); + return QString(); + } + + copiedFile.setPermissions(QFile::ReadUser | QFile::WriteUser); + + return destinationPath; +} + +QString ChatFileManager::getIntermediateStorageDir() +{ + const QString basePath = Core::ICore::userResourcePath().toFSPathString(); + const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files"); + + QDir dir; + if (!dir.exists(intermediatePath) && !dir.mkpath(intermediatePath)) { + LOG_MESSAGE(QString("Failed to create intermediate storage directory: %1") + .arg(intermediatePath)); + } + + return intermediatePath; +} + +QString ChatFileManager::generateIntermediateFileName(const QString &originalPath) +{ + const QFileInfo fileInfo(originalPath); + const QString extension = fileInfo.suffix(); + QString baseName = fileInfo.completeBaseName().left(30); + + static const QRegularExpression specialChars("[^a-zA-Z0-9_-]"); + baseName.replace(specialChars, "_"); + + if (baseName.isEmpty()) { + baseName = "file"; + } + + const QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"); + const QString uuid = QUuid::createUuid().toString(QUuid::WithoutBraces).left(8); + + return QString("%1_%2_%3.%4").arg(baseName, timestamp, uuid, extension); +} + +} // namespace QodeAssist::Chat + diff --git a/ChatView/ChatFileManager.hpp b/ChatView/ChatFileManager.hpp new file mode 100644 index 0000000..80dec71 --- /dev/null +++ b/ChatView/ChatFileManager.hpp @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024-2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +namespace QodeAssist::Chat { + +class ChatFileManager : public QObject +{ + Q_OBJECT + +public: + explicit ChatFileManager(QObject *parent = nullptr); + ~ChatFileManager(); + + QStringList processDroppedFiles(const QStringList &filePaths); + void setChatFilePath(const QString &chatFilePath); + QString chatFilePath() const; + void clearIntermediateStorage(); + + static bool isFileAccessible(const QString &filePath); + static void cleanupGlobalIntermediateStorage(); + +signals: + void fileOperationFailed(const QString &error); + void fileCopiedToStorage(const QString &originalPath, const QString &newPath); + +private: + QString copyToIntermediateStorage(const QString &filePath); + QString getIntermediateStorageDir(); + QString generateIntermediateFileName(const QString &originalPath); + + QString m_chatFilePath; + QString m_intermediateStorageDir; +}; + +} // namespace QodeAssist::Chat + diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 22fa1a8..b182291 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -53,6 +54,7 @@ ChatRootView::ChatRootView(QQuickItem *parent) , m_chatModel(new ChatModel(this)) , m_promptProvider(LLMCore::PromptTemplateManager::instance()) , m_clientInterface(new ClientInterface(m_chatModel, &m_promptProvider, this)) + , m_fileManager(new ChatFileManager(this)) , m_isRequestInProgress(false) { m_isSyncOpenFiles = Settings::chatAssistantSettings().linkOpenFiles(); @@ -230,6 +232,11 @@ ChatRootView::ChatRootView(QQuickItem *parent) &Utils::BaseAspect::changed, this, &ChatRootView::isThinkingSupportChanged); + + connect(m_fileManager, &ChatFileManager::fileOperationFailed, this, [this](const QString &error) { + m_lastErrorMessage = error; + emit lastErrorMessageChanged(); + }); } ChatModel *ChatRootView::chatModel() const @@ -265,6 +272,8 @@ void ChatRootView::sendMessage(const QString &message) m_clientInterface ->sendMessage(message, m_attachmentFiles, m_linkedFiles, useTools(), useThinking()); + + m_fileManager->clearIntermediateStorage(); clearAttachmentFiles(); setRequestProgressStatus(true); } @@ -282,18 +291,23 @@ void ChatRootView::cancelRequest() void ChatRootView::clearAttachmentFiles() { - if (!m_attachmentFiles.isEmpty()) { - m_attachmentFiles.clear(); - emit attachmentFilesChanged(); + if (m_attachmentFiles.isEmpty()) { + return; } + + m_attachmentFiles.clear(); + emit attachmentFilesChanged(); + m_fileManager->clearIntermediateStorage(); } void ChatRootView::clearLinkedFiles() { - if (!m_linkedFiles.isEmpty()) { - m_linkedFiles.clear(); - emit linkedFilesChanged(); + if (m_linkedFiles.isEmpty()) { + return; } + + m_linkedFiles.clear(); + emit linkedFilesChanged(); } QString ChatRootView::getChatsHistoryDir() const @@ -304,8 +318,8 @@ QString ChatRootView::getChatsHistoryDir() const Settings::ProjectSettings projectSettings(project); path = projectSettings.chatHistoryPath().toFSPathString(); } else { - path = QString("%1/qodeassist/chat_history") - .arg(Core::ICore::userResourcePath().toFSPathString()); + QDir baseDir(Core::ICore::userResourcePath().toFSPathString()); + path = baseDir.filePath("qodeassist/chat_history"); } QDir dir(path); @@ -342,6 +356,12 @@ void ChatRootView::loadHistory(const QString &filePath) setRecentFilePath(filePath); } + m_fileManager->clearIntermediateStorage(); + m_attachmentFiles.clear(); + m_linkedFiles.clear(); + emit attachmentFilesChanged(); + emit linkedFilesChanged(); + m_currentMessageRequestId.clear(); updateInputTokensCount(); updateCurrentMessageEditsStats(); @@ -499,8 +519,10 @@ void ChatRootView::addFilesToAttachList(const QStringList &filePaths) return; } + const QStringList processedPaths = m_fileManager->processDroppedFiles(filePaths); + bool filesAdded = false; - for (const QString &filePath : filePaths) { + for (const QString &filePath : processedPaths) { if (!m_attachmentFiles.contains(filePath)) { m_attachmentFiles.append(filePath); filesAdded = true; @@ -514,10 +536,15 @@ void ChatRootView::addFilesToAttachList(const QStringList &filePaths) void ChatRootView::removeFileFromAttachList(int index) { - if (index >= 0 && index < m_attachmentFiles.size()) { - m_attachmentFiles.removeAt(index); - emit attachmentFilesChanged(); + if (index < 0 || index >= m_attachmentFiles.size()) { + return; } + + const QString removedFile = m_attachmentFiles.at(index); + m_attachmentFiles.removeAt(index); + emit attachmentFilesChanged(); + + LOG_MESSAGE(QString("Removed attachment file: %1").arg(removedFile)); } void ChatRootView::showLinkFilesDialog() @@ -557,7 +584,6 @@ void ChatRootView::addFilesToLinkList(const QStringList &filePaths) if (!imageFiles.isEmpty()) { addFilesToAttachList(imageFiles); - m_lastInfoMessage = tr("Images automatically moved to Attach zone (%n file(s))", "", imageFiles.size()); emit lastInfoMessageChanged(); @@ -570,10 +596,15 @@ void ChatRootView::addFilesToLinkList(const QStringList &filePaths) void ChatRootView::removeFileFromLinkList(int index) { - if (index >= 0 && index < m_linkedFiles.size()) { - m_linkedFiles.removeAt(index); - emit linkedFilesChanged(); + if (index < 0 || index >= m_linkedFiles.size()) { + return; } + + const QString removedFile = m_linkedFiles.at(index); + m_linkedFiles.removeAt(index); + emit linkedFilesChanged(); + + LOG_MESSAGE(QString("Removed linked file: %1").arg(removedFile)); } void ChatRootView::showAddImageDialog() @@ -587,19 +618,7 @@ void ChatRootView::showAddImageDialog() } if (dialog.exec() == QDialog::Accepted) { - QStringList newFilePaths = dialog.selectedFiles(); - if (!newFilePaths.isEmpty()) { - bool filesAdded = false; - for (const QString &filePath : std::as_const(newFilePaths)) { - if (!m_attachmentFiles.contains(filePath)) { - m_attachmentFiles.append(filePath); - filesAdded = true; - } - } - if (filesAdded) { - emit attachmentFilesChanged(); - } - } + addFilesToAttachList(dialog.selectedFiles()); } } @@ -645,8 +664,8 @@ void ChatRootView::openChatHistoryFolder() Settings::ProjectSettings projectSettings(project); path = projectSettings.chatHistoryPath().toFSPathString(); } else { - path = QString("%1/qodeassist/chat_history") - .arg(Core::ICore::userResourcePath().toFSPathString()); + QDir baseDir(Core::ICore::userResourcePath().toFSPathString()); + path = baseDir.filePath("qodeassist/chat_history"); } QDir dir(path); @@ -666,7 +685,7 @@ void ChatRootView::openRulesFolder() } QString projectPath = project->projectDirectory().toFSPathString(); - QString rulesPath = projectPath + "/.qodeassist/rules"; + QString rulesPath = QDir(projectPath).filePath(".qodeassist/rules"); QDir dir(rulesPath); if (!dir.exists()) { @@ -762,6 +781,7 @@ void ChatRootView::setRecentFilePath(const QString &filePath) if (m_recentFilePath != filePath) { m_recentFilePath = filePath; m_clientInterface->setChatFilePath(filePath); + m_fileManager->setChatFilePath(filePath); emit chatFileNameChanged(); } } diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index 5e011e5..c7eb69f 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -23,6 +23,7 @@ #include "ChatModel.hpp" #include "ClientInterface.hpp" +#include "ChatFileManager.hpp" #include "llmcore/PromptProviderChat.hpp" #include @@ -199,6 +200,7 @@ private: ChatModel *m_chatModel; LLMCore::PromptProviderChat m_promptProvider; ClientInterface *m_clientInterface; + ChatFileManager *m_fileManager; QString m_currentTemplate; QString m_recentFilePath; QStringList m_attachmentFiles; diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 5382380..08d8049 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -60,6 +60,7 @@ ChatRootView { SplitDropZone { anchors.fill: parent + z: 99 onFilesDroppedToAttach: (urlStrings) => { var localPaths = root.convertUrlsToLocalPaths(urlStrings) diff --git a/ChatView/qml/controls/SplitDropZone.qml b/ChatView/qml/controls/SplitDropZone.qml index 8b9260a..bdd7592 100644 --- a/ChatView/qml/controls/SplitDropZone.qml +++ b/ChatView/qml/controls/SplitDropZone.qml @@ -23,10 +23,12 @@ import QtQuick.Controls Item { id: root - signal filesDroppedToAttach(var urlStrings) // Array of URL strings (file://...) - signal filesDroppedToLink(var urlStrings) // Array of URL strings (file://...) + signal filesDroppedToAttach(var urlStrings) + signal filesDroppedToLink(var urlStrings) property string activeZone: "" + property int filesCount: 0 + property bool isDragActive: false Item { id: splitDropOverlay @@ -34,12 +36,39 @@ Item { anchors.fill: parent visible: false z: 999 + opacity: 0 + + Behavior on opacity { + NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } + } Rectangle { anchors.fill: parent color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6) } + Rectangle { + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + topMargin: 30 + } + width: fileCountText.width + 40 + height: 50 + color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.9) + radius: 25 + visible: root.filesCount > 0 + + Text { + id: fileCountText + anchors.centerIn: parent + text: qsTr("%n file(s) to drop", "", root.filesCount) + font.pixelSize: 16 + font.bold: true + color: palette.highlightedText + } + } + Rectangle { id: leftZone @@ -76,19 +105,20 @@ Item { color: root.activeZone === "left" ? palette.highlightedText : palette.text opacity: 0.8 } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("(for one-time use)") + font.pixelSize: 12 + font.italic: true + color: root.activeZone === "left" ? palette.highlightedText : palette.text + opacity: 0.6 + } } - Behavior on color { - ColorAnimation { duration: 150 } - } - - Behavior on border.width { - NumberAnimation { duration: 150 } - } - - Behavior on border.color { - ColorAnimation { duration: 150 } - } + Behavior on color { ColorAnimation { duration: 150 } } + Behavior on border.width { NumberAnimation { duration: 150 } } + Behavior on border.color { ColorAnimation { duration: 150 } } } Rectangle { @@ -127,19 +157,20 @@ Item { color: root.activeZone === "right" ? palette.highlightedText : palette.text opacity: 0.8 } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("(added to context)") + font.pixelSize: 12 + font.italic: true + color: root.activeZone === "right" ? palette.highlightedText : palette.text + opacity: 0.6 + } } - Behavior on color { - ColorAnimation { duration: 150 } - } - - Behavior on border.width { - NumberAnimation { duration: 150 } - } - - Behavior on border.color { - ColorAnimation { duration: 150 } - } + Behavior on color { ColorAnimation { duration: 150 } } + Behavior on border.width { NumberAnimation { duration: 150 } } + Behavior on border.color { ColorAnimation { duration: 150 } } } Rectangle { @@ -193,42 +224,67 @@ Item { onEntered: (drag) => { if (drag.hasUrls) { + root.isDragActive = true + root.filesCount = drag.urls.length splitDropOverlay.visible = true + splitDropOverlay.opacity = 1 root.activeZone = "" } } onExited: { - splitDropOverlay.visible = false - root.activeZone = "" + root.isDragActive = false + root.filesCount = 0 + splitDropOverlay.opacity = 0 + + Qt.callLater(function() { + if (!root.isDragActive) { + splitDropOverlay.visible = false + root.activeZone = "" + } + }) } onPositionChanged: (drag) => { - if (drag.x < globalDropArea.width / 2) { - root.activeZone = "left" - } else { - root.activeZone = "right" + if (drag.hasUrls) { + root.activeZone = drag.x < globalDropArea.width / 2 ? "left" : "right" } } onDropped: (drop) => { - var targetZone = root.activeZone - splitDropOverlay.visible = false - root.activeZone = "" + const targetZone = root.activeZone + root.isDragActive = false + root.filesCount = 0 + splitDropOverlay.opacity = 0 - if (drop.hasUrls && drop.urls.length > 0) { - // Convert URLs to array of strings for C++ processing - var urlStrings = [] - for (var i = 0; i < drop.urls.length; i++) { - urlStrings.push(drop.urls[i].toString()) - } - - if (targetZone === "right") { - root.filesDroppedToLink(urlStrings) - } else { - root.filesDroppedToAttach(urlStrings) + Qt.callLater(function() { + splitDropOverlay.visible = false + root.activeZone = "" + }) + + if (!drop.hasUrls || drop.urls.length === 0) { + return + } + + var urlStrings = [] + for (var i = 0; i < drop.urls.length; i++) { + var urlString = drop.urls[i].toString() + if (urlString.startsWith("file://") || urlString.indexOf("://") === -1) { + urlStrings.push(urlString) } } + + if (urlStrings.length === 0) { + return + } + + drop.accept(Qt.CopyAction) + + if (targetZone === "right") { + root.filesDroppedToLink(urlStrings) + } else { + root.filesDroppedToAttach(urlStrings) + } } } } diff --git a/qodeassist.cpp b/qodeassist.cpp index 9e0d74a..1499ed3 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -62,6 +62,7 @@ #include "widgets/CustomInstructionsManager.hpp" #include "widgets/QuickRefactorDialog.hpp" #include +#include #include #include #include @@ -87,6 +88,8 @@ public: ~QodeAssistPlugin() final { + Chat::ChatFileManager::cleanupGlobalIntermediateStorage(); + delete m_qodeAssistClient; if (m_chatOutputPane) { delete m_chatOutputPane; @@ -249,6 +252,8 @@ public: editorContextMenu->addAction(closeChatViewAction.command(), Core::Constants::G_DEFAULT_THREE); } + + Chat::ChatFileManager::cleanupGlobalIntermediateStorage(); } void extensionsInitialized() final {}