feat: Add temp file storage for chat (#279)

* fix: Add signature to chat history
* feat: Add file storage for chat
This commit is contained in:
Petr Mironychev
2025-11-28 13:59:43 +01:00
committed by GitHub
parent 595895840a
commit 22377c8f6a
8 changed files with 426 additions and 76 deletions

View File

@ -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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
#include "ChatFileManager.hpp"
#include "Logger.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
#include <QUuid>
#include <QDateTime>
#include <QRegularExpression>
#include <coreplugin/icore.h>
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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include <QMap>
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

View File

@ -22,6 +22,7 @@
#include <QClipboard>
#include <QDesktopServices>
#include <QFileDialog>
#include <QFileInfo>
#include <QMessageBox>
#include <coreplugin/editormanager/editormanager.h>
@ -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();
}
}

View File

@ -23,6 +23,7 @@
#include "ChatModel.hpp"
#include "ClientInterface.hpp"
#include "ChatFileManager.hpp"
#include "llmcore/PromptProviderChat.hpp"
#include <coreplugin/editormanager/editormanager.h>
@ -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;

View File

@ -60,6 +60,7 @@ ChatRootView {
SplitDropZone {
anchors.fill: parent
z: 99
onFilesDroppedToAttach: (urlStrings) => {
var localPaths = root.convertUrlsToLocalPaths(urlStrings)

View File

@ -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)
}
}
}
}

View File

@ -62,6 +62,7 @@
#include "widgets/CustomInstructionsManager.hpp"
#include "widgets/QuickRefactorDialog.hpp"
#include <ChatView/ChatView.hpp>
#include <ChatView/ChatFileManager.hpp>
#include <coreplugin/actionmanager/actioncontainer.h>
#include <coreplugin/actionmanager/actionmanager.h>
#include <texteditor/textdocument.h>
@ -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 {}