Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0af983bda | |||
| 4bd96e0718 | |||
| 7b0d3c2abb | |||
| 75d1551b00 | |||
| 406ba05bfb | |||
| 7a97d0aba5 | |||
| b19c4c0c0c | |||
| a466332822 | |||
| e1fa01d123 | |||
| 37e41d3b76 | |||
| 2d5667d8ca | |||
| 22377c8f6a | |||
| 595895840a | |||
| f6d647d5c8 | |||
| 1f9c60ffb2 | |||
| 6ec4a61c0c | |||
| 7feb088de3 | |||
| 627a821115 | |||
| 9b0ae98f02 | |||
| 85a7bba90e | |||
| b18ef4c400 | |||
| bbacdfc22a | |||
| 670f81c3dd | |||
| b4f31dee23 | |||
| dc6ec4fb4f | |||
| 07de415346 | |||
| a15f64a234 | |||
| 0feaa3a0f7 | |||
| a3527e1442 | |||
| 24565dc81f | |||
| 90655cded4 | |||
| 1e3b1997cc | |||
| 6f7d8a0987 | |||
| 55b6080273 | |||
| ce9e2717d6 | |||
| ef73895823 | |||
| bcdec96d92 | |||
| 86c6930c5f | |||
| f3aa706227 | |||
| 944e7fb00a | |||
| 296a0ff7b8 | |||
| 06bd7db7ea | |||
| 43f9e4e75b | |||
| 204cffd7d0 | |||
| 995597d789 | |||
| 9974b2f5e6 | |||
| 31e3d9db7c | |||
| 6f680e3974 | |||
| 953774aaa8 | |||
| 9ecd285d1d |
2
.github/workflows/build_cmake.yml
vendored
@ -165,7 +165,7 @@ jobs:
|
||||
execute_process(COMMAND ${CMAKE_COMMAND} -E tar xvf ../${archive} WORKING_DIRECTORY qt6)
|
||||
endfunction()
|
||||
|
||||
foreach(package qtbase qtdeclarative qttools)
|
||||
foreach(package qtbase qtdeclarative qttools qtsvg)
|
||||
downloadAndExtract(
|
||||
"${qt_base_url}/qt.qt6.${qt_version_dotless}.${qt_package_arch_suffix}/${qt_package_version}${package}${qt_package_suffix}.7z"
|
||||
${package}.7z
|
||||
|
||||
@ -11,7 +11,7 @@ set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
find_package(QtCreator REQUIRED COMPONENTS Core)
|
||||
find_package(Qt6 COMPONENTS Core Gui Quick Widgets Network Test LinguistTools REQUIRED)
|
||||
find_package(Qt6 COMPONENTS Core Gui Quick Widgets Network Svg Test LinguistTools REQUIRED)
|
||||
find_package(GTest)
|
||||
|
||||
qt_standard_project_setup(I18N_TRANSLATED_LANGUAGES en)
|
||||
@ -57,6 +57,7 @@ add_qtc_plugin(QodeAssist
|
||||
Qt::Quick
|
||||
Qt::Widgets
|
||||
Qt::Network
|
||||
Qt::Svg
|
||||
QtCreator::ExtensionSystem
|
||||
QtCreator::Utils
|
||||
QtCreator::CPlusPlus
|
||||
@ -69,6 +70,7 @@ add_qtc_plugin(QodeAssist
|
||||
QodeAssistConstants.hpp
|
||||
QodeAssisttr.h
|
||||
LLMClientInterface.hpp LLMClientInterface.cpp
|
||||
RefactorContextHelper.hpp
|
||||
templates/Templates.hpp
|
||||
templates/CodeLlamaFim.hpp
|
||||
templates/Ollama.hpp
|
||||
@ -88,6 +90,7 @@ add_qtc_plugin(QodeAssist
|
||||
templates/GoogleAI.hpp
|
||||
templates/LlamaCppFim.hpp
|
||||
templates/Qwen3CoderFIM.hpp
|
||||
templates/OpenAIResponses.hpp
|
||||
providers/Providers.hpp
|
||||
providers/OllamaProvider.hpp providers/OllamaProvider.cpp
|
||||
providers/ClaudeProvider.hpp providers/ClaudeProvider.cpp
|
||||
@ -99,9 +102,22 @@ add_qtc_plugin(QodeAssist
|
||||
providers/GoogleAIProvider.hpp providers/GoogleAIProvider.cpp
|
||||
providers/LlamaCppProvider.hpp providers/LlamaCppProvider.cpp
|
||||
providers/CodestralProvider.hpp providers/CodestralProvider.cpp
|
||||
providers/OpenAIResponses/ModelRequest.hpp
|
||||
providers/OpenAIResponses/ResponseObject.hpp
|
||||
providers/OpenAIResponses/GetResponseRequest.hpp
|
||||
providers/OpenAIResponses/DeleteResponseRequest.hpp
|
||||
providers/OpenAIResponses/CancelResponseRequest.hpp
|
||||
providers/OpenAIResponses/ListInputItemsRequest.hpp
|
||||
providers/OpenAIResponses/InputTokensRequest.hpp
|
||||
providers/OpenAIResponses/ItemTypesReference.hpp
|
||||
providers/OpenAIResponsesRequestBuilder.hpp
|
||||
providers/OpenAIResponsesProvider.hpp providers/OpenAIResponsesProvider.cpp
|
||||
providers/OpenAIResponsesMessage.hpp providers/OpenAIResponsesMessage.cpp
|
||||
QodeAssist.qrc
|
||||
LSPCompletion.hpp
|
||||
LLMSuggestion.hpp LLMSuggestion.cpp
|
||||
RefactorSuggestion.hpp RefactorSuggestion.cpp
|
||||
RefactorSuggestionHoverHandler.hpp RefactorSuggestionHoverHandler.cpp
|
||||
QodeAssistClient.hpp QodeAssistClient.cpp
|
||||
chat/ChatOutputPane.h chat/ChatOutputPane.cpp
|
||||
chat/NavigationPanel.hpp chat/NavigationPanel.cpp
|
||||
@ -109,10 +125,21 @@ add_qtc_plugin(QodeAssist
|
||||
CodeHandler.hpp CodeHandler.cpp
|
||||
UpdateStatusWidget.hpp UpdateStatusWidget.cpp
|
||||
widgets/CompletionProgressHandler.hpp widgets/CompletionProgressHandler.cpp
|
||||
widgets/CompletionErrorHandler.hpp widgets/CompletionErrorHandler.cpp
|
||||
widgets/CompletionHintWidget.hpp widgets/CompletionHintWidget.cpp
|
||||
widgets/CompletionHintHandler.hpp widgets/CompletionHintHandler.cpp
|
||||
widgets/ProgressWidget.hpp widgets/ProgressWidget.cpp
|
||||
widgets/ErrorWidget.hpp widgets/ErrorWidget.cpp
|
||||
widgets/EditorChatButton.hpp widgets/EditorChatButton.cpp
|
||||
widgets/EditorChatButtonHandler.hpp widgets/EditorChatButtonHandler.cpp
|
||||
widgets/QuickRefactorDialog.hpp widgets/QuickRefactorDialog.cpp
|
||||
widgets/CustomInstructionsManager.hpp widgets/CustomInstructionsManager.cpp
|
||||
widgets/AddCustomInstructionDialog.hpp widgets/AddCustomInstructionDialog.cpp
|
||||
widgets/RefactorWidget.hpp widgets/RefactorWidget.cpp
|
||||
widgets/RefactorWidgetHandler.hpp widgets/RefactorWidgetHandler.cpp
|
||||
widgets/ContextExtractor.hpp
|
||||
widgets/DiffStatistics.hpp
|
||||
|
||||
QuickRefactorHandler.hpp QuickRefactorHandler.cpp
|
||||
tools/ToolsFactory.hpp tools/ToolsFactory.cpp
|
||||
tools/ReadVisibleFilesTool.hpp tools/ReadVisibleFilesTool.cpp
|
||||
@ -123,6 +150,7 @@ add_qtc_plugin(QodeAssist
|
||||
tools/CreateNewFileTool.hpp tools/CreateNewFileTool.cpp
|
||||
tools/EditFileTool.hpp tools/EditFileTool.cpp
|
||||
tools/BuildProjectTool.hpp tools/BuildProjectTool.cpp
|
||||
tools/ExecuteTerminalCommandTool.hpp tools/ExecuteTerminalCommandTool.cpp
|
||||
tools/ProjectSearchTool.hpp tools/ProjectSearchTool.cpp
|
||||
tools/FindAndReadFileTool.hpp tools/FindAndReadFileTool.cpp
|
||||
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp
|
||||
|
||||
@ -10,18 +10,21 @@ qt_add_qml_module(QodeAssistChatView
|
||||
QtQuick
|
||||
QML_FILES
|
||||
qml/RootItem.qml
|
||||
qml/ChatItem.qml
|
||||
qml/dialog/CodeBlock.qml
|
||||
qml/dialog/TextBlock.qml
|
||||
qml/parts/TopBar.qml
|
||||
qml/parts/BottomBar.qml
|
||||
qml/parts/AttachedFilesPlace.qml
|
||||
qml/parts/Toast.qml
|
||||
qml/ToolStatusItem.qml
|
||||
qml/ThinkingStatusItem.qml
|
||||
qml/FileEditItem.qml
|
||||
qml/parts/RulesViewer.qml
|
||||
qml/parts/FileEditsActionBar.qml
|
||||
|
||||
qml/chatparts/CodeBlock.qml
|
||||
qml/chatparts/FileEditBlock.qml
|
||||
qml/chatparts/TextBlock.qml
|
||||
qml/chatparts/ThinkingBlock.qml
|
||||
qml/chatparts/ToolBlock.qml
|
||||
qml/chatparts/ChatItem.qml
|
||||
|
||||
qml/controls/AttachedFilesPlace.qml
|
||||
qml/controls/BottomBar.qml
|
||||
qml/controls/FileEditsActionBar.qml
|
||||
qml/controls/RulesViewer.qml
|
||||
qml/controls/Toast.qml
|
||||
qml/controls/TopBar.qml
|
||||
qml/controls/SplitDropZone.qml
|
||||
|
||||
RESOURCES
|
||||
icons/attach-file-light.svg
|
||||
@ -30,6 +33,7 @@ qt_add_qml_module(QodeAssistChatView
|
||||
icons/close-light.svg
|
||||
icons/link-file-light.svg
|
||||
icons/link-file-dark.svg
|
||||
icons/image-dark.svg
|
||||
icons/load-chat-dark.svg
|
||||
icons/save-chat-dark.svg
|
||||
icons/clean-icon-dark.svg
|
||||
@ -45,6 +49,9 @@ qt_add_qml_module(QodeAssistChatView
|
||||
icons/reject-changes-button.svg
|
||||
icons/thinking-icon-on.svg
|
||||
icons/thinking-icon-off.svg
|
||||
icons/tools-icon-on.svg
|
||||
icons/tools-icon-off.svg
|
||||
icons/settings-icon.svg
|
||||
|
||||
SOURCES
|
||||
ChatWidget.hpp ChatWidget.cpp
|
||||
@ -56,7 +63,8 @@ qt_add_qml_module(QodeAssistChatView
|
||||
ChatSerializer.hpp ChatSerializer.cpp
|
||||
ChatView.hpp ChatView.cpp
|
||||
ChatData.hpp
|
||||
|
||||
FileItem.hpp FileItem.cpp
|
||||
ChatFileManager.hpp ChatFileManager.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(QodeAssistChatView
|
||||
|
||||
@ -26,7 +26,7 @@ namespace QodeAssist::Chat {
|
||||
Q_NAMESPACE
|
||||
QML_NAMED_ELEMENT(MessagePartType)
|
||||
|
||||
enum class MessagePartType { Code, Text };
|
||||
enum class MessagePartType { Code, Text, Image };
|
||||
Q_ENUM_NS(MessagePartType)
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
206
ChatView/ChatFileManager.cpp
Normal 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
|
||||
|
||||
59
ChatView/ChatFileManager.hpp
Normal 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
|
||||
|
||||
@ -20,8 +20,11 @@
|
||||
#include "ChatModel.hpp"
|
||||
#include <utils/aspects.h>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QUrl>
|
||||
#include <QtQml>
|
||||
|
||||
#include "ChatAssistantSettings.hpp"
|
||||
@ -75,15 +78,53 @@ 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;
|
||||
}
|
||||
case Roles::Images: {
|
||||
QVariantList imagesList;
|
||||
for (const auto &image : message.images) {
|
||||
QVariantMap imageMap;
|
||||
imageMap["fileName"] = image.fileName;
|
||||
imageMap["storedPath"] = image.storedPath;
|
||||
imageMap["mediaType"] = image.mediaType;
|
||||
|
||||
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(image.storedPath);
|
||||
imageMap["imageUrl"] = QUrl::fromLocalFile(fullPath).toString();
|
||||
} else {
|
||||
imageMap["imageUrl"] = QString();
|
||||
}
|
||||
|
||||
imagesList.append(imageMap);
|
||||
}
|
||||
return imagesList;
|
||||
}
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
@ -96,6 +137,7 @@ QHash<int, QByteArray> ChatModel::roleNames() const
|
||||
roles[Roles::Content] = "content";
|
||||
roles[Roles::Attachments] = "attachments";
|
||||
roles[Roles::IsRedacted] = "isRedacted";
|
||||
roles[Roles::Images] = "images";
|
||||
return roles;
|
||||
}
|
||||
|
||||
@ -103,27 +145,27 @@ void ChatModel::addMessage(
|
||||
const QString &content,
|
||||
ChatRole role,
|
||||
const QString &id,
|
||||
const QList<Context::ContentFile> &attachments)
|
||||
const QList<Context::ContentFile> &attachments,
|
||||
const QList<ImageAttachment> &images,
|
||||
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();
|
||||
lastMessage.content = content;
|
||||
lastMessage.attachments = attachments;
|
||||
lastMessage.images = images;
|
||||
lastMessage.isRedacted = isRedacted;
|
||||
lastMessage.signature = signature;
|
||||
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
|
||||
} else {
|
||||
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
|
||||
Message newMessage{role, content, id};
|
||||
newMessage.attachments = attachments;
|
||||
newMessage.images = images;
|
||||
newMessage.isRedacted = isRedacted;
|
||||
newMessage.signature = signature;
|
||||
m_messages.append(newMessage);
|
||||
endInsertRows();
|
||||
|
||||
@ -419,6 +461,16 @@ void ChatModel::addThinkingBlock(
|
||||
displayContent += "\n[Signature: " + signature.left(40) + "...]";
|
||||
}
|
||||
|
||||
for (int i = 0; i < m_messages.size(); ++i) {
|
||||
if (m_messages[i].role == ChatRole::Thinking && m_messages[i].id == requestId) {
|
||||
m_messages[i].content = displayContent;
|
||||
m_messages[i].signature = signature;
|
||||
emit dataChanged(index(i), index(i));
|
||||
LOG_MESSAGE(QString("Updated existing thinking message at index %1").arg(i));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
|
||||
Message thinkingMessage;
|
||||
thinkingMessage.role = ChatRole::Thinking;
|
||||
@ -534,4 +586,14 @@ void ChatModel::updateFileEditStatus(const QString &editId, const QString &statu
|
||||
}
|
||||
}
|
||||
|
||||
void ChatModel::setChatFilePath(const QString &filePath)
|
||||
{
|
||||
m_chatFilePath = filePath;
|
||||
}
|
||||
|
||||
QString ChatModel::chatFilePath() const
|
||||
{
|
||||
return m_chatFilePath;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@ -40,9 +40,16 @@ public:
|
||||
enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking };
|
||||
Q_ENUM(ChatRole)
|
||||
|
||||
enum Roles { RoleType = Qt::UserRole, Content, Attachments, IsRedacted };
|
||||
enum Roles { RoleType = Qt::UserRole, Content, Attachments, IsRedacted, Images };
|
||||
Q_ENUM(Roles)
|
||||
|
||||
struct ImageAttachment
|
||||
{
|
||||
QString fileName; // Original filename
|
||||
QString storedPath; // Path to stored image file (relative to chat folder)
|
||||
QString mediaType; // MIME type
|
||||
};
|
||||
|
||||
struct Message
|
||||
{
|
||||
ChatRole role;
|
||||
@ -52,6 +59,7 @@ public:
|
||||
QString signature = QString();
|
||||
|
||||
QList<Context::ContentFile> attachments;
|
||||
QList<ImageAttachment> images;
|
||||
};
|
||||
|
||||
explicit ChatModel(QObject *parent = nullptr);
|
||||
@ -64,7 +72,10 @@ public:
|
||||
const QString &content,
|
||||
ChatRole role,
|
||||
const QString &id,
|
||||
const QList<Context::ContentFile> &attachments = {});
|
||||
const QList<Context::ContentFile> &attachments = {},
|
||||
const QList<ImageAttachment> &images = {},
|
||||
bool isRedacted = false,
|
||||
const QString &signature = QString());
|
||||
Q_INVOKABLE void clear();
|
||||
Q_INVOKABLE QList<MessagePart> processMessageContent(const QString &content) const;
|
||||
|
||||
@ -92,6 +103,9 @@ public:
|
||||
|
||||
void setLoadingFromHistory(bool loading);
|
||||
bool isLoadingFromHistory() const;
|
||||
|
||||
void setChatFilePath(const QString &filePath);
|
||||
QString chatFilePath() const;
|
||||
|
||||
signals:
|
||||
void tokensThresholdChanged();
|
||||
@ -107,6 +121,7 @@ private:
|
||||
|
||||
QVector<Message> m_messages;
|
||||
bool m_loadingFromHistory = false;
|
||||
QString m_chatFilePath;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
#include <QClipboard>
|
||||
#include <QDesktopServices>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
@ -35,16 +36,17 @@
|
||||
|
||||
#include "ChatAssistantSettings.hpp"
|
||||
#include "ChatSerializer.hpp"
|
||||
#include "ConfigurationManager.hpp"
|
||||
#include "GeneralSettings.hpp"
|
||||
#include "ToolsSettings.hpp"
|
||||
#include "SettingsConstants.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include "ProjectSettings.hpp"
|
||||
#include "ProvidersManager.hpp"
|
||||
#include "ToolsSettings.hpp"
|
||||
#include "context/ChangesManager.h"
|
||||
#include "context/ContextManager.hpp"
|
||||
#include "context/TokenUtils.hpp"
|
||||
#include "llmcore/RulesLoader.hpp"
|
||||
#include "ProvidersManager.hpp"
|
||||
#include "GeneralSettings.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
@ -53,6 +55,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();
|
||||
@ -67,6 +70,20 @@ ChatRootView::ChatRootView(QQuickItem *parent)
|
||||
connect(
|
||||
&settings.caModel, &Utils::BaseAspect::changed, this, &ChatRootView::currentTemplateChanged);
|
||||
|
||||
connect(&settings.caProvider, &Utils::BaseAspect::changed, this, [this]() {
|
||||
auto &settings = Settings::generalSettings();
|
||||
m_currentConfiguration
|
||||
= QString("%1 - %2").arg(settings.caProvider.value(), settings.caModel.value());
|
||||
emit currentConfigurationChanged();
|
||||
});
|
||||
|
||||
connect(&settings.caModel, &Utils::BaseAspect::changed, this, [this]() {
|
||||
auto &settings = Settings::generalSettings();
|
||||
m_currentConfiguration
|
||||
= QString("%1 - %2").arg(settings.caProvider.value(), settings.caModel.value());
|
||||
emit currentConfigurationChanged();
|
||||
});
|
||||
|
||||
connect(
|
||||
m_clientInterface,
|
||||
&ClientInterface::messageReceivedCompletely,
|
||||
@ -83,7 +100,7 @@ ChatRootView::ChatRootView(QQuickItem *parent)
|
||||
this,
|
||||
&ChatRootView::updateInputTokensCount);
|
||||
|
||||
connect(m_chatModel, &ChatModel::modelReseted, this, [this]() {
|
||||
connect(m_chatModel, &ChatModel::modelReseted, this, [this]() {
|
||||
setRecentFilePath(QString{});
|
||||
m_currentMessageRequestId.clear();
|
||||
updateCurrentMessageEditsStats();
|
||||
@ -147,41 +164,42 @@ ChatRootView::ChatRootView(QQuickItem *parent)
|
||||
m_lastErrorMessage = error;
|
||||
emit lastErrorMessageChanged();
|
||||
});
|
||||
|
||||
|
||||
connect(m_clientInterface, &ClientInterface::requestStarted, this, [this](const QString &requestId) {
|
||||
if (!m_currentMessageRequestId.isEmpty()) {
|
||||
LOG_MESSAGE(QString("Clearing previous message requestId: %1").arg(m_currentMessageRequestId));
|
||||
LOG_MESSAGE(
|
||||
QString("Clearing previous message requestId: %1").arg(m_currentMessageRequestId));
|
||||
}
|
||||
|
||||
|
||||
m_currentMessageRequestId = requestId;
|
||||
LOG_MESSAGE(QString("New message request started: %1").arg(requestId));
|
||||
updateCurrentMessageEditsStats();
|
||||
});
|
||||
|
||||
|
||||
connect(
|
||||
&Context::ChangesManager::instance(),
|
||||
&Context::ChangesManager::fileEditAdded,
|
||||
this,
|
||||
[this](const QString &) { updateCurrentMessageEditsStats(); });
|
||||
|
||||
|
||||
connect(
|
||||
&Context::ChangesManager::instance(),
|
||||
&Context::ChangesManager::fileEditApplied,
|
||||
this,
|
||||
[this](const QString &) { updateCurrentMessageEditsStats(); });
|
||||
|
||||
|
||||
connect(
|
||||
&Context::ChangesManager::instance(),
|
||||
&Context::ChangesManager::fileEditRejected,
|
||||
this,
|
||||
[this](const QString &) { updateCurrentMessageEditsStats(); });
|
||||
|
||||
|
||||
connect(
|
||||
&Context::ChangesManager::instance(),
|
||||
&Context::ChangesManager::fileEditUndone,
|
||||
this,
|
||||
[this](const QString &) { updateCurrentMessageEditsStats(); });
|
||||
|
||||
|
||||
connect(
|
||||
&Context::ChangesManager::instance(),
|
||||
&Context::ChangesManager::fileEditArchived,
|
||||
@ -190,6 +208,7 @@ ChatRootView::ChatRootView(QQuickItem *parent)
|
||||
|
||||
updateInputTokensCount();
|
||||
refreshRules();
|
||||
loadAvailableConfigurations();
|
||||
|
||||
connect(
|
||||
ProjectExplorer::ProjectManager::instance(),
|
||||
@ -197,26 +216,28 @@ ChatRootView::ChatRootView(QQuickItem *parent)
|
||||
this,
|
||||
&ChatRootView::refreshRules);
|
||||
|
||||
QSettings appSettings;
|
||||
m_isAgentMode = appSettings.value("QodeAssist/Chat/AgentMode", false).toBool();
|
||||
m_isThinkingMode = Settings::chatAssistantSettings().enableThinkingMode();
|
||||
connect(
|
||||
&Settings::chatAssistantSettings().enableChatTools,
|
||||
&Utils::BaseAspect::changed,
|
||||
this,
|
||||
&ChatRootView::useToolsChanged);
|
||||
|
||||
connect(
|
||||
&Settings::chatAssistantSettings().enableThinkingMode,
|
||||
&Utils::BaseAspect::changed,
|
||||
this,
|
||||
[this]() { setIsThinkingMode(Settings::chatAssistantSettings().enableThinkingMode()); });
|
||||
&ChatRootView::useThinkingChanged);
|
||||
|
||||
connect(
|
||||
&Settings::toolsSettings().useTools,
|
||||
&Utils::BaseAspect::changed,
|
||||
this,
|
||||
&ChatRootView::toolsSupportEnabledChanged);
|
||||
connect(
|
||||
&Settings::generalSettings().caProvider,
|
||||
&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
|
||||
@ -242,7 +263,18 @@ void ChatRootView::sendMessage(const QString &message)
|
||||
}
|
||||
}
|
||||
|
||||
m_clientInterface->sendMessage(message, m_attachmentFiles, m_linkedFiles, m_isAgentMode);
|
||||
if (m_recentFilePath.isEmpty()) {
|
||||
QString filePath = getAutosaveFilePath(message, m_attachmentFiles);
|
||||
if (!filePath.isEmpty()) {
|
||||
setRecentFilePath(filePath);
|
||||
LOG_MESSAGE(QString("Set chat file path for new chat: %1").arg(filePath));
|
||||
}
|
||||
}
|
||||
|
||||
m_clientInterface
|
||||
->sendMessage(message, m_attachmentFiles, m_linkedFiles, useTools(), useThinking());
|
||||
|
||||
m_fileManager->clearIntermediateStorage();
|
||||
clearAttachmentFiles();
|
||||
setRequestProgressStatus(true);
|
||||
}
|
||||
@ -260,18 +292,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
|
||||
@ -282,8 +319,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);
|
||||
@ -319,7 +356,13 @@ void ChatRootView::loadHistory(const QString &filePath)
|
||||
} else {
|
||||
setRecentFilePath(filePath);
|
||||
}
|
||||
|
||||
|
||||
m_fileManager->clearIntermediateStorage();
|
||||
m_attachmentFiles.clear();
|
||||
m_linkedFiles.clear();
|
||||
emit attachmentFilesChanged();
|
||||
emit linkedFilesChanged();
|
||||
|
||||
m_currentMessageRequestId.clear();
|
||||
updateInputTokensCount();
|
||||
updateCurrentMessageEditsStats();
|
||||
@ -379,51 +422,23 @@ void ChatRootView::showLoadDialog()
|
||||
|
||||
QString ChatRootView::getSuggestedFileName() const
|
||||
{
|
||||
QStringList parts;
|
||||
|
||||
static const QRegularExpression saitizeSymbols = QRegularExpression("[\\/:*?\"<>|\\s]");
|
||||
static const QRegularExpression underSymbols = QRegularExpression("_+");
|
||||
QString shortMessage;
|
||||
|
||||
if (m_chatModel->rowCount() > 0) {
|
||||
QString firstMessage
|
||||
= m_chatModel->data(m_chatModel->index(0), ChatModel::Content).toString();
|
||||
QString shortMessage = firstMessage.split('\n').first().simplified().left(30);
|
||||
shortMessage = firstMessage.split('\n').first().simplified().left(30);
|
||||
|
||||
QString sanitizedMessage = shortMessage;
|
||||
sanitizedMessage.replace(saitizeSymbols, "_");
|
||||
sanitizedMessage.replace(underSymbols, "_");
|
||||
sanitizedMessage = sanitizedMessage.trimmed();
|
||||
|
||||
if (!sanitizedMessage.isEmpty()) {
|
||||
if (sanitizedMessage.startsWith('_')) {
|
||||
sanitizedMessage.remove(0, 1);
|
||||
}
|
||||
if (sanitizedMessage.endsWith('_')) {
|
||||
sanitizedMessage.chop(1);
|
||||
}
|
||||
|
||||
QString targetDir = getChatsHistoryDir();
|
||||
QString fullPath = QDir(targetDir).filePath(sanitizedMessage);
|
||||
|
||||
QFileInfo fileInfo(fullPath);
|
||||
if (!fileInfo.exists() && QFileInfo(fileInfo.path()).isWritable()) {
|
||||
parts << sanitizedMessage;
|
||||
if (shortMessage.isEmpty()) {
|
||||
QVariantList images
|
||||
= m_chatModel->data(m_chatModel->index(0), ChatModel::Images).toList();
|
||||
if (!images.isEmpty()) {
|
||||
shortMessage = "image_chat";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parts << QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm");
|
||||
|
||||
QString fileName = parts.join("_");
|
||||
|
||||
QString fullPath = QDir(getChatsHistoryDir()).filePath(fileName);
|
||||
QFileInfo finalCheck(fullPath);
|
||||
|
||||
if (fileName.isEmpty() || finalCheck.exists() || !QFileInfo(finalCheck.path()).isWritable()) {
|
||||
fileName = QString("chat_%1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm"));
|
||||
}
|
||||
|
||||
return fileName;
|
||||
return generateChatFileName(shortMessage, getChatsHistoryDir());
|
||||
}
|
||||
|
||||
void ChatRootView::autosave()
|
||||
@ -453,6 +468,28 @@ QString ChatRootView::getAutosaveFilePath() const
|
||||
return QDir(dir).filePath(getSuggestedFileName() + ".json");
|
||||
}
|
||||
|
||||
QString ChatRootView::getAutosaveFilePath(
|
||||
const QString &firstMessage, const QStringList &attachments) const
|
||||
{
|
||||
if (!m_recentFilePath.isEmpty()) {
|
||||
return m_recentFilePath;
|
||||
}
|
||||
|
||||
QString dir = getChatsHistoryDir();
|
||||
if (dir.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString shortMessage = firstMessage.split('\n').first().simplified().left(30);
|
||||
|
||||
if (shortMessage.isEmpty() && hasImageAttachments(attachments)) {
|
||||
shortMessage = "image_chat";
|
||||
}
|
||||
|
||||
QString fileName = generateChatFileName(shortMessage, dir);
|
||||
return QDir(dir).filePath(fileName + ".json");
|
||||
}
|
||||
|
||||
QStringList ChatRootView::attachmentFiles() const
|
||||
{
|
||||
return m_attachmentFiles;
|
||||
@ -473,28 +510,42 @@ void ChatRootView::showAttachFilesDialog()
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
void ChatRootView::addFilesToAttachList(const QStringList &filePaths)
|
||||
{
|
||||
if (filePaths.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QStringList processedPaths = m_fileManager->processDroppedFiles(filePaths);
|
||||
|
||||
bool filesAdded = false;
|
||||
for (const QString &filePath : processedPaths) {
|
||||
if (!m_attachmentFiles.contains(filePath)) {
|
||||
m_attachmentFiles.append(filePath);
|
||||
filesAdded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (filesAdded) {
|
||||
emit attachmentFilesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@ -507,28 +558,84 @@ void ChatRootView::showLinkFilesDialog()
|
||||
}
|
||||
|
||||
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_linkedFiles.contains(filePath)) {
|
||||
m_linkedFiles.append(filePath);
|
||||
filesAdded = true;
|
||||
}
|
||||
}
|
||||
if (filesAdded) {
|
||||
emit linkedFilesChanged();
|
||||
}
|
||||
addFilesToLinkList(dialog.selectedFiles());
|
||||
}
|
||||
}
|
||||
|
||||
void ChatRootView::addFilesToLinkList(const QStringList &filePaths)
|
||||
{
|
||||
if (filePaths.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
bool filesAdded = false;
|
||||
QStringList imageFiles;
|
||||
|
||||
for (const QString &filePath : filePaths) {
|
||||
if (isImageFile(filePath)) {
|
||||
imageFiles.append(filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!m_linkedFiles.contains(filePath)) {
|
||||
m_linkedFiles.append(filePath);
|
||||
filesAdded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageFiles.isEmpty()) {
|
||||
addFilesToAttachList(imageFiles);
|
||||
m_lastInfoMessage
|
||||
= tr("Images automatically moved to Attach zone (%n file(s))", "", imageFiles.size());
|
||||
emit lastInfoMessageChanged();
|
||||
}
|
||||
|
||||
if (filesAdded) {
|
||||
emit linkedFilesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
QFileDialog dialog(nullptr, tr("Select Images to Attach"));
|
||||
dialog.setFileMode(QFileDialog::ExistingFiles);
|
||||
dialog.setNameFilter(tr("Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp)"));
|
||||
|
||||
if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
|
||||
dialog.setDirectory(project->projectDirectory().toFSPathString());
|
||||
}
|
||||
|
||||
if (dialog.exec() == QDialog::Accepted) {
|
||||
addFilesToAttachList(dialog.selectedFiles());
|
||||
}
|
||||
}
|
||||
|
||||
QStringList ChatRootView::convertUrlsToLocalPaths(const QVariantList &urls) const
|
||||
{
|
||||
QStringList localPaths;
|
||||
for (const QVariant &urlVariant : urls) {
|
||||
QUrl url(urlVariant.toString());
|
||||
if (url.isLocalFile()) {
|
||||
QString localPath = url.toLocalFile();
|
||||
if (!localPath.isEmpty()) {
|
||||
localPaths.append(localPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return localPaths;
|
||||
}
|
||||
|
||||
void ChatRootView::calculateMessageTokensCount(const QString &message)
|
||||
@ -558,8 +665,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);
|
||||
@ -579,7 +686,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()) {
|
||||
@ -590,6 +697,11 @@ void ChatRootView::openRulesFolder()
|
||||
QDesktopServices::openUrl(url);
|
||||
}
|
||||
|
||||
void ChatRootView::openSettings()
|
||||
{
|
||||
Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_CHAT_ASSISTANT_SETTINGS_PAGE_ID);
|
||||
}
|
||||
|
||||
void ChatRootView::updateInputTokensCount()
|
||||
{
|
||||
int inputTokens = m_messageTokensCount;
|
||||
@ -665,10 +777,17 @@ QString ChatRootView::chatFileName() const
|
||||
return QFileInfo(m_recentFilePath).baseName();
|
||||
}
|
||||
|
||||
QString ChatRootView::chatFilePath() const
|
||||
{
|
||||
return m_recentFilePath;
|
||||
}
|
||||
|
||||
void ChatRootView::setRecentFilePath(const QString &filePath)
|
||||
{
|
||||
if (m_recentFilePath != filePath) {
|
||||
m_recentFilePath = filePath;
|
||||
m_clientInterface->setChatFilePath(filePath);
|
||||
m_fileManager->setChatFilePath(filePath);
|
||||
emit chatFileNameChanged();
|
||||
}
|
||||
}
|
||||
@ -776,43 +895,26 @@ void ChatRootView::refreshRules()
|
||||
emit activeRulesCountChanged();
|
||||
}
|
||||
|
||||
bool ChatRootView::isAgentMode() const
|
||||
bool ChatRootView::useTools() const
|
||||
{
|
||||
return m_isAgentMode;
|
||||
return Settings::chatAssistantSettings().enableChatTools();
|
||||
}
|
||||
|
||||
void ChatRootView::setIsAgentMode(bool newIsAgentMode)
|
||||
void ChatRootView::setUseTools(bool enabled)
|
||||
{
|
||||
if (m_isAgentMode != newIsAgentMode) {
|
||||
m_isAgentMode = newIsAgentMode;
|
||||
|
||||
QSettings settings;
|
||||
settings.setValue("QodeAssist/Chat/AgentMode", newIsAgentMode);
|
||||
|
||||
emit isAgentModeChanged();
|
||||
}
|
||||
Settings::chatAssistantSettings().enableChatTools.setValue(enabled);
|
||||
Settings::chatAssistantSettings().writeSettings();
|
||||
}
|
||||
|
||||
bool ChatRootView::isThinkingMode() const
|
||||
bool ChatRootView::useThinking() const
|
||||
{
|
||||
return m_isThinkingMode;
|
||||
return Settings::chatAssistantSettings().enableThinkingMode();
|
||||
}
|
||||
|
||||
void ChatRootView::setIsThinkingMode(bool newIsThinkingMode)
|
||||
void ChatRootView::setUseThinking(bool enabled)
|
||||
{
|
||||
if (m_isThinkingMode != newIsThinkingMode) {
|
||||
m_isThinkingMode = newIsThinkingMode;
|
||||
|
||||
Settings::chatAssistantSettings().enableThinkingMode.setValue(newIsThinkingMode);
|
||||
Settings::chatAssistantSettings().writeSettings();
|
||||
|
||||
emit isThinkingModeChanged();
|
||||
}
|
||||
}
|
||||
|
||||
bool ChatRootView::toolsSupportEnabled() const
|
||||
{
|
||||
return Settings::toolsSettings().useTools();
|
||||
Settings::chatAssistantSettings().enableThinkingMode.setValue(enabled);
|
||||
Settings::chatAssistantSettings().writeSettings();
|
||||
}
|
||||
|
||||
void ChatRootView::applyFileEdit(const QString &editId)
|
||||
@ -821,13 +923,13 @@ void ChatRootView::applyFileEdit(const QString &editId)
|
||||
if (Context::ChangesManager::instance().applyFileEdit(editId)) {
|
||||
m_lastInfoMessage = QString("File edit applied successfully");
|
||||
emit lastInfoMessageChanged();
|
||||
|
||||
|
||||
updateFileEditStatus(editId, "applied");
|
||||
} else {
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
m_lastErrorMessage = edit.statusMessage.isEmpty()
|
||||
? QString("Failed to apply file edit")
|
||||
: QString("Failed to apply file edit: %1").arg(edit.statusMessage);
|
||||
m_lastErrorMessage = edit.statusMessage.isEmpty()
|
||||
? QString("Failed to apply file edit")
|
||||
: QString("Failed to apply file edit: %1").arg(edit.statusMessage);
|
||||
emit lastErrorMessageChanged();
|
||||
}
|
||||
}
|
||||
@ -838,13 +940,13 @@ void ChatRootView::rejectFileEdit(const QString &editId)
|
||||
if (Context::ChangesManager::instance().rejectFileEdit(editId)) {
|
||||
m_lastInfoMessage = QString("File edit rejected");
|
||||
emit lastInfoMessageChanged();
|
||||
|
||||
|
||||
updateFileEditStatus(editId, "rejected");
|
||||
} else {
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
m_lastErrorMessage = edit.statusMessage.isEmpty()
|
||||
? QString("Failed to reject file edit")
|
||||
: QString("Failed to reject file edit: %1").arg(edit.statusMessage);
|
||||
m_lastErrorMessage = edit.statusMessage.isEmpty()
|
||||
? QString("Failed to reject file edit")
|
||||
: QString("Failed to reject file edit: %1").arg(edit.statusMessage);
|
||||
emit lastErrorMessageChanged();
|
||||
}
|
||||
}
|
||||
@ -855,13 +957,13 @@ void ChatRootView::undoFileEdit(const QString &editId)
|
||||
if (Context::ChangesManager::instance().undoFileEdit(editId)) {
|
||||
m_lastInfoMessage = QString("File edit undone successfully");
|
||||
emit lastInfoMessageChanged();
|
||||
|
||||
|
||||
updateFileEditStatus(editId, "rejected");
|
||||
} else {
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
m_lastErrorMessage = edit.statusMessage.isEmpty()
|
||||
? QString("Failed to undo file edit")
|
||||
: QString("Failed to undo file edit: %1").arg(edit.statusMessage);
|
||||
m_lastErrorMessage = edit.statusMessage.isEmpty()
|
||||
? QString("Failed to undo file edit")
|
||||
: QString("Failed to undo file edit: %1").arg(edit.statusMessage);
|
||||
emit lastErrorMessageChanged();
|
||||
}
|
||||
}
|
||||
@ -869,37 +971,36 @@ void ChatRootView::undoFileEdit(const QString &editId)
|
||||
void ChatRootView::openFileEditInEditor(const QString &editId)
|
||||
{
|
||||
LOG_MESSAGE(QString("Opening file edit in editor: %1").arg(editId));
|
||||
|
||||
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
if (edit.editId.isEmpty()) {
|
||||
m_lastErrorMessage = QString("File edit not found: %1").arg(editId);
|
||||
emit lastErrorMessageChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Utils::FilePath filePath = Utils::FilePath::fromString(edit.filePath);
|
||||
|
||||
|
||||
Core::IEditor *editor = Core::EditorManager::openEditor(filePath);
|
||||
if (!editor) {
|
||||
m_lastErrorMessage = QString("Failed to open file in editor: %1").arg(edit.filePath);
|
||||
emit lastErrorMessageChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
auto *textEditor = qobject_cast<TextEditor::BaseTextEditor *>(editor);
|
||||
if (textEditor && textEditor->editorWidget()) {
|
||||
QTextDocument *doc = textEditor->editorWidget()->document();
|
||||
if (doc) {
|
||||
QString currentContent = doc->toPlainText();
|
||||
int position = -1;
|
||||
|
||||
|
||||
if (edit.status == Context::ChangesManager::Applied && !edit.newContent.isEmpty()) {
|
||||
position = currentContent.indexOf(edit.newContent);
|
||||
}
|
||||
else if (!edit.oldContent.isEmpty()) {
|
||||
} else if (!edit.oldContent.isEmpty()) {
|
||||
position = currentContent.indexOf(edit.oldContent);
|
||||
}
|
||||
|
||||
|
||||
if (position >= 0) {
|
||||
QTextCursor cursor(doc);
|
||||
cursor.setPosition(position);
|
||||
@ -908,7 +1009,7 @@ void ChatRootView::openFileEditInEditor(const QString &editId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LOG_MESSAGE(QString("Opened file in editor: %1").arg(edit.filePath));
|
||||
}
|
||||
|
||||
@ -918,33 +1019,35 @@ void ChatRootView::updateFileEditStatus(const QString &editId, const QString &st
|
||||
for (int i = 0; i < messages.size(); ++i) {
|
||||
if (messages[i].role == Chat::ChatModel::FileEdit && messages[i].id == editId) {
|
||||
QString content = messages[i].content;
|
||||
|
||||
|
||||
const QString marker = "QODEASSIST_FILE_EDIT:";
|
||||
int markerPos = content.indexOf(marker);
|
||||
|
||||
|
||||
QString jsonStr = content;
|
||||
if (markerPos >= 0) {
|
||||
jsonStr = content.mid(markerPos + marker.length());
|
||||
}
|
||||
|
||||
|
||||
QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
|
||||
if (doc.isObject()) {
|
||||
QJsonObject obj = doc.object();
|
||||
obj["status"] = status;
|
||||
|
||||
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
if (!edit.statusMessage.isEmpty()) {
|
||||
obj["status_message"] = edit.statusMessage;
|
||||
}
|
||||
|
||||
QString updatedContent = marker + QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact));
|
||||
|
||||
QString updatedContent = marker
|
||||
+ QString::fromUtf8(
|
||||
QJsonDocument(obj).toJson(QJsonDocument::Compact));
|
||||
m_chatModel->updateMessageContent(editId, updatedContent);
|
||||
LOG_MESSAGE(QString("Updated file edit status to: %1").arg(status));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateCurrentMessageEditsStats();
|
||||
}
|
||||
|
||||
@ -955,37 +1058,39 @@ void ChatRootView::applyAllFileEditsForCurrentMessage()
|
||||
emit lastErrorMessageChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
LOG_MESSAGE(QString("Applying all file edits for message: %1").arg(m_currentMessageRequestId));
|
||||
|
||||
|
||||
QString errorMsg;
|
||||
bool success = Context::ChangesManager::instance()
|
||||
.reapplyAllEditsForRequest(m_currentMessageRequestId, &errorMsg);
|
||||
|
||||
|
||||
if (success) {
|
||||
m_lastInfoMessage = QString("All file edits applied successfully");
|
||||
emit lastInfoMessageChanged();
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(
|
||||
m_currentMessageRequestId);
|
||||
for (const auto &edit : edits) {
|
||||
if (edit.status == Context::ChangesManager::Applied) {
|
||||
updateFileEditStatus(edit.editId, "applied");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m_lastErrorMessage = errorMsg.isEmpty()
|
||||
? QString("Failed to apply some file edits")
|
||||
: QString("Failed to apply some file edits:\n%1").arg(errorMsg);
|
||||
m_lastErrorMessage = errorMsg.isEmpty()
|
||||
? QString("Failed to apply some file edits")
|
||||
: QString("Failed to apply some file edits:\n%1").arg(errorMsg);
|
||||
emit lastErrorMessageChanged();
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(
|
||||
m_currentMessageRequestId);
|
||||
for (const auto &edit : edits) {
|
||||
if (edit.status == Context::ChangesManager::Applied) {
|
||||
updateFileEditStatus(edit.editId, "applied");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateCurrentMessageEditsStats();
|
||||
}
|
||||
|
||||
@ -996,45 +1101,47 @@ void ChatRootView::undoAllFileEditsForCurrentMessage()
|
||||
emit lastErrorMessageChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
LOG_MESSAGE(QString("Undoing all file edits for message: %1").arg(m_currentMessageRequestId));
|
||||
|
||||
|
||||
QString errorMsg;
|
||||
bool success = Context::ChangesManager::instance()
|
||||
.undoAllEditsForRequest(m_currentMessageRequestId, &errorMsg);
|
||||
|
||||
|
||||
if (success) {
|
||||
m_lastInfoMessage = QString("All file edits undone successfully");
|
||||
emit lastInfoMessageChanged();
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(
|
||||
m_currentMessageRequestId);
|
||||
for (const auto &edit : edits) {
|
||||
if (edit.status == Context::ChangesManager::Rejected) {
|
||||
updateFileEditStatus(edit.editId, "rejected");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m_lastErrorMessage = errorMsg.isEmpty()
|
||||
? QString("Failed to undo some file edits")
|
||||
: QString("Failed to undo some file edits:\n%1").arg(errorMsg);
|
||||
m_lastErrorMessage = errorMsg.isEmpty()
|
||||
? QString("Failed to undo some file edits")
|
||||
: QString("Failed to undo some file edits:\n%1").arg(errorMsg);
|
||||
emit lastErrorMessageChanged();
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(
|
||||
m_currentMessageRequestId);
|
||||
for (const auto &edit : edits) {
|
||||
if (edit.status == Context::ChangesManager::Rejected) {
|
||||
updateFileEditStatus(edit.editId, "rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateCurrentMessageEditsStats();
|
||||
}
|
||||
|
||||
void ChatRootView::updateCurrentMessageEditsStats()
|
||||
{
|
||||
if (m_currentMessageRequestId.isEmpty()) {
|
||||
if (m_currentMessageTotalEdits != 0 || m_currentMessageAppliedEdits != 0 ||
|
||||
m_currentMessagePendingEdits != 0 || m_currentMessageRejectedEdits != 0) {
|
||||
if (m_currentMessageTotalEdits != 0 || m_currentMessageAppliedEdits != 0
|
||||
|| m_currentMessagePendingEdits != 0 || m_currentMessageRejectedEdits != 0) {
|
||||
m_currentMessageTotalEdits = 0;
|
||||
m_currentMessageAppliedEdits = 0;
|
||||
m_currentMessagePendingEdits = 0;
|
||||
@ -1043,14 +1150,14 @@ void ChatRootView::updateCurrentMessageEditsStats()
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentMessageRequestId);
|
||||
|
||||
|
||||
int total = edits.size();
|
||||
int applied = 0;
|
||||
int pending = 0;
|
||||
int rejected = 0;
|
||||
|
||||
|
||||
for (const auto &edit : edits) {
|
||||
switch (edit.status) {
|
||||
case Context::ChangesManager::Applied:
|
||||
@ -1067,7 +1174,7 @@ void ChatRootView::updateCurrentMessageEditsStats()
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool changed = false;
|
||||
if (m_currentMessageTotalEdits != total) {
|
||||
m_currentMessageTotalEdits = total;
|
||||
@ -1085,10 +1192,14 @@ void ChatRootView::updateCurrentMessageEditsStats()
|
||||
m_currentMessageRejectedEdits = rejected;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
|
||||
if (changed) {
|
||||
LOG_MESSAGE(QString("Updated message edits stats: total=%1, applied=%2, pending=%3, rejected=%4")
|
||||
.arg(total).arg(applied).arg(pending).arg(rejected));
|
||||
LOG_MESSAGE(
|
||||
QString("Updated message edits stats: total=%1, applied=%2, pending=%3, rejected=%4")
|
||||
.arg(total)
|
||||
.arg(applied)
|
||||
.arg(pending)
|
||||
.arg(rejected));
|
||||
emit currentMessageEditsStatsChanged();
|
||||
}
|
||||
}
|
||||
@ -1126,5 +1237,127 @@ bool ChatRootView::isThinkingSupport() const
|
||||
return provider && provider->supportThinking();
|
||||
}
|
||||
|
||||
QString ChatRootView::generateChatFileName(const QString &shortMessage, const QString &dir) const
|
||||
{
|
||||
static const QRegularExpression saitizeSymbols = QRegularExpression("[\\/:*?\"<>|\\s]");
|
||||
static const QRegularExpression underSymbols = QRegularExpression("_+");
|
||||
|
||||
QStringList parts;
|
||||
QString sanitizedMessage = shortMessage;
|
||||
sanitizedMessage.replace(saitizeSymbols, "_");
|
||||
sanitizedMessage.replace(underSymbols, "_");
|
||||
sanitizedMessage = sanitizedMessage.trimmed();
|
||||
|
||||
if (!sanitizedMessage.isEmpty()) {
|
||||
if (sanitizedMessage.startsWith('_')) {
|
||||
sanitizedMessage.remove(0, 1);
|
||||
}
|
||||
if (sanitizedMessage.endsWith('_')) {
|
||||
sanitizedMessage.chop(1);
|
||||
}
|
||||
|
||||
QString fullPath = QDir(dir).filePath(sanitizedMessage);
|
||||
QFileInfo fileInfo(fullPath);
|
||||
if (!fileInfo.exists() && QFileInfo(fileInfo.path()).isWritable()) {
|
||||
parts << sanitizedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
parts << QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm");
|
||||
|
||||
QString fileName = parts.join("_");
|
||||
QString fullPath = QDir(dir).filePath(fileName);
|
||||
QFileInfo finalCheck(fullPath);
|
||||
|
||||
if (fileName.isEmpty() || finalCheck.exists() || !QFileInfo(finalCheck.path()).isWritable()) {
|
||||
fileName = QString("chat_%1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm"));
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
bool ChatRootView::hasImageAttachments(const QStringList &attachments) const
|
||||
{
|
||||
for (const QString &filePath : attachments) {
|
||||
if (isImageFile(filePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChatRootView::isImageFile(const QString &filePath) const
|
||||
{
|
||||
static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"};
|
||||
|
||||
QFileInfo fileInfo(filePath);
|
||||
return imageExtensions.contains(fileInfo.suffix().toLower());
|
||||
}
|
||||
|
||||
void ChatRootView::loadAvailableConfigurations()
|
||||
{
|
||||
auto &manager = Settings::ConfigurationManager::instance();
|
||||
manager.loadConfigurations(Settings::ConfigurationType::Chat);
|
||||
|
||||
QVector<Settings::AIConfiguration> configs = manager.configurations(
|
||||
Settings::ConfigurationType::Chat);
|
||||
|
||||
m_availableConfigurations.clear();
|
||||
m_availableConfigurations.append(QObject::tr("Current Settings"));
|
||||
|
||||
for (const Settings::AIConfiguration &config : configs) {
|
||||
m_availableConfigurations.append(config.name);
|
||||
}
|
||||
|
||||
auto &settings = Settings::generalSettings();
|
||||
QString currentProvider = settings.caProvider.value();
|
||||
QString currentModel = settings.caModel.value();
|
||||
m_currentConfiguration = QString("%1 - %2").arg(currentProvider, currentModel);
|
||||
|
||||
emit availableConfigurationsChanged();
|
||||
emit currentConfigurationChanged();
|
||||
}
|
||||
|
||||
void ChatRootView::applyConfiguration(const QString &configName)
|
||||
{
|
||||
if (configName == QObject::tr("Current Settings")) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto &manager = Settings::ConfigurationManager::instance();
|
||||
QVector<Settings::AIConfiguration> configs = manager.configurations(
|
||||
Settings::ConfigurationType::Chat);
|
||||
|
||||
for (const Settings::AIConfiguration &config : configs) {
|
||||
if (config.name == configName) {
|
||||
auto &settings = Settings::generalSettings();
|
||||
|
||||
settings.caProvider.setValue(config.provider);
|
||||
settings.caModel.setValue(config.model);
|
||||
settings.caTemplate.setValue(config.templateName);
|
||||
settings.caUrl.setValue(config.url);
|
||||
settings.caEndpointMode.setValue(
|
||||
settings.caEndpointMode.indexForDisplay(config.endpointMode));
|
||||
settings.caCustomEndpoint.setValue(config.customEndpoint);
|
||||
|
||||
settings.writeSettings();
|
||||
|
||||
m_currentConfiguration = QString("%1 - %2").arg(config.provider, config.model);
|
||||
emit currentConfigurationChanged();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QStringList ChatRootView::availableConfigurations() const
|
||||
{
|
||||
return m_availableConfigurations;
|
||||
}
|
||||
|
||||
QString ChatRootView::currentConfiguration() const
|
||||
{
|
||||
return m_currentConfiguration;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
|
||||
#include "ChatModel.hpp"
|
||||
#include "ClientInterface.hpp"
|
||||
#include "ChatFileManager.hpp"
|
||||
#include "llmcore/PromptProviderChat.hpp"
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
|
||||
@ -48,16 +49,16 @@ class ChatRootView : public QQuickItem
|
||||
Q_PROPERTY(QString lastInfoMessage READ lastInfoMessage NOTIFY lastInfoMessageChanged FINAL)
|
||||
Q_PROPERTY(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL)
|
||||
Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL)
|
||||
Q_PROPERTY(bool isAgentMode READ isAgentMode WRITE setIsAgentMode NOTIFY isAgentModeChanged FINAL)
|
||||
Q_PROPERTY(bool isThinkingMode READ isThinkingMode WRITE setIsThinkingMode NOTIFY isThinkingModeChanged FINAL)
|
||||
Q_PROPERTY(
|
||||
bool toolsSupportEnabled READ toolsSupportEnabled NOTIFY toolsSupportEnabledChanged FINAL)
|
||||
Q_PROPERTY(bool useTools READ useTools WRITE setUseTools NOTIFY useToolsChanged FINAL)
|
||||
Q_PROPERTY(bool useThinking READ useThinking WRITE setUseThinking NOTIFY useThinkingChanged FINAL)
|
||||
|
||||
Q_PROPERTY(int currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(int currentMessagePendingEdits READ currentMessagePendingEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(int currentMessageRejectedEdits READ currentMessageRejectedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
|
||||
Q_PROPERTY(bool isThinkingSupport READ isThinkingSupport NOTIFY isThinkingSupportChanged FINAL)
|
||||
Q_PROPERTY(QStringList availableConfigurations READ availableConfigurations NOTIFY availableConfigurationsChanged FINAL)
|
||||
Q_PROPERTY(QString currentConfiguration READ currentConfiguration NOTIFY currentConfigurationChanged FINAL)
|
||||
|
||||
QML_ELEMENT
|
||||
|
||||
@ -75,18 +76,25 @@ public:
|
||||
|
||||
void autosave();
|
||||
QString getAutosaveFilePath() const;
|
||||
QString getAutosaveFilePath(const QString &firstMessage, const QStringList &attachments) const;
|
||||
|
||||
QStringList attachmentFiles() const;
|
||||
QStringList linkedFiles() const;
|
||||
|
||||
Q_INVOKABLE void showAttachFilesDialog();
|
||||
Q_INVOKABLE void addFilesToAttachList(const QStringList &filePaths);
|
||||
Q_INVOKABLE void removeFileFromAttachList(int index);
|
||||
Q_INVOKABLE void showLinkFilesDialog();
|
||||
Q_INVOKABLE void addFilesToLinkList(const QStringList &filePaths);
|
||||
Q_INVOKABLE void removeFileFromLinkList(int index);
|
||||
Q_INVOKABLE QStringList convertUrlsToLocalPaths(const QVariantList &urls) const;
|
||||
Q_INVOKABLE void showAddImageDialog();
|
||||
Q_INVOKABLE bool isImageFile(const QString &filePath) const;
|
||||
Q_INVOKABLE void calculateMessageTokensCount(const QString &message);
|
||||
Q_INVOKABLE void setIsSyncOpenFiles(bool state);
|
||||
Q_INVOKABLE void openChatHistoryFolder();
|
||||
Q_INVOKABLE void openRulesFolder();
|
||||
Q_INVOKABLE void openSettings();
|
||||
|
||||
Q_INVOKABLE void updateInputTokensCount();
|
||||
int inputTokensCount() const;
|
||||
@ -98,6 +106,7 @@ public:
|
||||
void onEditorCreated(Core::IEditor *editor, const Utils::FilePath &filePath);
|
||||
|
||||
QString chatFileName() const;
|
||||
Q_INVOKABLE QString chatFilePath() const;
|
||||
void setRecentFilePath(const QString &filePath);
|
||||
bool shouldIgnoreFileForAttach(const Utils::FilePath &filePath);
|
||||
|
||||
@ -118,11 +127,10 @@ public:
|
||||
Q_INVOKABLE QString getRuleContent(int index);
|
||||
Q_INVOKABLE void refreshRules();
|
||||
|
||||
bool isAgentMode() const;
|
||||
void setIsAgentMode(bool newIsAgentMode);
|
||||
bool isThinkingMode() const;
|
||||
void setIsThinkingMode(bool newIsThinkingMode);
|
||||
bool toolsSupportEnabled() const;
|
||||
bool useTools() const;
|
||||
void setUseTools(bool enabled);
|
||||
bool useThinking() const;
|
||||
void setUseThinking(bool enabled);
|
||||
|
||||
Q_INVOKABLE void applyFileEdit(const QString &editId);
|
||||
Q_INVOKABLE void rejectFileEdit(const QString &editId);
|
||||
@ -132,6 +140,11 @@ public:
|
||||
Q_INVOKABLE void applyAllFileEditsForCurrentMessage();
|
||||
Q_INVOKABLE void undoAllFileEditsForCurrentMessage();
|
||||
Q_INVOKABLE void updateCurrentMessageEditsStats();
|
||||
|
||||
Q_INVOKABLE void loadAvailableConfigurations();
|
||||
Q_INVOKABLE void applyConfiguration(const QString &configName);
|
||||
QStringList availableConfigurations() const;
|
||||
QString currentConfiguration() const;
|
||||
|
||||
int currentMessageTotalEdits() const;
|
||||
int currentMessageAppliedEdits() const;
|
||||
@ -170,21 +183,25 @@ signals:
|
||||
void activeRulesChanged();
|
||||
void activeRulesCountChanged();
|
||||
|
||||
void isAgentModeChanged();
|
||||
void isThinkingModeChanged();
|
||||
void toolsSupportEnabledChanged();
|
||||
void useToolsChanged();
|
||||
void useThinkingChanged();
|
||||
void currentMessageEditsStatsChanged();
|
||||
|
||||
void isThinkingSupportChanged();
|
||||
void availableConfigurationsChanged();
|
||||
void currentConfigurationChanged();
|
||||
|
||||
private:
|
||||
void updateFileEditStatus(const QString &editId, const QString &status);
|
||||
QString getChatsHistoryDir() const;
|
||||
QString getSuggestedFileName() const;
|
||||
QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
|
||||
bool hasImageAttachments(const QStringList &attachments) const;
|
||||
|
||||
ChatModel *m_chatModel;
|
||||
LLMCore::PromptProviderChat m_promptProvider;
|
||||
ClientInterface *m_clientInterface;
|
||||
ChatFileManager *m_fileManager;
|
||||
QString m_currentTemplate;
|
||||
QString m_recentFilePath;
|
||||
QStringList m_attachmentFiles;
|
||||
@ -196,8 +213,6 @@ private:
|
||||
bool m_isRequestInProgress;
|
||||
QString m_lastErrorMessage;
|
||||
QVariantList m_activeRules;
|
||||
bool m_isAgentMode;
|
||||
bool m_isThinkingMode;
|
||||
|
||||
QString m_currentMessageRequestId;
|
||||
int m_currentMessageTotalEdits{0};
|
||||
@ -205,6 +220,9 @@ private:
|
||||
int m_currentMessagePendingEdits{0};
|
||||
int m_currentMessageRejectedEdits{0};
|
||||
QString m_lastInfoMessage;
|
||||
|
||||
QStringList m_availableConfigurations;
|
||||
QString m_currentConfiguration;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@ -20,14 +20,17 @@
|
||||
#include "ChatSerializer.hpp"
|
||||
#include "Logger.hpp"
|
||||
|
||||
#include <QBuffer>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QUuid>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
const QString ChatSerializer::VERSION = "0.1";
|
||||
const QString ChatSerializer::VERSION = "0.2";
|
||||
|
||||
SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QString &filePath)
|
||||
{
|
||||
@ -35,12 +38,20 @@ SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QSt
|
||||
return {false, "Failed to create directory structure"};
|
||||
}
|
||||
|
||||
QString contentFolder = getChatContentFolder(filePath);
|
||||
QDir dir;
|
||||
if (!dir.exists(contentFolder)) {
|
||||
if (!dir.mkpath(contentFolder)) {
|
||||
LOG_MESSAGE(QString("Warning: Failed to create content folder: %1").arg(contentFolder));
|
||||
}
|
||||
}
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
return {false, QString("Failed to open file for writing: %1").arg(filePath)};
|
||||
}
|
||||
|
||||
QJsonObject root = serializeChat(model);
|
||||
QJsonObject root = serializeChat(model, filePath);
|
||||
QJsonDocument doc(root);
|
||||
|
||||
if (file.write(doc.toJson(QJsonDocument::Indented)) == -1) {
|
||||
@ -70,27 +81,55 @@ SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString
|
||||
return {false, QString("Unsupported version: %1").arg(version)};
|
||||
}
|
||||
|
||||
if (!deserializeChat(model, root)) {
|
||||
if (!deserializeChat(model, root, filePath)) {
|
||||
return {false, "Failed to deserialize chat data"};
|
||||
}
|
||||
|
||||
return {true, QString()};
|
||||
}
|
||||
|
||||
QJsonObject ChatSerializer::serializeMessage(const ChatModel::Message &message)
|
||||
QJsonObject ChatSerializer::serializeMessage(const ChatModel::Message &message, const QString &chatFilePath)
|
||||
{
|
||||
QJsonObject messageObj;
|
||||
messageObj["role"] = static_cast<int>(message.role);
|
||||
messageObj["content"] = message.content;
|
||||
messageObj["id"] = message.id;
|
||||
messageObj["isRedacted"] = message.isRedacted;
|
||||
|
||||
if (message.isRedacted) {
|
||||
messageObj["isRedacted"] = true;
|
||||
}
|
||||
|
||||
if (!message.signature.isEmpty()) {
|
||||
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) {
|
||||
QJsonObject imageObj;
|
||||
imageObj["fileName"] = image.fileName;
|
||||
imageObj["storedPath"] = image.storedPath;
|
||||
imageObj["mediaType"] = image.mediaType;
|
||||
imagesArray.append(imageObj);
|
||||
}
|
||||
messageObj["images"] = imagesArray;
|
||||
}
|
||||
|
||||
return messageObj;
|
||||
}
|
||||
|
||||
ChatModel::Message ChatSerializer::deserializeMessage(const QJsonObject &json)
|
||||
ChatModel::Message ChatSerializer::deserializeMessage(const QJsonObject &json, const QString &chatFilePath)
|
||||
{
|
||||
ChatModel::Message message;
|
||||
message.role = static_cast<ChatModel::ChatRole>(json["role"].toInt());
|
||||
@ -98,14 +137,38 @@ ChatModel::Message ChatSerializer::deserializeMessage(const QJsonObject &json)
|
||||
message.id = json["id"].toString();
|
||||
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) {
|
||||
QJsonObject imageObj = imageValue.toObject();
|
||||
ChatModel::ImageAttachment image;
|
||||
image.fileName = imageObj["fileName"].toString();
|
||||
image.storedPath = imageObj["storedPath"].toString();
|
||||
image.mediaType = imageObj["mediaType"].toString();
|
||||
message.images.append(image);
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
QJsonObject ChatSerializer::serializeChat(const ChatModel *model)
|
||||
QJsonObject ChatSerializer::serializeChat(const ChatModel *model, const QString &chatFilePath)
|
||||
{
|
||||
QJsonArray messagesArray;
|
||||
for (const auto &message : model->getChatHistory()) {
|
||||
messagesArray.append(serializeMessage(message));
|
||||
messagesArray.append(serializeMessage(message, chatFilePath));
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
@ -115,14 +178,14 @@ QJsonObject ChatSerializer::serializeChat(const ChatModel *model)
|
||||
return root;
|
||||
}
|
||||
|
||||
bool ChatSerializer::deserializeChat(ChatModel *model, const QJsonObject &json)
|
||||
bool ChatSerializer::deserializeChat(ChatModel *model, const QJsonObject &json, const QString &chatFilePath)
|
||||
{
|
||||
QJsonArray messagesArray = json["messages"].toArray();
|
||||
QVector<ChatModel::Message> messages;
|
||||
messages.reserve(messagesArray.size());
|
||||
|
||||
for (const auto &messageValue : messagesArray) {
|
||||
messages.append(deserializeMessage(messageValue.toObject()));
|
||||
messages.append(deserializeMessage(messageValue.toObject(), chatFilePath));
|
||||
}
|
||||
|
||||
model->clear();
|
||||
@ -130,7 +193,11 @@ bool ChatSerializer::deserializeChat(ChatModel *model, const QJsonObject &json)
|
||||
model->setLoadingFromHistory(true);
|
||||
|
||||
for (const auto &message : messages) {
|
||||
model->addMessage(message.content, message.role, message.id);
|
||||
model->addMessage(message.content, message.role, message.id, message.attachments, message.images, message.isRedacted, message.signature);
|
||||
LOG_MESSAGE(QString("Loaded message with %1 image(s), isRedacted=%2, signature length=%3")
|
||||
.arg(message.images.size())
|
||||
.arg(message.isRedacted)
|
||||
.arg(message.signature.length()));
|
||||
}
|
||||
|
||||
model->setLoadingFromHistory(false);
|
||||
@ -147,7 +214,85 @@ 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::getChatContentFolder(const QString &chatFilePath)
|
||||
{
|
||||
QFileInfo fileInfo(chatFilePath);
|
||||
QString baseName = fileInfo.completeBaseName();
|
||||
QString dirPath = fileInfo.absolutePath();
|
||||
return QDir(dirPath).filePath(baseName + "_content");
|
||||
}
|
||||
|
||||
bool ChatSerializer::saveContentToStorage(const QString &chatFilePath,
|
||||
const QString &fileName,
|
||||
const QString &base64Data,
|
||||
QString &storedPath)
|
||||
{
|
||||
QString contentFolder = getChatContentFolder(chatFilePath);
|
||||
QDir dir;
|
||||
if (!dir.exists(contentFolder)) {
|
||||
if (!dir.mkpath(contentFolder)) {
|
||||
LOG_MESSAGE(QString("Failed to create content folder: %1").arg(contentFolder));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
QFileInfo originalFileInfo(fileName);
|
||||
QString extension = originalFileInfo.suffix();
|
||||
QString baseName = originalFileInfo.completeBaseName();
|
||||
QString uniqueName = QString("%1_%2.%3")
|
||||
.arg(baseName)
|
||||
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8))
|
||||
.arg(extension);
|
||||
|
||||
QString fullPath = QDir(contentFolder).filePath(uniqueName);
|
||||
|
||||
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(contentData) == -1) {
|
||||
LOG_MESSAGE(QString("Failed to write content data: %1").arg(file.errorString()));
|
||||
return false;
|
||||
}
|
||||
|
||||
file.close();
|
||||
|
||||
storedPath = uniqueName;
|
||||
LOG_MESSAGE(QString("Saved content: %1 to %2").arg(fileName, fullPath));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
QString ChatSerializer::loadContentFromStorage(const QString &chatFilePath, const QString &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 content file: %1").arg(fullPath));
|
||||
return QString();
|
||||
}
|
||||
|
||||
QByteArray contentData = file.readAll();
|
||||
file.close();
|
||||
|
||||
return contentData.toBase64();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@ -40,10 +40,18 @@ public:
|
||||
static SerializationResult loadFromFile(ChatModel *model, const QString &filePath);
|
||||
|
||||
// Public for testing purposes
|
||||
static QJsonObject serializeMessage(const ChatModel::Message &message);
|
||||
static ChatModel::Message deserializeMessage(const QJsonObject &json);
|
||||
static QJsonObject serializeChat(const ChatModel *model);
|
||||
static bool deserializeChat(ChatModel *model, const QJsonObject &json);
|
||||
static QJsonObject serializeMessage(const ChatModel::Message &message, const QString &chatFilePath);
|
||||
static ChatModel::Message deserializeMessage(const QJsonObject &json, const QString &chatFilePath);
|
||||
static QJsonObject serializeChat(const ChatModel *model, const QString &chatFilePath);
|
||||
static bool deserializeChat(ChatModel *model, const QJsonObject &json, const QString &chatFilePath);
|
||||
|
||||
// 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;
|
||||
|
||||
@ -19,10 +19,15 @@
|
||||
|
||||
#include "ClientInterface.hpp"
|
||||
|
||||
#include <projectexplorer/buildconfiguration.h>
|
||||
#include <projectexplorer/target.h>
|
||||
#include <texteditor/textdocument.h>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QImageReader>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QMimeDatabase>
|
||||
#include <QUuid>
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
@ -36,13 +41,14 @@
|
||||
#include <texteditor/texteditor.h>
|
||||
|
||||
#include "ChatAssistantSettings.hpp"
|
||||
#include "ChatSerializer.hpp"
|
||||
#include "GeneralSettings.hpp"
|
||||
#include "ToolsSettings.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include "ProvidersManager.hpp"
|
||||
#include "RequestConfig.hpp"
|
||||
#include <context/ChangesManager.h>
|
||||
#include "ToolsSettings.hpp"
|
||||
#include <RulesLoader.hpp>
|
||||
#include <context/ChangesManager.h>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
@ -63,15 +69,71 @@ void ClientInterface::sendMessage(
|
||||
const QString &message,
|
||||
const QList<QString> &attachments,
|
||||
const QList<QString> &linkedFiles,
|
||||
bool useAgentMode)
|
||||
bool useTools,
|
||||
bool useThinking)
|
||||
{
|
||||
cancelRequest();
|
||||
m_accumulatedResponses.clear();
|
||||
|
||||
|
||||
Context::ChangesManager::instance().archiveAllNonArchivedEdits();
|
||||
|
||||
auto attachFiles = m_contextManager->getContentFiles(attachments);
|
||||
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", attachFiles);
|
||||
QList<QString> imageFiles;
|
||||
QList<QString> textFiles;
|
||||
|
||||
for (const QString &filePath : attachments) {
|
||||
if (isImageFile(filePath)) {
|
||||
imageFiles.append(filePath);
|
||||
} else {
|
||||
textFiles.append(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
for (const QString &imagePath : imageFiles) {
|
||||
QString base64Data = encodeImageToBase64(imagePath);
|
||||
if (base64Data.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QString storedPath;
|
||||
QFileInfo fileInfo(imagePath);
|
||||
if (ChatSerializer::saveContentToStorage(
|
||||
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
|
||||
ChatModel::ImageAttachment imageAttachment;
|
||||
imageAttachment.fileName = fileInfo.fileName();
|
||||
imageAttachment.storedPath = storedPath;
|
||||
imageAttachment.mediaType = getMediaTypeForImage(imagePath);
|
||||
imageAttachments.append(imageAttachment);
|
||||
|
||||
LOG_MESSAGE(QString("Stored image %1 as %2").arg(fileInfo.fileName(), storedPath));
|
||||
}
|
||||
}
|
||||
} else if (!imageFiles.isEmpty()) {
|
||||
LOG_MESSAGE(QString("Warning: Chat file path not set, cannot save %1 image(s)")
|
||||
.arg(imageFiles.size()));
|
||||
}
|
||||
|
||||
m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", storedAttachments, imageAttachments);
|
||||
|
||||
auto &chatAssistantSettings = Settings::chatAssistantSettings();
|
||||
|
||||
@ -93,7 +155,7 @@ void ClientInterface::sendMessage(
|
||||
|
||||
LLMCore::ContextData context;
|
||||
|
||||
const bool isToolsEnabled = Settings::toolsSettings().useTools() && useAgentMode;
|
||||
const bool isToolsEnabled = useTools;
|
||||
|
||||
if (chatAssistantSettings.useSystemPrompt()) {
|
||||
QString systemPrompt = chatAssistantSettings.systemPrompt();
|
||||
@ -102,7 +164,15 @@ void ClientInterface::sendMessage(
|
||||
|
||||
if (project) {
|
||||
systemPrompt += QString("\n# Active project name: %1").arg(project->displayName());
|
||||
systemPrompt += QString("\n# Active Project path: %1").arg(project->projectDirectory().toUrlishString());
|
||||
systemPrompt += QString("\n# Active Project path: %1")
|
||||
.arg(project->projectDirectory().toUrlishString());
|
||||
|
||||
if (auto target = project->activeTarget()) {
|
||||
if (auto buildConfig = target->activeBuildConfiguration()) {
|
||||
systemPrompt += QString("\n# Active Build directory: %1")
|
||||
.arg(buildConfig->buildDirectory().toUrlishString());
|
||||
}
|
||||
}
|
||||
|
||||
QString projectRules
|
||||
= LLMCore::RulesLoader::loadRulesForProject(project, LLMCore::RulesContext::Chat);
|
||||
@ -125,16 +195,42 @@ void ClientInterface::sendMessage(
|
||||
if (msg.role == ChatModel::ChatRole::Tool || msg.role == ChatModel::ChatRole::FileEdit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
|
||||
if (provider->supportImage() && !m_chatFilePath.isEmpty() && !msg.images.isEmpty()) {
|
||||
auto apiImages = loadImagesFromStorage(msg.images);
|
||||
if (!apiImages.isEmpty()) {
|
||||
apiMessage.images = apiImages;
|
||||
}
|
||||
}
|
||||
|
||||
messages.append(apiMessage);
|
||||
}
|
||||
|
||||
if (!imageFiles.isEmpty() && !provider->supportImage()) {
|
||||
LOG_MESSAGE(QString("Provider %1 doesn't support images, %2 ignored")
|
||||
.arg(provider->name(), QString::number(imageFiles.size())));
|
||||
}
|
||||
|
||||
context.history = messages;
|
||||
|
||||
LLMCore::LLMConfig config;
|
||||
@ -158,13 +254,18 @@ void ClientInterface::sendMessage(
|
||||
config.apiKey = provider->apiKey();
|
||||
|
||||
config.provider->prepareRequest(
|
||||
config.providerRequest, promptTemplate, context, LLMCore::RequestType::Chat, isToolsEnabled);
|
||||
config.providerRequest,
|
||||
promptTemplate,
|
||||
context,
|
||||
LLMCore::RequestType::Chat,
|
||||
useTools,
|
||||
useThinking);
|
||||
|
||||
QString requestId = QUuid::createUuid().toString();
|
||||
QJsonObject request{{"id", requestId}};
|
||||
|
||||
m_activeRequests[requestId] = {request, provider};
|
||||
|
||||
|
||||
emit requestStarted(requestId);
|
||||
|
||||
connect(
|
||||
@ -188,14 +289,14 @@ void ClientInterface::sendMessage(
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::toolExecutionStarted,
|
||||
m_chatModel,
|
||||
&ChatModel::addToolExecutionStatus,
|
||||
this,
|
||||
&ClientInterface::handleToolExecutionStarted,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::toolExecutionCompleted,
|
||||
m_chatModel,
|
||||
&ChatModel::updateToolResult,
|
||||
this,
|
||||
&ClientInterface::handleToolExecutionCompleted,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider,
|
||||
@ -206,14 +307,14 @@ void ClientInterface::sendMessage(
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::thinkingBlockReceived,
|
||||
m_chatModel,
|
||||
&ChatModel::addThinkingBlock,
|
||||
this,
|
||||
&ClientInterface::handleThinkingBlockReceived,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider,
|
||||
&LLMCore::Provider::redactedThinkingBlockReceived,
|
||||
m_chatModel,
|
||||
&ChatModel::addRedactedThinkingBlock,
|
||||
this,
|
||||
&ClientInterface::handleRedactedThinkingBlockReceived,
|
||||
Qt::UniqueConnection);
|
||||
|
||||
provider->sendRequest(requestId, config.url, config.providerRequest);
|
||||
@ -328,14 +429,14 @@ void ClientInterface::handleFullResponse(const QString &requestId, const QString
|
||||
const RequestContext &ctx = it.value();
|
||||
|
||||
QString finalText = !fullText.isEmpty() ? fullText : m_accumulatedResponses[requestId];
|
||||
|
||||
|
||||
QString applyError;
|
||||
bool applySuccess = Context::ChangesManager::instance()
|
||||
.applyPendingEditsForRequest(requestId, &applyError);
|
||||
|
||||
bool applySuccess
|
||||
= Context::ChangesManager::instance().applyPendingEditsForRequest(requestId, &applyError);
|
||||
|
||||
if (!applySuccess) {
|
||||
LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2")
|
||||
.arg(requestId, applyError));
|
||||
.arg(requestId, applyError));
|
||||
}
|
||||
|
||||
LOG_MESSAGE(
|
||||
@ -374,4 +475,134 @@ void ClientInterface::handleCleanAccumulatedData(const QString &requestId)
|
||||
LOG_MESSAGE(QString("Cleared accumulated responses for continuation request %1").arg(requestId));
|
||||
}
|
||||
|
||||
void ClientInterface::handleThinkingBlockReceived(
|
||||
const QString &requestId, const QString &thinking, const QString &signature)
|
||||
{
|
||||
if (!m_activeRequests.contains(requestId)) {
|
||||
LOG_MESSAGE(QString("Ignoring thinking block for non-chat request: %1").arg(requestId));
|
||||
return;
|
||||
}
|
||||
|
||||
m_chatModel->addThinkingBlock(requestId, thinking, signature);
|
||||
}
|
||||
|
||||
void ClientInterface::handleRedactedThinkingBlockReceived(
|
||||
const QString &requestId, const QString &signature)
|
||||
{
|
||||
if (!m_activeRequests.contains(requestId)) {
|
||||
LOG_MESSAGE(
|
||||
QString("Ignoring redacted thinking block for non-chat request: %1").arg(requestId));
|
||||
return;
|
||||
}
|
||||
|
||||
m_chatModel->addRedactedThinkingBlock(requestId, signature);
|
||||
}
|
||||
|
||||
void ClientInterface::handleToolExecutionStarted(
|
||||
const QString &requestId, const QString &toolId, const QString &toolName)
|
||||
{
|
||||
if (!m_activeRequests.contains(requestId)) {
|
||||
LOG_MESSAGE(QString("Ignoring tool execution start for non-chat request: %1").arg(requestId));
|
||||
return;
|
||||
}
|
||||
|
||||
m_chatModel->addToolExecutionStatus(requestId, toolId, toolName);
|
||||
}
|
||||
|
||||
void ClientInterface::handleToolExecutionCompleted(
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QString &toolOutput)
|
||||
{
|
||||
if (!m_activeRequests.contains(requestId)) {
|
||||
LOG_MESSAGE(QString("Ignoring tool execution result for non-chat request: %1").arg(requestId));
|
||||
return;
|
||||
}
|
||||
|
||||
m_chatModel->updateToolResult(requestId, toolId, toolName, toolOutput);
|
||||
}
|
||||
|
||||
bool ClientInterface::isImageFile(const QString &filePath) const
|
||||
{
|
||||
static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"};
|
||||
|
||||
QFileInfo fileInfo(filePath);
|
||||
QString extension = fileInfo.suffix().toLower();
|
||||
|
||||
return imageExtensions.contains(extension);
|
||||
}
|
||||
|
||||
QString ClientInterface::getMediaTypeForImage(const QString &filePath) const
|
||||
{
|
||||
static const QHash<QString, QString> mediaTypes
|
||||
= {{"png", "image/png"},
|
||||
{"jpg", "image/jpeg"},
|
||||
{"jpeg", "image/jpeg"},
|
||||
{"gif", "image/gif"},
|
||||
{"webp", "image/webp"},
|
||||
{"bmp", "image/bmp"},
|
||||
{"svg", "image/svg+xml"}};
|
||||
|
||||
QFileInfo fileInfo(filePath);
|
||||
QString extension = fileInfo.suffix().toLower();
|
||||
|
||||
if (mediaTypes.contains(extension)) {
|
||||
return mediaTypes[extension];
|
||||
}
|
||||
|
||||
QMimeDatabase mimeDb;
|
||||
QMimeType mimeType = mimeDb.mimeTypeForFile(filePath);
|
||||
return mimeType.name();
|
||||
}
|
||||
|
||||
QString ClientInterface::encodeImageToBase64(const QString &filePath) const
|
||||
{
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
LOG_MESSAGE(QString("Failed to open image file: %1").arg(filePath));
|
||||
return QString();
|
||||
}
|
||||
|
||||
QByteArray imageData = file.readAll();
|
||||
file.close();
|
||||
|
||||
return imageData.toBase64();
|
||||
}
|
||||
|
||||
QVector<LLMCore::ImageAttachment> ClientInterface::loadImagesFromStorage(
|
||||
const QList<ChatModel::ImageAttachment> &storedImages) const
|
||||
{
|
||||
QVector<LLMCore::ImageAttachment> apiImages;
|
||||
|
||||
for (const auto &storedImage : storedImages) {
|
||||
QString base64Data
|
||||
= ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
|
||||
if (base64Data.isEmpty()) {
|
||||
LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
LLMCore::ImageAttachment apiImage;
|
||||
apiImage.data = base64Data;
|
||||
apiImage.mediaType = storedImage.mediaType;
|
||||
apiImage.isUrl = false;
|
||||
|
||||
apiImages.append(apiImage);
|
||||
}
|
||||
|
||||
return apiImages;
|
||||
}
|
||||
|
||||
void ClientInterface::setChatFilePath(const QString &filePath)
|
||||
{
|
||||
m_chatFilePath = filePath;
|
||||
m_chatModel->setChatFilePath(filePath);
|
||||
}
|
||||
|
||||
QString ClientInterface::chatFilePath() const
|
||||
{
|
||||
return m_chatFilePath;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@ -43,11 +43,15 @@ public:
|
||||
const QString &message,
|
||||
const QList<QString> &attachments = {},
|
||||
const QList<QString> &linkedFiles = {},
|
||||
bool useAgentMode = false);
|
||||
bool useTools = false,
|
||||
bool useThinking = false);
|
||||
void clearMessages();
|
||||
void cancelRequest();
|
||||
|
||||
Context::ContextManager *contextManager() const;
|
||||
|
||||
void setChatFilePath(const QString &filePath);
|
||||
QString chatFilePath() const;
|
||||
|
||||
signals:
|
||||
void errorOccurred(const QString &error);
|
||||
@ -59,12 +63,26 @@ private slots:
|
||||
void handleFullResponse(const QString &requestId, const QString &fullText);
|
||||
void handleRequestFailed(const QString &requestId, const QString &error);
|
||||
void handleCleanAccumulatedData(const QString &requestId);
|
||||
void handleThinkingBlockReceived(
|
||||
const QString &requestId, const QString &thinking, const QString &signature);
|
||||
void handleRedactedThinkingBlockReceived(const QString &requestId, const QString &signature);
|
||||
void handleToolExecutionStarted(
|
||||
const QString &requestId, const QString &toolId, const QString &toolName);
|
||||
void handleToolExecutionCompleted(
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QString &toolOutput);
|
||||
|
||||
private:
|
||||
void handleLLMResponse(const QString &response, const QJsonObject &request);
|
||||
QString getCurrentFileContext() const;
|
||||
QString getSystemPromptWithLinkedFiles(
|
||||
const QString &basePrompt, const QList<QString> &linkedFiles) const;
|
||||
bool isImageFile(const QString &filePath) const;
|
||||
QString getMediaTypeForImage(const QString &filePath) const;
|
||||
QString encodeImageToBase64(const QString &filePath) const;
|
||||
QVector<LLMCore::ImageAttachment> loadImagesFromStorage(const QList<ChatModel::ImageAttachment> &storedImages) const;
|
||||
|
||||
struct RequestContext
|
||||
{
|
||||
@ -75,6 +93,7 @@ private:
|
||||
LLMCore::IPromptProvider *m_promptProvider = nullptr;
|
||||
ChatModel *m_chatModel;
|
||||
Context::ContextManager *m_contextManager;
|
||||
QString m_chatFilePath;
|
||||
|
||||
QHash<QString, RequestContext> m_activeRequests;
|
||||
QHash<QString, QString> m_accumulatedResponses;
|
||||
|
||||
76
ChatView/FileItem.cpp
Normal file
@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (C) 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 "FileItem.hpp"
|
||||
|
||||
#include <QDesktopServices>
|
||||
#include <QUrl>
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <logger/Logger.hpp>
|
||||
#include <utils/filepath.h>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
FileItem::FileItem(QQuickItem *parent)
|
||||
: QQuickItem(parent)
|
||||
{}
|
||||
|
||||
void FileItem::openFileInEditor()
|
||||
{
|
||||
if (m_filePath.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Utils::FilePath filePathObj = Utils::FilePath::fromString(m_filePath);
|
||||
Core::IEditor *editor = Core::EditorManager::openEditor(filePathObj);
|
||||
|
||||
if (!editor) {
|
||||
LOG_MESSAGE(QString("Failed to open file in editor: %1").arg(m_filePath));
|
||||
}
|
||||
}
|
||||
|
||||
void FileItem::openFileInExternalEditor()
|
||||
{
|
||||
if (m_filePath.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
bool success = QDesktopServices::openUrl(QUrl::fromLocalFile(m_filePath));
|
||||
if (success) {
|
||||
LOG_MESSAGE(QString("Opened file in external application: %1").arg(m_filePath));
|
||||
} else {
|
||||
LOG_MESSAGE(QString("Failed to open file externally: %1").arg(m_filePath));
|
||||
}
|
||||
}
|
||||
|
||||
QString FileItem::filePath() const
|
||||
{
|
||||
return m_filePath;
|
||||
}
|
||||
|
||||
void FileItem::setFilePath(const QString &newFilePath)
|
||||
{
|
||||
if (m_filePath == newFilePath)
|
||||
return;
|
||||
m_filePath = newFilePath;
|
||||
emit filePathChanged();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
48
ChatView/FileItem.hpp
Normal file
@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 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 <QQuickItem>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class FileItem: public QQuickItem
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_NAMED_ELEMENT(FileItem)
|
||||
|
||||
Q_PROPERTY(QString filePath READ filePath WRITE setFilePath NOTIFY filePathChanged)
|
||||
|
||||
public:
|
||||
FileItem(QQuickItem *parent = nullptr);
|
||||
|
||||
Q_INVOKABLE void openFileInEditor();
|
||||
Q_INVOKABLE void openFileInExternalEditor();
|
||||
|
||||
QString filePath() const;
|
||||
void setFilePath(const QString &newFilePath);
|
||||
|
||||
signals:
|
||||
void filePathChanged();
|
||||
|
||||
private:
|
||||
QString m_filePath;
|
||||
};
|
||||
}
|
||||
@ -32,11 +32,15 @@ class MessagePart
|
||||
Q_PROPERTY(MessagePartType type MEMBER type CONSTANT FINAL)
|
||||
Q_PROPERTY(QString text MEMBER text CONSTANT FINAL)
|
||||
Q_PROPERTY(QString language MEMBER language CONSTANT FINAL)
|
||||
Q_PROPERTY(QString imageData MEMBER imageData CONSTANT FINAL)
|
||||
Q_PROPERTY(QString mediaType MEMBER mediaType CONSTANT FINAL)
|
||||
QML_VALUE_TYPE(messagePart)
|
||||
public:
|
||||
MessagePartType type;
|
||||
QString text;
|
||||
QString language;
|
||||
QString imageData; // Base64 data or URL
|
||||
QString mediaType; // e.g., "image/png", "image/jpeg"
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
6
ChatView/icons/image-dark.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 304 B |
4
ChatView/icons/settings-icon.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 962 B |
@ -1,4 +1,4 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.4445 9.32233C17.7036 7.28556 21.8559 7.75441 25.8713 9.68854C27.428 9.4057 30.1744 8.91006 31.6477 9.47565C34.5351 10.5309 36.6339 12.7385 37.0285 14.9805C37.81 15.3756 38.4502 15.9932 38.8635 16.751C39.7282 18.3354 39.8498 19.9232 39.2678 21.2061C39.8159 22.277 39.9974 23.4913 39.7844 24.67C39.663 25.4561 39.3556 26.2047 38.8869 26.8555C38.4183 27.5062 37.8013 28.0419 37.0842 28.42C36.8857 28.5274 34.5887 28.6167 34.3713 28.6885C34.6443 32.2168 30.9868 33.5005 27.8889 32.6602L29.0403 36.586L26.0803 36.6885L23.8713 31.6885L21.8713 29.6885C20.125 30.1697 17.0919 30.168 15.76 28.0831C15.639 27.8916 15.5299 27.693 15.4319 27.4893C15.0931 27.5567 14.7474 27.5909 14.4016 27.5919C13.415 27.5918 11.771 27.3037 10.9358 26.7393C10.2736 26.3112 9.74862 25.7095 9.42014 25.004C7.64097 25.2413 6.13134 24.8334 5.14474 23.8262C3.8951 22.5721 3.72021 18.9738 4.37131 16.751C5.22965 13.7841 7.6818 12.9427 12.8713 11.6885C13.3214 11.1426 13.8387 9.69851 14.4445 9.32233ZM21.2551 15.0001L20.9358 16.1114L19.8723 16.4444L19.3401 15.5557L18.4895 16.3331L19.0217 17.2217L18.383 18.2217L17.2131 18.0001L17.0002 18.8887L18.0637 19.4444V20.5557L17.0002 21.1114L17.2131 22.0001L18.383 21.7774L19.0217 22.7774L18.4895 23.6671L19.3401 24.4444L19.8723 23.5557L20.9358 23.8887L21.2551 25.0001H22.7444L23.0637 23.8887L24.1272 23.5557L24.6594 24.4444L25.511 23.6671L24.9787 22.7774L25.6174 21.7774L26.7873 22.0001L27.0002 21.1114L25.9358 20.5557V19.4444L27.0002 18.8887L26.7873 18.0001L25.6174 18.2217L24.9787 17.2217L25.6174 16.4444L24.6594 15.5557L24.1272 16.4444L23.0637 16.1114L22.7444 15.0001H21.2551Z" fill="black"/>
|
||||
<path d="M6 35L38 6" stroke="black" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M14.4445 9.32233C17.7036 7.28556 21.8559 7.75441 25.8713 9.68854C27.428 9.4057 30.1744 8.91006 31.6477 9.47565C34.5351 10.5309 36.6339 12.7385 37.0285 14.9805C37.81 15.3756 38.4502 15.9932 38.8635 16.751C39.7282 18.3354 39.8498 19.9232 39.2678 21.2061C39.8159 22.277 39.9974 23.4913 39.7844 24.67C39.663 25.4561 39.3556 26.2047 38.8869 26.8555C38.4183 27.5062 37.8013 28.0419 37.0842 28.42C36.8857 28.5274 34.5887 28.6167 34.3713 28.6885C34.6443 32.2168 30.9868 33.5005 27.8889 32.6602L29.0403 36.586L26.0803 36.6885L23.8713 31.6885L21.8713 29.6885C20.125 30.1697 17.0919 30.168 15.76 28.0831C15.639 27.8916 15.5299 27.693 15.4319 27.4893C15.0931 27.5567 14.7474 27.5909 14.4016 27.5919C13.415 27.5918 11.771 27.3037 10.9358 26.7393C10.2736 26.3112 9.74862 25.7095 9.42014 25.004C7.64097 25.2413 6.13134 24.8334 5.14474 23.8262C3.8951 22.5721 3.72021 18.9738 4.37131 16.751C5.22965 13.7841 7.6818 12.9427 12.8713 11.6885C13.3214 11.1426 13.8387 9.69851 14.4445 9.32233ZM21.2551 15.0001L20.9358 16.1114L19.8723 16.4444L19.3401 15.5557L18.4895 16.3331L19.0217 17.2217L18.383 18.2217L17.2131 18.0001L17.0002 18.8887L18.0637 19.4444V20.5557L17.0002 21.1114L17.2131 22.0001L18.383 21.7774L19.0217 22.7774L18.4895 23.6671L19.3401 24.4444L19.8723 23.5557L20.9358 23.8887L21.2551 25.0001H22.7444L23.0637 23.8887L24.1272 23.5557L24.6594 24.4444L25.511 23.6671L24.9787 22.7774L25.6174 21.7774L26.7873 22.0001L27.0002 21.1114L25.9358 20.5557V19.4444L27.0002 18.8887L26.7873 18.0001L25.6174 18.2217L24.9787 17.2217L25.6174 16.4444L24.6594 15.5557L24.1272 16.4444L23.0637 16.1114L22.7444 15.0001H21.2551Z" fill="black" fill-opacity="0.6"/>
|
||||
<path d="M6 35L38 6" stroke="black" stroke-opacity="0.6" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
11
ChatView/icons/tools-icon-off.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_82_71)">
|
||||
<path d="M10.7777 0.0435181C14.2316 -0.253961 17.6161 0.979215 20.0629 3.42633C23.4139 6.77767 24.3012 11.6719 22.7299 15.8433C22.9016 15.988 23.0706 16.1419 23.2377 16.3072L42.2221 34.2203C42.2288 34.2268 42.2353 34.2344 42.2426 34.2408C44.4752 36.4735 44.4752 40.1064 42.2426 42.3394C40.0096 44.5717 36.4035 44.5446 34.1713 42.3121C34.1617 42.3031 34.1528 42.2937 34.144 42.2838L16.3871 23.1519C16.2254 22.9894 16.0746 22.8196 15.933 22.6451C11.7604 24.2194 6.86327 23.3335 3.50919 19.98C1.06298 17.5327 -0.171482 14.1483 0.126373 10.6949C0.160109 10.3034 0.41818 9.96685 0.78653 9.83258C1.15602 9.69759 1.57009 9.78945 1.84805 10.067L7.53555 15.7535L13.8402 13.7574L15.8363 7.4527L10.1488 1.7652C9.87057 1.48716 9.77945 1.07345 9.91348 0.704651C10.0489 0.335072 10.3852 0.0774496 10.7777 0.0435181ZM37.3656 34.7496L37.3129 34.9302L37.1586 35.4673L36.8363 35.5679L36.6195 35.2047L36.4623 34.942L36.2357 35.148L35.725 35.6148L35.5746 35.7525L35.6791 35.9283L35.9184 36.3287L35.7104 36.6548L35.1742 36.5543L34.9408 36.5093L34.8852 36.7418L34.7572 37.275L34.7123 37.4644L34.8842 37.5543L35.3891 37.8179V38.1802L34.8842 38.4449L34.7123 38.5347L34.7572 38.7242L34.8852 39.2574L34.9408 39.4898L35.1742 39.4449L35.7104 39.3433L35.9184 39.6695L35.6791 40.0709L35.5746 40.2466L35.725 40.3843L36.2357 40.8511L36.4623 41.0572L36.6195 40.7945L36.8363 40.4302L37.1586 40.5308L37.3129 41.0689L37.3656 41.2496H38.6352L38.6879 41.0689L38.8412 40.5308L39.1635 40.4302L39.3813 40.7945L39.5385 41.0572L39.765 40.8511L40.2758 40.3843L40.4262 40.2466L40.3217 40.0709L40.0815 39.6695L40.2895 39.3433L40.8266 39.4449L41.06 39.4898L41.1156 39.2574L41.2436 38.7242L41.2885 38.5347L41.1166 38.4449L40.6117 38.1802V37.8179L41.1166 37.5543L41.2885 37.4644L41.2436 37.275L41.1156 36.7418L41.06 36.5093L40.8266 36.5543L40.2895 36.6548L40.0815 36.3287L40.3217 35.9283L40.4262 35.7525L40.2758 35.6148L39.765 35.148L39.5385 34.942L39.3813 35.2047L39.1635 35.5679L38.8412 35.4673L38.6879 34.9302L38.6352 34.7496H37.3656Z" fill="black" fill-opacity="0.6"/>
|
||||
<path d="M6 36L38 7" stroke="black" stroke-opacity="0.6" stroke-width="4" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_82_71">
|
||||
<rect width="44" height="44" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
10
ChatView/icons/tools-icon-on.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_82_50)">
|
||||
<path d="M10.7775 0.0441895C14.2315 -0.253375 17.6159 0.979824 20.0627 3.427C23.4135 6.77842 24.3011 11.6726 22.7297 15.844C22.9013 15.9886 23.0714 16.1416 23.2385 16.3069L42.2219 34.2209C42.2285 34.2274 42.2352 34.2341 42.2424 34.2405C44.475 36.4732 44.475 40.1061 42.2424 42.3391C40.0094 44.5715 36.4033 44.5444 34.1711 42.3118C34.1615 42.3028 34.1525 42.2934 34.1437 42.2834L16.3869 23.1516C16.2251 22.9891 16.0745 22.8193 15.9328 22.6448C11.7602 24.2191 6.86304 23.3333 3.50897 19.9797C1.06276 17.5324 -0.171773 14.148 0.12616 10.6946C0.159908 10.3029 0.418723 9.96644 0.787292 9.83228C1.15667 9.69748 1.56997 9.78926 1.84784 10.0667L7.53534 15.7532L13.84 13.7571L15.8361 7.45239L10.1486 1.76489C9.87052 1.48684 9.78022 1.07306 9.91425 0.704346C10.0498 0.334991 10.3852 0.0781082 10.7775 0.0441895ZM37.3654 34.7502L37.3127 34.9309L37.1584 35.468L36.8361 35.5686L36.6193 35.2053L36.4621 34.9426L36.2355 35.1487L35.7248 35.6155L35.5744 35.7532L35.6789 35.929L35.9182 36.3293L35.7101 36.6555L35.174 36.5549L34.9406 36.51L34.8849 36.7424L34.757 37.2756L34.7121 37.4651L34.884 37.5549L35.3889 37.8186V38.1809L34.884 38.4456L34.7121 38.5354L34.757 38.7249L34.8849 39.2581L34.9406 39.4905L35.174 39.4456L35.7101 39.344L35.9182 39.6702L35.6789 40.0715L35.5744 40.2473L35.7248 40.385L36.2355 40.8518L36.4621 41.0579L36.6193 40.7952L36.8361 40.4309L37.1584 40.5315L37.3127 41.0696L37.3654 41.2502H38.6349L38.6877 41.0696L38.841 40.5315L39.1633 40.4309L39.381 40.7952L39.5383 41.0579L39.7648 40.8518L40.2756 40.385L40.426 40.2473L40.3215 40.0715L40.0812 39.6702L40.2892 39.344L40.8264 39.4456L41.0598 39.4905L41.1154 39.2581L41.2433 38.7249L41.2883 38.5354L41.1164 38.4456L40.6115 38.1809V37.8186L41.1164 37.5549L41.2883 37.4651L41.2433 37.2756L41.1154 36.7424L41.0598 36.51L40.8264 36.5549L40.2892 36.6555L40.0812 36.3293L40.3215 35.929L40.426 35.7532L40.2756 35.6155L39.7648 35.1487L39.5383 34.9426L39.381 35.2053L39.1633 35.5686L38.841 35.468L38.6877 34.9309L38.6349 34.7502H37.3654Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_82_50">
|
||||
<rect width="44" height="44" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@ -1,220 +0,0 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import QtQuick
|
||||
import ChatView
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import UIControls
|
||||
|
||||
import "./dialog"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias msgModel: msgCreator.model
|
||||
property alias messageAttachments: attachmentsModel.model
|
||||
property string textFontFamily: Qt.application.font.family
|
||||
property string codeFontFamily: {
|
||||
switch (Qt.platform.os) {
|
||||
case "windows":
|
||||
return "Consolas";
|
||||
case "osx":
|
||||
return "Menlo";
|
||||
case "linux":
|
||||
return "DejaVu Sans Mono";
|
||||
default:
|
||||
return "monospace";
|
||||
}
|
||||
}
|
||||
property int textFontSize: Qt.application.font.pointSize
|
||||
property int codeFontSize: Qt.application.font.pointSize
|
||||
property int textFormat: 0
|
||||
|
||||
property bool isUserMessage: false
|
||||
property int messageIndex: -1
|
||||
|
||||
signal resetChatToMessage(int index)
|
||||
|
||||
height: msgColumn.implicitHeight + 10
|
||||
radius: 8
|
||||
color: isUserMessage ? palette.alternateBase
|
||||
: palette.base
|
||||
|
||||
HoverHandler {
|
||||
id: mouse
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: msgColumn
|
||||
|
||||
x: 5
|
||||
width: parent.width - x
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
id: msgCreator
|
||||
delegate: Loader {
|
||||
id: msgCreatorDelegate
|
||||
// Fix me:
|
||||
// why does `required property MessagePart modelData` not work?
|
||||
required property var modelData
|
||||
|
||||
Layout.preferredWidth: root.width
|
||||
sourceComponent: {
|
||||
// If `required property MessagePart modelData` is used
|
||||
// and conversion to MessagePart fails, you're left
|
||||
// with a nullptr. This tests that to prevent crashing.
|
||||
if(!modelData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch(modelData.type) {
|
||||
case MessagePartType.Text: return textComponent;
|
||||
case MessagePartType.Code: return codeBlockComponent;
|
||||
default: return textComponent;
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: textComponent
|
||||
TextComponent {
|
||||
itemData: msgCreatorDelegate.modelData
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: codeBlockComponent
|
||||
CodeBlockComponent {
|
||||
itemData: msgCreatorDelegate.modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
id: attachmentsFlow
|
||||
|
||||
Layout.fillWidth: true
|
||||
visible: attachmentsModel.model && attachmentsModel.model.length > 0
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
id: attachmentsModel
|
||||
|
||||
delegate: Rectangle {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: userMessageMarker
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 3
|
||||
height: root.height - root.radius
|
||||
color: "#92BD6C"
|
||||
radius: root.radius
|
||||
visible: root.isUserMessage
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: stopButtonId
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
}
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/undo-changes-button.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
visible: root.isUserMessage && mouse.hovered
|
||||
onClicked: function() {
|
||||
root.resetChatToMessage(root.messageIndex)
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Reset chat to this message and edit")
|
||||
ToolTip.delay: 500
|
||||
}
|
||||
|
||||
component TextComponent : TextBlock {
|
||||
required property var itemData
|
||||
height: implicitHeight + 10
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: 10
|
||||
text: textFormat == Text.MarkdownText ? utils.getSafeMarkdownText(itemData.text)
|
||||
: itemData.text
|
||||
font.family: root.textFontFamily
|
||||
font.pointSize: root.textFontSize
|
||||
textFormat: {
|
||||
if (root.textFormat == 0) {
|
||||
return Text.MarkdownText
|
||||
} else if (root.textFormat == 1) {
|
||||
return Text.RichText
|
||||
} else {
|
||||
return Text.PlainText
|
||||
}
|
||||
}
|
||||
|
||||
ChatUtils {
|
||||
id: utils
|
||||
}
|
||||
}
|
||||
|
||||
component CodeBlockComponent : CodeBlock {
|
||||
id: codeblock
|
||||
|
||||
required property var itemData
|
||||
anchors {
|
||||
left: parent.left
|
||||
leftMargin: 10
|
||||
right: parent.right
|
||||
rightMargin: 10
|
||||
}
|
||||
|
||||
code: itemData.text
|
||||
language: itemData.language
|
||||
codeFontFamily: root.codeFontFamily
|
||||
codeFontSize: root.codeFontSize
|
||||
}
|
||||
}
|
||||
@ -24,7 +24,8 @@ import QtQuick.Layouts
|
||||
import ChatView
|
||||
import UIControls
|
||||
import Qt.labs.platform as Platform
|
||||
import "./parts"
|
||||
|
||||
import "./chatparts"
|
||||
|
||||
ChatRootView {
|
||||
id: root
|
||||
@ -57,6 +58,25 @@ ChatRootView {
|
||||
color: palette.window
|
||||
}
|
||||
|
||||
SplitDropZone {
|
||||
anchors.fill: parent
|
||||
z: 99
|
||||
|
||||
onFilesDroppedToAttach: (urlStrings) => {
|
||||
var localPaths = root.convertUrlsToLocalPaths(urlStrings)
|
||||
if (localPaths.length > 0) {
|
||||
root.addFilesToAttachList(localPaths)
|
||||
}
|
||||
}
|
||||
|
||||
onFilesDroppedToLink: (urlStrings) => {
|
||||
var localPaths = root.convertUrlsToLocalPaths(urlStrings)
|
||||
if (localPaths.length > 0) {
|
||||
root.addFilesToLinkList(localPaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
@ -84,18 +104,31 @@ ChatRootView {
|
||||
checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false
|
||||
onCheckedChanged: _chatview.isPin = topBar.pinButton.checked
|
||||
}
|
||||
agentModeSwitch {
|
||||
checked: root.isAgentMode
|
||||
enabled: root.toolsSupportEnabled
|
||||
onToggled: {
|
||||
root.isAgentMode = agentModeSwitch.checked
|
||||
toolsButton {
|
||||
checked: root.useTools
|
||||
onCheckedChanged: {
|
||||
root.useTools = toolsButton.checked
|
||||
}
|
||||
}
|
||||
thinkingMode {
|
||||
checked: root.isThinkingMode
|
||||
checked: root.useThinking
|
||||
enabled: root.isThinkingSupport
|
||||
onCheckedChanged: {
|
||||
root.isThinkingMode = thinkingMode.checked
|
||||
root.useThinking = thinkingMode.checked
|
||||
}
|
||||
}
|
||||
settingsButton.onClicked: root.openSettings()
|
||||
configSelector {
|
||||
model: root.availableConfigurations
|
||||
displayText: root.currentConfiguration
|
||||
onActivated: function(index) {
|
||||
if (index > 0) {
|
||||
root.applyConfiguration(root.availableConfigurations[index])
|
||||
}
|
||||
}
|
||||
|
||||
popup.onAboutToShow: {
|
||||
root.loadAvailableConfigurations()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -169,6 +202,8 @@ ChatRootView {
|
||||
width: parent.width
|
||||
msgModel: root.chatModel.processMessageContent(model.content)
|
||||
messageAttachments: model.attachments
|
||||
messageImages: model.images
|
||||
chatFilePath: root.chatFilePath()
|
||||
isUserMessage: model.roleType === ChatModel.User
|
||||
messageIndex: index
|
||||
textFontFamily: root.textFontFamily
|
||||
@ -188,7 +223,7 @@ ChatRootView {
|
||||
Component {
|
||||
id: toolMessageComponent
|
||||
|
||||
ToolStatusItem {
|
||||
ToolBlock {
|
||||
id: toolsItem
|
||||
|
||||
width: parent.width
|
||||
@ -208,7 +243,7 @@ ChatRootView {
|
||||
Component {
|
||||
id: fileEditMessageComponent
|
||||
|
||||
FileEditItem {
|
||||
FileEditBlock {
|
||||
width: parent.width
|
||||
editContent: model.content
|
||||
|
||||
@ -233,7 +268,7 @@ ChatRootView {
|
||||
Component {
|
||||
id: thinkingMessageComponent
|
||||
|
||||
ThinkingStatusItem {
|
||||
ThinkingBlock {
|
||||
id: thinking
|
||||
|
||||
width: parent.width
|
||||
@ -393,6 +428,7 @@ ChatRootView {
|
||||
onCheckedChanged: root.setIsSyncOpenFiles(bottomBar.syncOpenFiles.checked)
|
||||
}
|
||||
attachFiles.onClicked: root.showAttachFilesDialog()
|
||||
attachImages.onClicked: root.showAddImageDialog()
|
||||
linkFiles.onClicked: root.showLinkFilesDialog()
|
||||
}
|
||||
}
|
||||
|
||||
394
ChatView/qml/chatparts/ChatItem.qml
Normal file
@ -0,0 +1,394 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import QtQuick
|
||||
import ChatView
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import UIControls
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias msgModel: msgCreator.model
|
||||
property alias messageAttachments: attachmentsModel.model
|
||||
property alias messageImages: imagesModel.model
|
||||
property string chatFilePath: ""
|
||||
property string textFontFamily: Qt.application.font.family
|
||||
property string codeFontFamily: {
|
||||
switch (Qt.platform.os) {
|
||||
case "windows":
|
||||
return "Consolas";
|
||||
case "osx":
|
||||
return "Menlo";
|
||||
case "linux":
|
||||
return "DejaVu Sans Mono";
|
||||
default:
|
||||
return "monospace";
|
||||
}
|
||||
}
|
||||
property int textFontSize: Qt.application.font.pointSize
|
||||
property int codeFontSize: Qt.application.font.pointSize
|
||||
property int textFormat: 0
|
||||
|
||||
property bool isUserMessage: false
|
||||
property int messageIndex: -1
|
||||
|
||||
signal resetChatToMessage(int index)
|
||||
|
||||
height: msgColumn.implicitHeight + 10
|
||||
radius: 8
|
||||
color: isUserMessage ? palette.alternateBase
|
||||
: palette.base
|
||||
|
||||
HoverHandler {
|
||||
id: mouse
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: msgColumn
|
||||
|
||||
x: 5
|
||||
width: parent.width - x
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
id: msgCreator
|
||||
delegate: Loader {
|
||||
id: msgCreatorDelegate
|
||||
// Fix me:
|
||||
// why does `required property MessagePart modelData` not work?
|
||||
required property var modelData
|
||||
|
||||
Layout.preferredWidth: root.width
|
||||
sourceComponent: {
|
||||
// If `required property MessagePart modelData` is used
|
||||
// and conversion to MessagePart fails, you're left
|
||||
// with a nullptr. This tests that to prevent crashing.
|
||||
if(!modelData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch(modelData.type) {
|
||||
case MessagePartType.Text: return textComponent;
|
||||
case MessagePartType.Code: return codeBlockComponent;
|
||||
default: return textComponent;
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: textComponent
|
||||
TextComponent {
|
||||
itemData: msgCreatorDelegate.modelData
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: codeBlockComponent
|
||||
CodeBlockComponent {
|
||||
itemData: msgCreatorDelegate.modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
id: attachmentsFlow
|
||||
|
||||
Layout.fillWidth: true
|
||||
visible: attachmentsModel.model && attachmentsModel.model.length > 0
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
id: attachmentsModel
|
||||
|
||||
delegate: AttachmentComponent {
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
itemData: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
id: imagesFlow
|
||||
|
||||
Layout.fillWidth: true
|
||||
visible: imagesModel.model && imagesModel.model.length > 0
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
spacing: 10
|
||||
|
||||
Repeater {
|
||||
id: imagesModel
|
||||
|
||||
delegate: ImageComponent {
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
itemData: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: userMessageMarker
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 3
|
||||
height: root.height - root.radius
|
||||
color: "#92BD6C"
|
||||
radius: root.radius
|
||||
visible: root.isUserMessage
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: stopButtonId
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
}
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/undo-changes-button.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
visible: root.isUserMessage && mouse.hovered
|
||||
onClicked: function() {
|
||||
root.resetChatToMessage(root.messageIndex)
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Reset chat to this message and edit")
|
||||
ToolTip.delay: 500
|
||||
}
|
||||
|
||||
component TextComponent : TextBlock {
|
||||
required property var itemData
|
||||
height: implicitHeight + 10
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: 10
|
||||
text: textFormat == Text.MarkdownText ? utils.getSafeMarkdownText(itemData.text)
|
||||
: itemData.text
|
||||
font.family: root.textFontFamily
|
||||
font.pointSize: root.textFontSize
|
||||
textFormat: {
|
||||
if (root.textFormat == 0) {
|
||||
return Text.MarkdownText
|
||||
} else if (root.textFormat == 1) {
|
||||
return Text.RichText
|
||||
} else {
|
||||
return Text.PlainText
|
||||
}
|
||||
}
|
||||
|
||||
ChatUtils {
|
||||
id: utils
|
||||
}
|
||||
}
|
||||
|
||||
component CodeBlockComponent : CodeBlock {
|
||||
id: codeblock
|
||||
|
||||
required property var itemData
|
||||
anchors {
|
||||
left: parent.left
|
||||
leftMargin: 10
|
||||
right: parent.right
|
||||
rightMargin: 10
|
||||
}
|
||||
|
||||
code: itemData.text
|
||||
language: itemData.language
|
||||
codeFontFamily: root.codeFontFamily
|
||||
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
|
||||
|
||||
readonly property int maxImageWidth: Math.min(400, root.width - 40)
|
||||
readonly property int maxImageHeight: 300
|
||||
|
||||
width: Math.min(imageDisplay.implicitWidth, maxImageWidth) + 16
|
||||
height: imageDisplay.implicitHeight + fileNameText.implicitHeight + 16
|
||||
radius: 4
|
||||
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
|
||||
spacing: 4
|
||||
|
||||
Image {
|
||||
id: imageDisplay
|
||||
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.maximumWidth: parent.parent.maxImageWidth
|
||||
Layout.maximumHeight: parent.parent.maxImageHeight
|
||||
|
||||
source: itemData.imageUrl ? itemData.imageUrl : ""
|
||||
|
||||
sourceSize.width: parent.parent.maxImageWidth
|
||||
sourceSize.height: parent.parent.maxImageHeight
|
||||
fillMode: Image.PreserveAspectFit
|
||||
cache: true
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
mipmap: true
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: imageDisplay.status === Image.Loading
|
||||
visible: running
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("Failed to load image")
|
||||
visible: imageDisplay.status === Image.Error
|
||||
color: palette.placeholderText
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: fileNameText
|
||||
|
||||
Layout.fillWidth: true
|
||||
text: itemData.fileName || ""
|
||||
color: palette.text
|
||||
font.pointSize: root.textFontSize - 1
|
||||
elide: Text.ElideMiddle
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -40,19 +40,68 @@ Flow {
|
||||
Repeater {
|
||||
id: attachRepeater
|
||||
|
||||
delegate: Rectangle {
|
||||
delegate: FileItem {
|
||||
id: fileItem
|
||||
|
||||
required property int index
|
||||
required property string modelData
|
||||
|
||||
filePath: modelData
|
||||
|
||||
height: 30
|
||||
width: contentRow.width + 10
|
||||
radius: 4
|
||||
color: palette.button
|
||||
border.width: 1
|
||||
border.color: mouse.hovered ? palette.highlight : root.accentColor
|
||||
|
||||
HoverHandler {
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 4
|
||||
color: palette.button
|
||||
border.width: 1
|
||||
border.color: mouse.containsMouse ? palette.highlight : root.accentColor
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
contextMenu.popup()
|
||||
} else if (mouse.button === Qt.MiddleButton ||
|
||||
(mouse.button === Qt.LeftButton && (mouse.modifiers & Qt.ControlModifier))) {
|
||||
root.removeFileFromListByIndex(fileItem.index)
|
||||
} else if (mouse.modifiers & Qt.ShiftModifier) {
|
||||
fileItem.openFileInExternalEditor()
|
||||
} else {
|
||||
fileItem.openFileInEditor()
|
||||
}
|
||||
}
|
||||
|
||||
ToolTip.visible: containsMouse
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: "Click: Open in Qt Creator\nShift+Click: Open in external editor\nCtrl+Click / Middle Click: Remove"
|
||||
}
|
||||
|
||||
Menu {
|
||||
id: contextMenu
|
||||
|
||||
MenuItem {
|
||||
text: "Open in Qt Creator"
|
||||
onTriggered: fileItem.openFileInEditor()
|
||||
}
|
||||
|
||||
MenuItem {
|
||||
text: "Open in External Editor"
|
||||
onTriggered: fileItem.openFileInExternalEditor()
|
||||
}
|
||||
|
||||
MenuSeparator {}
|
||||
|
||||
MenuItem {
|
||||
text: "Remove"
|
||||
onTriggered: root.removeFileFromListByIndex(fileItem.index)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
@ -29,9 +29,9 @@ Rectangle {
|
||||
property alias sendButton: sendButtonId
|
||||
property alias syncOpenFiles: syncOpenFilesId
|
||||
property alias attachFiles: attachFilesId
|
||||
property alias attachImages: attachImagesId
|
||||
property alias linkFiles: linkFilesId
|
||||
|
||||
|
||||
color: palette.window.hslLightness > 0.5 ?
|
||||
Qt.darker(palette.window, 1.1) :
|
||||
Qt.lighter(palette.window, 1.1)
|
||||
@ -73,6 +73,19 @@ Rectangle {
|
||||
ToolTip.text: qsTr("Attach file to message")
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: attachImagesId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/image-dark.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Attach image to message")
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: linkFilesId
|
||||
|
||||
291
ChatView/qml/controls/SplitDropZone.qml
Normal file
@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright (C) 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/>.
|
||||
*/
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
signal filesDroppedToAttach(var urlStrings)
|
||||
signal filesDroppedToLink(var urlStrings)
|
||||
|
||||
property string activeZone: ""
|
||||
property int filesCount: 0
|
||||
property bool isDragActive: false
|
||||
|
||||
Item {
|
||||
id: splitDropOverlay
|
||||
|
||||
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
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
color: root.activeZone === "left"
|
||||
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
|
||||
border.width: root.activeZone === "left" ? 3 : 2
|
||||
border.color: root.activeZone === "left"
|
||||
? palette.highlight
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 15
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Attach")
|
||||
font.pixelSize: 24
|
||||
font.bold: true
|
||||
color: root.activeZone === "left" ? palette.highlightedText : palette.text
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Images & Text Files")
|
||||
font.pixelSize: 14
|
||||
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 } }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: rightZone
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
color: root.activeZone === "right"
|
||||
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
|
||||
border.width: root.activeZone === "right" ? 3 : 2
|
||||
border.color: root.activeZone === "right"
|
||||
? palette.highlight
|
||||
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 15
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("LINK")
|
||||
font.pixelSize: 24
|
||||
font.bold: true
|
||||
color: root.activeZone === "right" ? palette.highlightedText : palette.text
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Text Files")
|
||||
font.pixelSize: 14
|
||||
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 } }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors {
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: 2
|
||||
color: palette.mid
|
||||
opacity: 0.4
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: leftDropArea
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
root.activeZone = "left"
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rightDropArea
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: parent.width / 2
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
root.activeZone = "right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropArea {
|
||||
id: globalDropArea
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
onEntered: (drag) => {
|
||||
if (drag.hasUrls) {
|
||||
root.isDragActive = true
|
||||
root.filesCount = drag.urls.length
|
||||
splitDropOverlay.visible = true
|
||||
splitDropOverlay.opacity = 1
|
||||
root.activeZone = ""
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
root.isDragActive = false
|
||||
root.filesCount = 0
|
||||
splitDropOverlay.opacity = 0
|
||||
|
||||
Qt.callLater(function() {
|
||||
if (!root.isDragActive) {
|
||||
splitDropOverlay.visible = false
|
||||
root.activeZone = ""
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onPositionChanged: (drag) => {
|
||||
if (drag.hasUrls) {
|
||||
root.activeZone = drag.x < globalDropArea.width / 2 ? "left" : "right"
|
||||
}
|
||||
}
|
||||
|
||||
onDropped: (drop) => {
|
||||
const targetZone = root.activeZone
|
||||
root.isDragActive = false
|
||||
root.filesCount = 0
|
||||
splitDropOverlay.opacity = 0
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,9 +34,11 @@ Rectangle {
|
||||
property alias openChatHistory: openChatHistoryId
|
||||
property alias pinButton: pinButtonId
|
||||
property alias rulesButton: rulesButtonId
|
||||
property alias agentModeSwitch: agentModeSwitchId
|
||||
property alias toolsButton: toolsButtonId
|
||||
property alias thinkingMode: thinkingModeId
|
||||
property alias settingsButton: settingsButtonId
|
||||
property alias activeRulesCount: activeRulesCountId.text
|
||||
property alias configSelector: configSelectorId
|
||||
|
||||
color: palette.window.hslLightness > 0.5 ?
|
||||
Qt.darker(palette.window, 1.1) :
|
||||
@ -52,7 +54,8 @@ Rectangle {
|
||||
spacing: 10
|
||||
|
||||
Row {
|
||||
height: agentModeSwitchId.height
|
||||
id: firstRow
|
||||
|
||||
spacing: 10
|
||||
|
||||
QoAButton {
|
||||
@ -74,23 +77,44 @@ Rectangle {
|
||||
: qsTr("Pin chat window to the top")
|
||||
}
|
||||
|
||||
QoATextSlider {
|
||||
id: agentModeSwitchId
|
||||
QoAComboBox {
|
||||
id: configSelectorId
|
||||
|
||||
implicitHeight: 25
|
||||
|
||||
model: []
|
||||
currentIndex: 0
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Switch AI configuration")
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: toolsButtonId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
leftText: "chat"
|
||||
rightText: "AI Agent"
|
||||
checkable: true
|
||||
opacity: enabled ? 1.0 : 0.2
|
||||
|
||||
icon {
|
||||
source: checked ? "qrc:/qt/qml/ChatView/icons/tools-icon-on.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/tools-icon-off.svg"
|
||||
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: {
|
||||
if (!agentModeSwitchId.enabled) {
|
||||
if (!toolsButtonId.enabled) {
|
||||
return qsTr("Tools are disabled in General Settings")
|
||||
}
|
||||
return checked
|
||||
? qsTr("Agent Mode: AI can use tools to read files, search project, and build code")
|
||||
: qsTr("Chat Mode: Simple conversation without tool access")
|
||||
? qsTr("Tools enabled: AI can use tools to read files, search project, and build code")
|
||||
: qsTr("Tools disabled: Simple conversation without tool access")
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,10 +140,27 @@ Rectangle {
|
||||
: qsTr("Thinking Mode disabled"))
|
||||
: qsTr("Thinking Mode is not available for this provider")
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: settingsButtonId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/settings-icon.svg"
|
||||
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Open Chat Assistant Settings")
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
height: agentModeSwitchId.height
|
||||
height: firstRow.height
|
||||
width: recentPathId.width
|
||||
|
||||
Text {
|
||||
@ -143,7 +184,10 @@ Rectangle {
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: secondRow
|
||||
|
||||
Layout.preferredWidth: root.width
|
||||
Layout.preferredHeight: firstRow.height
|
||||
|
||||
spacing: 10
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
@ -51,6 +51,8 @@ void ConfigurationManager::updateTemplateDescription(const Utils::StringAspect &
|
||||
m_generalSettings.ccTemplateDescription.setValue(templ->description());
|
||||
} else if (&templateAspect == &m_generalSettings.caTemplate) {
|
||||
m_generalSettings.caTemplateDescription.setValue(templ->description());
|
||||
} else if (&templateAspect == &m_generalSettings.qrTemplate) {
|
||||
m_generalSettings.qrTemplateDescription.setValue(templ->description());
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,6 +60,7 @@ void ConfigurationManager::updateAllTemplateDescriptions()
|
||||
{
|
||||
updateTemplateDescription(m_generalSettings.ccTemplate);
|
||||
updateTemplateDescription(m_generalSettings.caTemplate);
|
||||
updateTemplateDescription(m_generalSettings.qrTemplate);
|
||||
}
|
||||
|
||||
void ConfigurationManager::checkTemplate(const Utils::StringAspect &templateAspect)
|
||||
@ -94,12 +97,16 @@ void ConfigurationManager::setupConnections()
|
||||
|
||||
connect(&m_generalSettings.ccSelectProvider, &Button::clicked, this, &Config::selectProvider);
|
||||
connect(&m_generalSettings.caSelectProvider, &Button::clicked, this, &Config::selectProvider);
|
||||
connect(&m_generalSettings.qrSelectProvider, &Button::clicked, this, &Config::selectProvider);
|
||||
connect(&m_generalSettings.ccSelectModel, &Button::clicked, this, &Config::selectModel);
|
||||
connect(&m_generalSettings.caSelectModel, &Button::clicked, this, &Config::selectModel);
|
||||
connect(&m_generalSettings.qrSelectModel, &Button::clicked, this, &Config::selectModel);
|
||||
connect(&m_generalSettings.ccSelectTemplate, &Button::clicked, this, &Config::selectTemplate);
|
||||
connect(&m_generalSettings.caSelectTemplate, &Button::clicked, this, &Config::selectTemplate);
|
||||
connect(&m_generalSettings.qrSelectTemplate, &Button::clicked, this, &Config::selectTemplate);
|
||||
connect(&m_generalSettings.ccSetUrl, &Button::clicked, this, &Config::selectUrl);
|
||||
connect(&m_generalSettings.caSetUrl, &Button::clicked, this, &Config::selectUrl);
|
||||
connect(&m_generalSettings.qrSetUrl, &Button::clicked, this, &Config::selectUrl);
|
||||
|
||||
connect(
|
||||
&m_generalSettings.ccPreset1SelectProvider, &Button::clicked, this, &Config::selectProvider);
|
||||
@ -115,6 +122,10 @@ void ConfigurationManager::setupConnections()
|
||||
connect(&m_generalSettings.caTemplate, &Utils::StringAspect::changed, this, [this]() {
|
||||
updateTemplateDescription(m_generalSettings.caTemplate);
|
||||
});
|
||||
|
||||
connect(&m_generalSettings.qrTemplate, &Utils::StringAspect::changed, this, [this]() {
|
||||
updateTemplateDescription(m_generalSettings.qrTemplate);
|
||||
});
|
||||
}
|
||||
|
||||
void ConfigurationManager::selectProvider()
|
||||
@ -129,6 +140,8 @@ void ConfigurationManager::selectProvider()
|
||||
? m_generalSettings.ccProvider
|
||||
: settingsButton == &m_generalSettings.ccPreset1SelectProvider
|
||||
? m_generalSettings.ccPreset1Provider
|
||||
: settingsButton == &m_generalSettings.qrSelectProvider
|
||||
? m_generalSettings.qrProvider
|
||||
: m_generalSettings.caProvider;
|
||||
|
||||
QTimer::singleShot(0, this, [this, providersList, &targetSettings] {
|
||||
@ -145,17 +158,21 @@ void ConfigurationManager::selectModel()
|
||||
|
||||
const bool isCodeCompletion = (settingsButton == &m_generalSettings.ccSelectModel);
|
||||
const bool isPreset1 = (settingsButton == &m_generalSettings.ccPreset1SelectModel);
|
||||
const bool isQuickRefactor = (settingsButton == &m_generalSettings.qrSelectModel);
|
||||
|
||||
const QString providerName = isCodeCompletion ? m_generalSettings.ccProvider.volatileValue()
|
||||
: isPreset1 ? m_generalSettings.ccPreset1Provider.volatileValue()
|
||||
: isQuickRefactor ? m_generalSettings.qrProvider.volatileValue()
|
||||
: m_generalSettings.caProvider.volatileValue();
|
||||
|
||||
const auto providerUrl = isCodeCompletion ? m_generalSettings.ccUrl.volatileValue()
|
||||
: isPreset1 ? m_generalSettings.ccPreset1Url.volatileValue()
|
||||
: isQuickRefactor ? m_generalSettings.qrUrl.volatileValue()
|
||||
: m_generalSettings.caUrl.volatileValue();
|
||||
|
||||
auto &targetSettings = isCodeCompletion ? m_generalSettings.ccModel
|
||||
: isPreset1 ? m_generalSettings.ccPreset1Model
|
||||
: isQuickRefactor ? m_generalSettings.qrModel
|
||||
: m_generalSettings.caModel;
|
||||
|
||||
if (auto provider = m_providersManager.getProviderByName(providerName)) {
|
||||
@ -186,8 +203,10 @@ void ConfigurationManager::selectTemplate()
|
||||
|
||||
const bool isCodeCompletion = (settingsButton == &m_generalSettings.ccSelectTemplate);
|
||||
const bool isPreset1 = (settingsButton == &m_generalSettings.ccPreset1SelectTemplate);
|
||||
const bool isQuickRefactor = (settingsButton == &m_generalSettings.qrSelectTemplate);
|
||||
const QString providerName = isCodeCompletion ? m_generalSettings.ccProvider.volatileValue()
|
||||
: isPreset1 ? m_generalSettings.ccPreset1Provider.volatileValue()
|
||||
: isQuickRefactor ? m_generalSettings.qrProvider.volatileValue()
|
||||
: m_generalSettings.caProvider.volatileValue();
|
||||
auto providerID = m_providersManager.getProviderByName(providerName)->providerID();
|
||||
|
||||
@ -197,6 +216,7 @@ void ConfigurationManager::selectTemplate()
|
||||
|
||||
auto &targetSettings = isCodeCompletion ? m_generalSettings.ccTemplate
|
||||
: isPreset1 ? m_generalSettings.ccPreset1Template
|
||||
: isQuickRefactor ? m_generalSettings.qrTemplate
|
||||
: m_generalSettings.caTemplate;
|
||||
|
||||
QTimer::singleShot(0, &m_generalSettings, [this, templateList, &targetSettings]() {
|
||||
@ -221,6 +241,8 @@ void ConfigurationManager::selectUrl()
|
||||
auto &targetSettings = (settingsButton == &m_generalSettings.ccSetUrl) ? m_generalSettings.ccUrl
|
||||
: settingsButton == &m_generalSettings.ccPreset1SetUrl
|
||||
? m_generalSettings.ccPreset1Url
|
||||
: settingsButton == &m_generalSettings.qrSetUrl
|
||||
? m_generalSettings.qrUrl
|
||||
: m_generalSettings.caUrl;
|
||||
|
||||
QTimer::singleShot(0, &m_generalSettings, [this, urls, &targetSettings]() {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
/*
|
||||
* Copyright (C) 2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
*
|
||||
|
||||
@ -86,7 +86,22 @@ void LLMClientInterface::handleRequestFailed(const QString &requestId, const QSt
|
||||
return;
|
||||
|
||||
LOG_MESSAGE(QString("Request %1 failed: %2").arg(requestId, error));
|
||||
|
||||
// Send LSP error response to client
|
||||
const RequestContext &ctx = it.value();
|
||||
QJsonObject response;
|
||||
response["jsonrpc"] = "2.0";
|
||||
response[LanguageServerProtocol::idKey] = ctx.originalRequest["id"];
|
||||
|
||||
QJsonObject errorObject;
|
||||
errorObject["code"] = -32603; // Internal error code
|
||||
errorObject["message"] = error;
|
||||
response["error"] = errorObject;
|
||||
|
||||
emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response));
|
||||
|
||||
m_activeRequests.erase(it);
|
||||
m_performanceLogger.endTimeMeasurement(requestId);
|
||||
}
|
||||
|
||||
void LLMClientInterface::sendData(const QByteArray &data)
|
||||
@ -194,12 +209,32 @@ void LLMClientInterface::handleExit(const QJsonObject &request)
|
||||
emit finished();
|
||||
}
|
||||
|
||||
void LLMClientInterface::sendErrorResponse(const QJsonObject &request, const QString &errorMessage)
|
||||
{
|
||||
QJsonObject response;
|
||||
response["jsonrpc"] = "2.0";
|
||||
response[LanguageServerProtocol::idKey] = request["id"];
|
||||
|
||||
QJsonObject errorObject;
|
||||
errorObject["code"] = -32603; // Internal error code
|
||||
errorObject["message"] = errorMessage;
|
||||
response["error"] = errorObject;
|
||||
|
||||
emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response));
|
||||
|
||||
// End performance measurement if it was started
|
||||
QString requestId = request["id"].toString();
|
||||
m_performanceLogger.endTimeMeasurement(requestId);
|
||||
}
|
||||
|
||||
void LLMClientInterface::handleCompletion(const QJsonObject &request)
|
||||
{
|
||||
auto filePath = Context::extractFilePathFromRequest(request);
|
||||
auto documentInfo = m_documentReader.readDocument(filePath);
|
||||
if (!documentInfo.document) {
|
||||
LOG_MESSAGE("Error: Document is not available for" + filePath);
|
||||
QString error = QString("Document is not available: %1").arg(filePath);
|
||||
LOG_MESSAGE("Error: " + error);
|
||||
sendErrorResponse(request, error);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -217,7 +252,9 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
|
||||
const auto provider = m_providerRegistry.getProviderByName(providerName);
|
||||
|
||||
if (!provider) {
|
||||
LOG_MESSAGE(QString("No provider found with name: %1").arg(providerName));
|
||||
QString error = QString("No provider found with name: %1").arg(providerName);
|
||||
LOG_MESSAGE(error);
|
||||
sendErrorResponse(request, error);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -227,7 +264,9 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
|
||||
auto promptTemplate = m_promptProvider->getTemplateByName(templateName);
|
||||
|
||||
if (!promptTemplate) {
|
||||
LOG_MESSAGE(QString("No template found with name: %1").arg(templateName));
|
||||
QString error = QString("No template found with name: %1").arg(templateName);
|
||||
LOG_MESSAGE(error);
|
||||
sendErrorResponse(request, error);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -309,12 +348,15 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
|
||||
promptTemplate,
|
||||
updatedContext,
|
||||
LLMCore::RequestType::CodeCompletion,
|
||||
false,
|
||||
false);
|
||||
|
||||
auto errors = config.provider->validateRequest(config.providerRequest, promptTemplate->type());
|
||||
if (!errors.isEmpty()) {
|
||||
LOG_MESSAGE("Validate errors for fim request:");
|
||||
QString error = QString("Request validation failed: %1").arg(errors.join("; "));
|
||||
LOG_MESSAGE("Validate errors for request:");
|
||||
LOG_MESSAGES(errors);
|
||||
sendErrorResponse(request, error);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -418,12 +460,20 @@ void LLMClientInterface::sendCompletionToClient(
|
||||
: completion;
|
||||
}
|
||||
|
||||
if (processedCompletion.endsWith('\n')) {
|
||||
QString withoutTrailing = processedCompletion.chopped(1);
|
||||
if (!withoutTrailing.contains('\n')) {
|
||||
LOG_MESSAGE(QString("Removed trailing newline from single-line completion"));
|
||||
processedCompletion = withoutTrailing;
|
||||
}
|
||||
}
|
||||
|
||||
completionItem[LanguageServerProtocol::textKey] = processedCompletion;
|
||||
|
||||
QJsonObject range;
|
||||
range["start"] = position;
|
||||
QJsonObject end = position;
|
||||
end["character"] = position["character"].toInt() + processedCompletion.length();
|
||||
range["end"] = end;
|
||||
range["end"] = position;
|
||||
|
||||
completionItem[LanguageServerProtocol::rangeKey] = range;
|
||||
completionItem[LanguageServerProtocol::positionKey] = position;
|
||||
completions.append(completionItem);
|
||||
|
||||
@ -77,6 +77,7 @@ private:
|
||||
void handleInitialized(const QJsonObject &request);
|
||||
void handleExit(const QJsonObject &request);
|
||||
void handleCancelRequest();
|
||||
void sendErrorResponse(const QJsonObject &request, const QString &errorMessage);
|
||||
|
||||
struct RequestContext
|
||||
{
|
||||
|
||||
@ -29,34 +29,57 @@
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
QString mergeWithRightText(const QString &suggestion, const QString &rightText)
|
||||
static QStringList extractTokens(const QString &str)
|
||||
{
|
||||
if (suggestion.isEmpty() || rightText.isEmpty()) {
|
||||
return suggestion;
|
||||
}
|
||||
|
||||
int j = 0;
|
||||
QString processed = rightText;
|
||||
QSet<int> matchedPositions;
|
||||
|
||||
for (int i = 0; i < suggestion.length() && j < processed.length(); ++i) {
|
||||
if (suggestion[i] == processed[j]) {
|
||||
matchedPositions.insert(j);
|
||||
++j;
|
||||
QStringList tokens;
|
||||
QString currentToken;
|
||||
for (const QChar &ch : str) {
|
||||
if (ch.isLetterOrNumber() || ch == '_') {
|
||||
currentToken += ch;
|
||||
} else {
|
||||
if (!currentToken.isEmpty() && currentToken.length() > 1) {
|
||||
tokens.append(currentToken);
|
||||
}
|
||||
currentToken.clear();
|
||||
}
|
||||
}
|
||||
if (!currentToken.isEmpty() && currentToken.length() > 1) {
|
||||
tokens.append(currentToken);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
if (matchedPositions.isEmpty()) {
|
||||
return suggestion + rightText;
|
||||
int LLMSuggestion::calculateReplaceLength(const QString &suggestion,
|
||||
const QString &rightText,
|
||||
const QString &entireLine)
|
||||
{
|
||||
if (rightText.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
QList<int> positions = matchedPositions.values();
|
||||
std::sort(positions.begin(), positions.end(), std::greater<int>());
|
||||
for (int pos : positions) {
|
||||
processed.remove(pos, 1);
|
||||
QString structuralChars = "{}[]()<>;,";
|
||||
bool hasStructuralOverlap = false;
|
||||
for (const QChar &ch : structuralChars) {
|
||||
if (suggestion.contains(ch) && rightText.contains(ch)) {
|
||||
hasStructuralOverlap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStructuralOverlap) {
|
||||
return rightText.length();
|
||||
}
|
||||
|
||||
return suggestion;
|
||||
const QStringList suggestionTokens = extractTokens(suggestion);
|
||||
const QStringList lineTokens = extractTokens(entireLine);
|
||||
|
||||
for (const auto &token : suggestionTokens) {
|
||||
if (lineTokens.contains(token)) {
|
||||
return rightText.length();
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
LLMSuggestion::LLMSuggestion(
|
||||
@ -66,10 +89,8 @@ LLMSuggestion::LLMSuggestion(
|
||||
const auto &data = suggestions[currentCompletion];
|
||||
|
||||
int startPos = data.range.begin.toPositionInDocument(sourceDocument);
|
||||
int endPos = data.range.end.toPositionInDocument(sourceDocument);
|
||||
|
||||
startPos = qBound(0, startPos, sourceDocument->characterCount());
|
||||
endPos = qBound(startPos, endPos, sourceDocument->characterCount());
|
||||
|
||||
QTextCursor cursor(sourceDocument);
|
||||
cursor.setPosition(startPos);
|
||||
@ -77,17 +98,27 @@ LLMSuggestion::LLMSuggestion(
|
||||
QString blockText = block.text();
|
||||
|
||||
int cursorPositionInBlock = cursor.positionInBlock();
|
||||
|
||||
QString leftText = blockText.left(cursorPositionInBlock);
|
||||
QString rightText = blockText.mid(cursorPositionInBlock);
|
||||
|
||||
if (!data.text.contains('\n')) {
|
||||
QString processedRightText = mergeWithRightText(data.text, rightText);
|
||||
processedRightText = processedRightText.mid(data.text.length());
|
||||
QString displayText = blockText.left(cursorPositionInBlock) + data.text
|
||||
+ processedRightText;
|
||||
QString suggestionText = data.text;
|
||||
QString entireLine = blockText;
|
||||
|
||||
if (!suggestionText.contains('\n')) {
|
||||
int replaceLength = calculateReplaceLength(suggestionText, rightText, entireLine);
|
||||
QString remainingRightText = (replaceLength > 0) ? rightText.mid(replaceLength) : rightText;
|
||||
|
||||
QString displayText = leftText + suggestionText + remainingRightText;
|
||||
replacementDocument()->setPlainText(displayText);
|
||||
} else {
|
||||
QString displayText = blockText.left(cursorPositionInBlock) + data.text;
|
||||
int firstLineEnd = suggestionText.indexOf('\n');
|
||||
QString firstLine = suggestionText.left(firstLineEnd);
|
||||
QString restOfCompletion = suggestionText.mid(firstLineEnd);
|
||||
|
||||
int replaceLength = calculateReplaceLength(firstLine, rightText, entireLine);
|
||||
QString remainingRightText = (replaceLength > 0) ? rightText.mid(replaceLength) : rightText;
|
||||
|
||||
QString displayText = leftText + firstLine + remainingRightText + restOfCompletion;
|
||||
replacementDocument()->setPlainText(displayText);
|
||||
}
|
||||
}
|
||||
@ -104,10 +135,12 @@ bool LLMSuggestion::applyLine(TextEditor::TextEditorWidget *widget)
|
||||
|
||||
bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget)
|
||||
{
|
||||
const Utils::Text::Range range = suggestions()[currentSuggestion()].range;
|
||||
const auto ¤tSuggestions = suggestions();
|
||||
const auto ¤tData = currentSuggestions[currentSuggestion()];
|
||||
const Utils::Text::Range range = currentData.range;
|
||||
const QTextCursor cursor = range.begin.toTextCursor(sourceDocument());
|
||||
QTextCursor currentCursor = widget->textCursor();
|
||||
const QString text = suggestions()[currentSuggestion()].text;
|
||||
const QString text = currentData.text;
|
||||
|
||||
const int startPos = currentCursor.positionInBlock() - cursor.positionInBlock()
|
||||
+ (cursor.selectionEnd() - cursor.selectionStart());
|
||||
@ -131,6 +164,19 @@ bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startPos == 0) {
|
||||
QTextBlock currentBlock = cursor.block();
|
||||
QString textAfterCursor = currentBlock.text().mid(cursor.positionInBlock());
|
||||
QString entireLine = currentBlock.text();
|
||||
|
||||
int replaceLength = calculateReplaceLength(text, textAfterCursor, entireLine);
|
||||
|
||||
if (replaceLength > 0) {
|
||||
currentCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength);
|
||||
currentCursor.removeSelectedText();
|
||||
}
|
||||
}
|
||||
|
||||
if (!subText.contains('\n')) {
|
||||
currentCursor.insertText(subText);
|
||||
|
||||
@ -167,34 +213,47 @@ bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget)
|
||||
|
||||
bool LLMSuggestion::apply()
|
||||
{
|
||||
const Utils::Text::Range range = suggestions()[currentSuggestion()].range;
|
||||
const auto ¤tSuggestions = suggestions();
|
||||
const auto ¤tData = currentSuggestions[currentSuggestion()];
|
||||
const Utils::Text::Range range = currentData.range;
|
||||
const QTextCursor cursor = range.begin.toTextCursor(sourceDocument());
|
||||
const QString text = suggestions()[currentSuggestion()].text;
|
||||
QString text = currentData.text;
|
||||
|
||||
QTextBlock currentBlock = cursor.block();
|
||||
QString textBeforeCursor = currentBlock.text().left(cursor.positionInBlock());
|
||||
QString textAfterCursor = currentBlock.text().mid(cursor.positionInBlock());
|
||||
QString entireLine = currentBlock.text();
|
||||
|
||||
QTextCursor editCursor = cursor;
|
||||
editCursor.beginEditBlock();
|
||||
|
||||
int firstLineEnd = text.indexOf('\n');
|
||||
if (firstLineEnd != -1) {
|
||||
QString firstLine = text.left(firstLineEnd);
|
||||
QString restOfText = text.mid(firstLineEnd);
|
||||
|
||||
editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
|
||||
editCursor.removeSelectedText();
|
||||
|
||||
QString mergedFirstLine = mergeWithRightText(firstLine, textAfterCursor);
|
||||
editCursor.insertText(mergedFirstLine + restOfText);
|
||||
int replaceLength = calculateReplaceLength(firstLine, textAfterCursor, entireLine);
|
||||
|
||||
if (replaceLength > 0) {
|
||||
editCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength);
|
||||
editCursor.removeSelectedText();
|
||||
}
|
||||
|
||||
editCursor.insertText(firstLine + restOfText);
|
||||
} else {
|
||||
editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
|
||||
editCursor.removeSelectedText();
|
||||
|
||||
QString mergedText = mergeWithRightText(text, textAfterCursor);
|
||||
editCursor.insertText(mergedText);
|
||||
int replaceLength = calculateReplaceLength(text, textAfterCursor, entireLine);
|
||||
|
||||
if (replaceLength > 0) {
|
||||
editCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength);
|
||||
editCursor.removeSelectedText();
|
||||
}
|
||||
|
||||
editCursor.insertText(text);
|
||||
}
|
||||
|
||||
editCursor.endEditBlock();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist
|
||||
|
||||
|
||||
@ -41,5 +41,9 @@ public:
|
||||
bool applyLine(TextEditor::TextEditorWidget *widget) override;
|
||||
bool applyPart(Part part, TextEditor::TextEditorWidget *widget);
|
||||
bool apply() override;
|
||||
|
||||
static int calculateReplaceLength(const QString &suggestion,
|
||||
const QString &rightText,
|
||||
const QString &entireLine);
|
||||
};
|
||||
} // namespace QodeAssist
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"Id" : "qodeassist",
|
||||
"Name" : "QodeAssist",
|
||||
"Version" : "0.8.3",
|
||||
"Version" : "0.9.4",
|
||||
"CompatVersion" : "${IDE_VERSION}",
|
||||
"Vendor" : "Petr Mironychev",
|
||||
"VendorId" : "petrmironychev",
|
||||
|
||||
@ -24,7 +24,9 @@
|
||||
|
||||
#include "QodeAssistClient.hpp"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QInputDialog>
|
||||
#include <QKeyEvent>
|
||||
#include <QTimer>
|
||||
|
||||
#include <coreplugin/icore.h>
|
||||
@ -33,9 +35,14 @@
|
||||
|
||||
#include "LLMClientInterface.hpp"
|
||||
#include "LLMSuggestion.hpp"
|
||||
#include "RefactorSuggestion.hpp"
|
||||
#include "RefactorSuggestionHoverHandler.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProjectSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "widgets/RefactorWidgetHandler.hpp"
|
||||
#include "RefactorContextHelper.hpp"
|
||||
#include <context/ChangesManager.h>
|
||||
#include <logger/Logger.hpp>
|
||||
|
||||
@ -61,11 +68,20 @@ QodeAssistClient::QodeAssistClient(LLMClientInterface *clientInterface)
|
||||
setupConnections();
|
||||
|
||||
m_typingTimer.start();
|
||||
|
||||
m_hintHideTimer.setSingleShot(true);
|
||||
m_hintHideTimer.setInterval(Settings::codeCompletionSettings().hintHideTimeout());
|
||||
connect(&m_hintHideTimer, &QTimer::timeout, this, [this]() { m_hintHandler.hideHint(); });
|
||||
|
||||
m_refactorHoverHandler = new RefactorSuggestionHoverHandler();
|
||||
m_refactorWidgetHandler = new RefactorWidgetHandler(this);
|
||||
}
|
||||
|
||||
QodeAssistClient::~QodeAssistClient()
|
||||
{
|
||||
cleanupConnections();
|
||||
delete m_refactorHoverHandler;
|
||||
delete m_refactorWidgetHandler;
|
||||
}
|
||||
|
||||
void QodeAssistClient::openDocument(TextEditor::TextDocument *document)
|
||||
@ -75,6 +91,15 @@ void QodeAssistClient::openDocument(TextEditor::TextDocument *document)
|
||||
return;
|
||||
|
||||
Client::openDocument(document);
|
||||
|
||||
auto editors = TextEditor::BaseTextEditor::textEditorsForDocument(document);
|
||||
for (auto *editor : editors) {
|
||||
if (auto *widget = editor->editorWidget()) {
|
||||
widget->addHoverHandler(m_refactorHoverHandler);
|
||||
widget->installEventFilter(this);
|
||||
}
|
||||
}
|
||||
|
||||
connect(
|
||||
document,
|
||||
&TextDocument::contentsChangedWithPosition,
|
||||
@ -106,6 +131,12 @@ void QodeAssistClient::openDocument(TextEditor::TextDocument *document)
|
||||
if (charsRemoved > 0 || charsAdded <= 0) {
|
||||
m_recentCharCount = 0;
|
||||
m_typingTimer.restart();
|
||||
// 0 = Hint-based, 1 = Automatic
|
||||
const int triggerMode = Settings::codeCompletionSettings().completionTriggerMode();
|
||||
if (triggerMode != 1) {
|
||||
m_hintHideTimer.stop();
|
||||
m_hintHandler.hideHint();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -116,29 +147,37 @@ void QodeAssistClient::openDocument(TextEditor::TextDocument *document)
|
||||
if (lastChar.isEmpty() || lastChar[0].isPunct()) {
|
||||
m_recentCharCount = 0;
|
||||
m_typingTimer.restart();
|
||||
// 0 = Hint-based, 1 = Automatic
|
||||
const int triggerMode = Settings::codeCompletionSettings().completionTriggerMode();
|
||||
if (triggerMode != 1) {
|
||||
m_hintHideTimer.stop();
|
||||
m_hintHandler.hideHint();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
m_recentCharCount += charsAdded;
|
||||
bool isSpaceOrTab = lastChar[0].isSpace();
|
||||
bool ignoreWhitespace
|
||||
= Settings::codeCompletionSettings().ignoreWhitespaceInCharCount();
|
||||
|
||||
if (!ignoreWhitespace || !isSpaceOrTab) {
|
||||
m_recentCharCount += charsAdded;
|
||||
}
|
||||
|
||||
if (m_typingTimer.elapsed()
|
||||
> Settings::codeCompletionSettings().autoCompletionTypingInterval()) {
|
||||
m_recentCharCount = charsAdded;
|
||||
m_recentCharCount = (ignoreWhitespace && isSpaceOrTab) ? 0 : charsAdded;
|
||||
m_typingTimer.restart();
|
||||
}
|
||||
|
||||
if (m_recentCharCount
|
||||
> Settings::codeCompletionSettings().autoCompletionCharThreshold()) {
|
||||
scheduleRequest(widget);
|
||||
// 0 = Hint-based, 1 = Automatic
|
||||
const int triggerMode = Settings::codeCompletionSettings().completionTriggerMode();
|
||||
if (triggerMode == 1) {
|
||||
handleAutoRequestTrigger(widget, charsAdded, isSpaceOrTab);
|
||||
} else {
|
||||
handleHintBasedTrigger(widget, charsAdded, isSpaceOrTab, cursor);
|
||||
}
|
||||
});
|
||||
|
||||
// auto editors = BaseTextEditor::textEditorsForDocument(document);
|
||||
// connect(
|
||||
// editors.first()->editorWidget(),
|
||||
// &TextEditorWidget::selectionChanged,
|
||||
// this,
|
||||
// [this, editors]() { m_chatButtonHandler.showButton(editors.first()->editorWidget()); });
|
||||
}
|
||||
|
||||
bool QodeAssistClient::canOpenProject(ProjectExplorer::Project *project)
|
||||
@ -153,6 +192,7 @@ void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor)
|
||||
if (!isEnabled(project))
|
||||
return;
|
||||
|
||||
|
||||
if (m_llmClient->contextManager()
|
||||
->ignoreManager()
|
||||
->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) {
|
||||
@ -165,12 +205,23 @@ void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor)
|
||||
if (cursor.hasMultipleCursors() || cursor.hasSelection() || editor->suggestionVisible())
|
||||
return;
|
||||
|
||||
const int triggerMode = Settings::codeCompletionSettings().completionTriggerMode();
|
||||
|
||||
if (Settings::codeCompletionSettings().abortAssistOnRequest() && triggerMode == 0) {
|
||||
editor->abortAssist();
|
||||
}
|
||||
|
||||
const FilePath filePath = editor->textDocument()->filePath();
|
||||
GetCompletionRequest request{
|
||||
{TextDocumentIdentifier(hostPathToServerUri(filePath)),
|
||||
documentVersion(filePath),
|
||||
Position(cursor.mainCursor())}};
|
||||
if (Settings::codeCompletionSettings().showProgressWidget()) {
|
||||
m_progressHandler.setCancelCallback([this, editor = QPointer<TextEditorWidget>(editor)]() {
|
||||
if (editor) {
|
||||
cancelRunningRequest(editor);
|
||||
}
|
||||
});
|
||||
m_progressHandler.showProgress(editor);
|
||||
}
|
||||
request.setResponseCallback([this, editor = QPointer<TextEditorWidget>(editor)](
|
||||
@ -207,6 +258,12 @@ void QodeAssistClient::requestQuickRefactor(
|
||||
&QodeAssistClient::handleRefactoringResult);
|
||||
}
|
||||
|
||||
m_progressHandler.setCancelCallback([this, editor = QPointer<TextEditorWidget>(editor)]() {
|
||||
if (editor && m_refactorHandler) {
|
||||
m_refactorHandler->cancelRequest();
|
||||
m_progressHandler.hideProgress();
|
||||
}
|
||||
});
|
||||
m_progressHandler.showProgress(editor);
|
||||
m_refactorHandler->sendRefactorRequest(editor, instructions);
|
||||
}
|
||||
@ -233,6 +290,12 @@ void QodeAssistClient::scheduleRequest(TextEditor::TextEditorWidget *editor)
|
||||
});
|
||||
connect(editor, &TextEditorWidget::cursorPositionChanged, this, [this, editor] {
|
||||
cancelRunningRequest(editor);
|
||||
// 0 = Hint-based, 1 = Automatic
|
||||
const int triggerMode = Settings::codeCompletionSettings().completionTriggerMode();
|
||||
if (triggerMode != 1) {
|
||||
m_hintHideTimer.stop();
|
||||
m_hintHandler.hideHint();
|
||||
}
|
||||
});
|
||||
it = m_scheduledRequests.insert(editor, timer);
|
||||
}
|
||||
@ -243,8 +306,19 @@ void QodeAssistClient::scheduleRequest(TextEditor::TextEditorWidget *editor)
|
||||
void QodeAssistClient::handleCompletions(
|
||||
const GetCompletionRequest::Response &response, TextEditor::TextEditorWidget *editor)
|
||||
{
|
||||
if (response.error())
|
||||
m_progressHandler.hideProgress();
|
||||
const int triggerMode = Settings::codeCompletionSettings().completionTriggerMode();
|
||||
|
||||
if (Settings::codeCompletionSettings().abortAssistOnRequest() && triggerMode == 1) {
|
||||
editor->abortAssist();
|
||||
}
|
||||
|
||||
if (response.error()) {
|
||||
log(*response.error());
|
||||
m_errorHandler
|
||||
.showError(editor, tr("Code completion failed: %1").arg(response.error()->message()));
|
||||
return;
|
||||
}
|
||||
|
||||
int requestPosition = -1;
|
||||
if (const auto requestParams = m_runningRequests.take(editor).params())
|
||||
@ -264,14 +338,13 @@ void QodeAssistClient::handleCompletions(
|
||||
QList<Completion> completions
|
||||
= Utils::filtered(result->completions().toListOrEmpty(), isValidCompletion);
|
||||
|
||||
// remove trailing whitespaces from the end of the completions
|
||||
for (Completion &completion : completions) {
|
||||
const LanguageServerProtocol::Range range = completion.range();
|
||||
if (range.start().line() != range.end().line())
|
||||
continue; // do not remove trailing whitespaces for multi-line replacements
|
||||
continue;
|
||||
|
||||
const QString completionText = completion.text();
|
||||
const int end = int(completionText.size()) - 1; // empty strings have been removed above
|
||||
const int end = int(completionText.size()) - 1;
|
||||
int delta = 0;
|
||||
while (delta <= end && completionText[end - delta].isSpace())
|
||||
++delta;
|
||||
@ -288,9 +361,11 @@ void QodeAssistClient::handleCompletions(
|
||||
Text::Position pos{toTextPos(c.position())};
|
||||
return TextSuggestion::Data{range, pos, c.text()};
|
||||
});
|
||||
m_progressHandler.hideProgress();
|
||||
if (completions.isEmpty())
|
||||
|
||||
if (completions.isEmpty()) {
|
||||
LOG_MESSAGE("No valid completions received");
|
||||
return;
|
||||
}
|
||||
editor->insertSuggestion(std::make_unique<LLMSuggestion>(suggestions, editor->document()));
|
||||
}
|
||||
}
|
||||
@ -301,6 +376,12 @@ void QodeAssistClient::cancelRunningRequest(TextEditor::TextEditorWidget *editor
|
||||
if (it == m_runningRequests.constEnd())
|
||||
return;
|
||||
m_progressHandler.hideProgress();
|
||||
// 0 = Hint-based, 1 = Automatic
|
||||
const int triggerMode = Settings::codeCompletionSettings().completionTriggerMode();
|
||||
if (triggerMode != 1) {
|
||||
m_hintHideTimer.stop();
|
||||
m_hintHandler.hideHint();
|
||||
}
|
||||
cancelRequest(it->id());
|
||||
m_runningRequests.erase(it);
|
||||
}
|
||||
@ -342,32 +423,313 @@ void QodeAssistClient::cleanupConnections()
|
||||
m_scheduledRequests.clear();
|
||||
}
|
||||
|
||||
bool QodeAssistClient::isHintVisible() const
|
||||
{
|
||||
return m_hintHandler.isHintVisible();
|
||||
}
|
||||
|
||||
void QodeAssistClient::hideHintAndRequestCompletion(TextEditor::TextEditorWidget *editor)
|
||||
{
|
||||
m_hintHandler.hideHint();
|
||||
requestCompletions(editor);
|
||||
}
|
||||
|
||||
void QodeAssistClient::handleRefactoringResult(const RefactorResult &result)
|
||||
{
|
||||
m_progressHandler.hideProgress();
|
||||
|
||||
if (!result.success) {
|
||||
QString errorMessage = result.errorMessage.isEmpty()
|
||||
? tr("Quick refactor failed")
|
||||
: tr("Quick refactor failed: %1").arg(result.errorMessage);
|
||||
|
||||
if (result.editor) {
|
||||
m_errorHandler.showError(result.editor, errorMessage);
|
||||
}
|
||||
|
||||
LOG_MESSAGE(QString("Refactoring failed: %1").arg(result.errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
auto editor = BaseTextEditor::currentTextEditor();
|
||||
if (!editor) {
|
||||
LOG_MESSAGE("Refactoring failed: No active editor found");
|
||||
if (!result.editor) {
|
||||
LOG_MESSAGE("Refactoring result has no editor");
|
||||
return;
|
||||
}
|
||||
|
||||
auto editorWidget = editor->editorWidget();
|
||||
|
||||
QTextCursor cursor = editorWidget->textCursor();
|
||||
cursor.beginEditBlock();
|
||||
|
||||
int startPos = result.insertRange.begin.toPositionInDocument(editorWidget->document());
|
||||
int endPos = result.insertRange.end.toPositionInDocument(editorWidget->document());
|
||||
|
||||
cursor.setPosition(startPos);
|
||||
cursor.setPosition(endPos, QTextCursor::KeepAnchor);
|
||||
|
||||
cursor.insertText(result.newText);
|
||||
cursor.endEditBlock();
|
||||
m_progressHandler.hideProgress();
|
||||
int displayMode = Settings::quickRefactorSettings().displayMode();
|
||||
|
||||
if (displayMode == 0) {
|
||||
displayRefactoringWidget(result);
|
||||
} else {
|
||||
displayRefactoringSuggestion(result);
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
Utils::Text::Position toTextPos(const Utils::Text::Position &pos)
|
||||
{
|
||||
return Utils::Text::Position{pos.line, pos.column};
|
||||
}
|
||||
} // anonymous namespace
|
||||
|
||||
void QodeAssistClient::displayRefactoringSuggestion(const RefactorResult &result)
|
||||
{
|
||||
TextEditorWidget *editorWidget = result.editor;
|
||||
|
||||
Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)};
|
||||
Utils::Text::Position pos = toTextPos(result.insertRange.begin);
|
||||
|
||||
int startPos = range.begin.toPositionInDocument(editorWidget->document());
|
||||
int endPos = range.end.toPositionInDocument(editorWidget->document());
|
||||
|
||||
if (startPos != endPos) {
|
||||
QTextCursor startCursor(editorWidget->document());
|
||||
startCursor.setPosition(startPos);
|
||||
if (startCursor.positionInBlock() > 0) {
|
||||
startCursor.movePosition(QTextCursor::StartOfBlock);
|
||||
}
|
||||
|
||||
QTextCursor endCursor(editorWidget->document());
|
||||
endCursor.setPosition(endPos);
|
||||
if (endCursor.positionInBlock() > 0) {
|
||||
endCursor.movePosition(QTextCursor::EndOfBlock);
|
||||
if (!endCursor.atEnd()) {
|
||||
endCursor.movePosition(QTextCursor::NextCharacter);
|
||||
}
|
||||
}
|
||||
|
||||
Utils::Text::Position expandedBegin = Utils::Text::Position::fromPositionInDocument(
|
||||
editorWidget->document(), startCursor.position());
|
||||
Utils::Text::Position expandedEnd = Utils::Text::Position::fromPositionInDocument(
|
||||
editorWidget->document(), endCursor.position());
|
||||
|
||||
range = Utils::Text::Range(expandedBegin, expandedEnd);
|
||||
}
|
||||
|
||||
TextEditor::TextSuggestion::Data suggestionData{
|
||||
Utils::Text::Range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)},
|
||||
pos,
|
||||
result.newText};
|
||||
editorWidget->insertSuggestion(
|
||||
std::make_unique<RefactorSuggestion>(suggestionData, editorWidget->document()));
|
||||
|
||||
m_refactorHoverHandler->setSuggestionRange(range);
|
||||
|
||||
m_refactorHoverHandler->setApplyCallback([this, editorWidget]() {
|
||||
QKeyEvent tabEvent(QEvent::KeyPress, Qt::Key_Tab, Qt::NoModifier);
|
||||
QApplication::sendEvent(editorWidget, &tabEvent);
|
||||
m_refactorHoverHandler->clearSuggestionRange();
|
||||
});
|
||||
|
||||
m_refactorHoverHandler->setDismissCallback([this, editorWidget]() {
|
||||
editorWidget->clearSuggestion();
|
||||
m_refactorHoverHandler->clearSuggestionRange();
|
||||
});
|
||||
|
||||
LOG_MESSAGE("Displaying refactoring suggestion with hover handler");
|
||||
}
|
||||
|
||||
void QodeAssistClient::displayRefactoringWidget(const RefactorResult &result)
|
||||
{
|
||||
TextEditorWidget *editorWidget = result.editor;
|
||||
Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)};
|
||||
|
||||
RefactorContext ctx = RefactorContextHelper::extractContext(editorWidget, range);
|
||||
|
||||
QString displayOriginal;
|
||||
QString displayRefactored;
|
||||
QString textToApply = result.newText;
|
||||
|
||||
if (ctx.isInsertion) {
|
||||
bool isMultiline = result.newText.contains('\n');
|
||||
|
||||
if (isMultiline) {
|
||||
displayOriginal = ctx.textBeforeCursor;
|
||||
displayRefactored = ctx.textBeforeCursor + result.newText;
|
||||
} else {
|
||||
displayOriginal = ctx.textBeforeCursor + ctx.textAfterCursor;
|
||||
displayRefactored = ctx.textBeforeCursor + result.newText + ctx.textAfterCursor;
|
||||
}
|
||||
|
||||
if (!ctx.textBeforeCursor.isEmpty() || !ctx.textAfterCursor.isEmpty()) {
|
||||
textToApply = result.newText;
|
||||
}
|
||||
} else {
|
||||
displayOriginal = ctx.originalText;
|
||||
displayRefactored = result.newText;
|
||||
}
|
||||
|
||||
m_refactorWidgetHandler->setApplyCallback([this, editorWidget, result](const QString &editedText) {
|
||||
applyRefactoringEdit(editorWidget, result.insertRange, editedText);
|
||||
});
|
||||
|
||||
m_refactorWidgetHandler->setDeclineCallback([]() {});
|
||||
|
||||
m_refactorWidgetHandler->showRefactorWidget(
|
||||
editorWidget, displayOriginal, displayRefactored, range,
|
||||
ctx.contextBefore, ctx.contextAfter);
|
||||
|
||||
m_refactorWidgetHandler->setTextToApply(textToApply);
|
||||
}
|
||||
|
||||
void QodeAssistClient::applyRefactoringEdit(TextEditor::TextEditorWidget *editor,
|
||||
const Utils::Text::Range &range,
|
||||
const QString &text)
|
||||
{
|
||||
const QTextCursor startCursor = range.begin.toTextCursor(editor->document());
|
||||
const QTextCursor endCursor = range.end.toTextCursor(editor->document());
|
||||
const int startPos = startCursor.position();
|
||||
const int endPos = endCursor.position();
|
||||
|
||||
QTextCursor editCursor(editor->document());
|
||||
editCursor.beginEditBlock();
|
||||
|
||||
if (startPos == endPos) {
|
||||
bool isMultiline = text.contains('\n');
|
||||
editCursor.setPosition(startPos);
|
||||
|
||||
if (isMultiline) {
|
||||
editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
|
||||
editCursor.removeSelectedText();
|
||||
}
|
||||
|
||||
editCursor.insertText(text);
|
||||
} else {
|
||||
editCursor.setPosition(startPos);
|
||||
editCursor.setPosition(endPos, QTextCursor::KeepAnchor);
|
||||
editCursor.removeSelectedText();
|
||||
editCursor.insertText(text);
|
||||
}
|
||||
|
||||
editCursor.endEditBlock();
|
||||
}
|
||||
|
||||
void QodeAssistClient::handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget,
|
||||
int charsAdded,
|
||||
bool isSpaceOrTab)
|
||||
{
|
||||
Q_UNUSED(isSpaceOrTab);
|
||||
|
||||
if (m_recentCharCount
|
||||
> Settings::codeCompletionSettings().autoCompletionCharThreshold()) {
|
||||
scheduleRequest(widget);
|
||||
}
|
||||
}
|
||||
|
||||
void QodeAssistClient::handleHintBasedTrigger(TextEditor::TextEditorWidget *widget,
|
||||
int charsAdded,
|
||||
bool isSpaceOrTab,
|
||||
QTextCursor &cursor)
|
||||
{
|
||||
Q_UNUSED(charsAdded);
|
||||
|
||||
const int hintThreshold = Settings::codeCompletionSettings().hintCharThreshold();
|
||||
if (m_recentCharCount >= hintThreshold && !isSpaceOrTab) {
|
||||
const QRect cursorRect = widget->cursorRect(cursor);
|
||||
QPoint globalPos = widget->viewport()->mapToGlobal(cursorRect.topLeft());
|
||||
QPoint localPos = widget->mapFromGlobal(globalPos);
|
||||
|
||||
int fontSize = widget->font().pixelSize();
|
||||
if (fontSize <= 0) {
|
||||
fontSize = widget->fontMetrics().height();
|
||||
}
|
||||
|
||||
QTextCursor textCursor = widget->textCursor();
|
||||
|
||||
if (m_recentCharCount <= hintThreshold) {
|
||||
textCursor
|
||||
.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor, m_recentCharCount);
|
||||
} else {
|
||||
textCursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor, hintThreshold);
|
||||
}
|
||||
|
||||
int x = localPos.x() + cursorRect.height();
|
||||
int y = localPos.y() + cursorRect.height() / 4;
|
||||
|
||||
QPoint hintPos(x, y);
|
||||
|
||||
if (!m_hintHandler.isHintVisible()) {
|
||||
m_hintHandler.showHint(widget, hintPos, fontSize);
|
||||
} else {
|
||||
m_hintHandler.updateHintPosition(widget, hintPos);
|
||||
}
|
||||
|
||||
m_hintHideTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
bool QodeAssistClient::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
auto *editor = qobject_cast<TextEditor::TextEditorWidget *>(watched);
|
||||
if (!editor)
|
||||
return LanguageClient::Client::eventFilter(watched, event);
|
||||
|
||||
if (event->type() == QEvent::KeyPress) {
|
||||
auto *keyEvent = static_cast<QKeyEvent *>(event);
|
||||
|
||||
// Check hint trigger key (0=Space, 1=Ctrl+Space, 2=Alt+Space, 3=Ctrl+Enter, 4=Tab, 5=Enter)
|
||||
if (m_hintHandler.isHintVisible()) {
|
||||
const int triggerKeyIndex = Settings::codeCompletionSettings().hintTriggerKey();
|
||||
bool isMatchingKey = false;
|
||||
const Qt::KeyboardModifiers modifiers = keyEvent->modifiers();
|
||||
|
||||
switch (triggerKeyIndex) {
|
||||
case 0: // Space
|
||||
isMatchingKey = (keyEvent->key() == Qt::Key_Space
|
||||
&& (modifiers == Qt::NoModifier || modifiers == Qt::ShiftModifier));
|
||||
break;
|
||||
case 1: // Ctrl+Space
|
||||
isMatchingKey = (keyEvent->key() == Qt::Key_Space
|
||||
&& (modifiers & Qt::ControlModifier));
|
||||
break;
|
||||
case 2: // Alt+Space
|
||||
isMatchingKey = (keyEvent->key() == Qt::Key_Space
|
||||
&& (modifiers & Qt::AltModifier));
|
||||
break;
|
||||
case 3: // Ctrl+Enter
|
||||
isMatchingKey = ((keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter)
|
||||
&& (modifiers & Qt::ControlModifier));
|
||||
break;
|
||||
case 4: // Tab
|
||||
isMatchingKey = (keyEvent->key() == Qt::Key_Tab);
|
||||
break;
|
||||
case 5: // Enter
|
||||
isMatchingKey = ((keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter)
|
||||
&& (modifiers == Qt::NoModifier || modifiers == Qt::ShiftModifier));
|
||||
break;
|
||||
}
|
||||
|
||||
if (isMatchingKey) {
|
||||
m_hintHideTimer.stop();
|
||||
m_hintHandler.hideHint();
|
||||
requestCompletions(editor);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyEvent->key() == Qt::Key_Escape) {
|
||||
if (m_runningRequests.contains(editor)) {
|
||||
cancelRunningRequest(editor);
|
||||
}
|
||||
|
||||
if (m_scheduledRequests.contains(editor)) {
|
||||
auto *timer = m_scheduledRequests.value(editor);
|
||||
if (timer && timer->isActive()) {
|
||||
timer->stop();
|
||||
}
|
||||
}
|
||||
|
||||
if (m_refactorHandler && m_refactorHandler->isProcessing()) {
|
||||
m_refactorHandler->cancelRequest();
|
||||
}
|
||||
|
||||
m_progressHandler.hideProgress();
|
||||
m_hintHideTimer.stop();
|
||||
m_hintHandler.hideHint();
|
||||
}
|
||||
}
|
||||
|
||||
return LanguageClient::Client::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist
|
||||
|
||||
@ -29,8 +29,12 @@
|
||||
#include "LLMClientInterface.hpp"
|
||||
#include "LSPCompletion.hpp"
|
||||
#include "QuickRefactorHandler.hpp"
|
||||
#include "RefactorSuggestionHoverHandler.hpp"
|
||||
#include "widgets/CompletionProgressHandler.hpp"
|
||||
#include "widgets/CompletionErrorHandler.hpp"
|
||||
#include "widgets/CompletionHintHandler.hpp"
|
||||
#include "widgets/EditorChatButtonHandler.hpp"
|
||||
#include "widgets/RefactorWidgetHandler.hpp"
|
||||
#include <languageclient/client.h>
|
||||
#include <llmcore/IPromptProvider.hpp>
|
||||
#include <llmcore/IProviderRegistry.hpp>
|
||||
@ -50,6 +54,12 @@ public:
|
||||
void requestCompletions(TextEditor::TextEditorWidget *editor);
|
||||
void requestQuickRefactor(
|
||||
TextEditor::TextEditorWidget *editor, const QString &instructions = QString());
|
||||
|
||||
bool isHintVisible() const;
|
||||
void hideHintAndRequestCompletion(TextEditor::TextEditorWidget *editor);
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private:
|
||||
void scheduleRequest(TextEditor::TextEditorWidget *editor);
|
||||
@ -61,6 +71,12 @@ private:
|
||||
void setupConnections();
|
||||
void cleanupConnections();
|
||||
void handleRefactoringResult(const RefactorResult &result);
|
||||
void displayRefactoringSuggestion(const RefactorResult &result);
|
||||
void displayRefactoringWidget(const RefactorResult &result);
|
||||
void applyRefactoringEdit(TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range, const QString &text);
|
||||
|
||||
void handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget, int charsAdded, bool isSpaceOrTab);
|
||||
void handleHintBasedTrigger(TextEditor::TextEditorWidget *widget, int charsAdded, bool isSpaceOrTab, QTextCursor &cursor);
|
||||
|
||||
QHash<TextEditor::TextEditorWidget *, GetCompletionRequest> m_runningRequests;
|
||||
QHash<TextEditor::TextEditorWidget *, QTimer *> m_scheduledRequests;
|
||||
@ -69,9 +85,14 @@ private:
|
||||
|
||||
QElapsedTimer m_typingTimer;
|
||||
int m_recentCharCount;
|
||||
QTimer m_hintHideTimer;
|
||||
CompletionProgressHandler m_progressHandler;
|
||||
CompletionErrorHandler m_errorHandler;
|
||||
CompletionHintHandler m_hintHandler;
|
||||
EditorChatButtonHandler m_chatButtonHandler;
|
||||
QuickRefactorHandler *m_refactorHandler{nullptr};
|
||||
RefactorSuggestionHoverHandler *m_refactorHoverHandler{nullptr};
|
||||
RefactorWidgetHandler *m_refactorWidgetHandler{nullptr};
|
||||
LLMClientInterface *m_llmClient;
|
||||
};
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
#include <QUuid>
|
||||
|
||||
#include <context/DocumentContextReader.hpp>
|
||||
#include <llmcore/ResponseCleaner.hpp>
|
||||
#include <context/DocumentReaderQtCreator.hpp>
|
||||
#include <context/Utils.hpp>
|
||||
#include <llmcore/PromptTemplateManager.hpp>
|
||||
@ -33,6 +34,7 @@
|
||||
#include <logger/Logger.hpp>
|
||||
#include <settings/ChatAssistantSettings.hpp>
|
||||
#include <settings/GeneralSettings.hpp>
|
||||
#include <settings/QuickRefactorSettings.hpp>
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
@ -110,26 +112,30 @@ void QuickRefactorHandler::prepareAndSendRequest(
|
||||
auto &providerRegistry = LLMCore::ProvidersManager::instance();
|
||||
auto &promptManager = LLMCore::PromptTemplateManager::instance();
|
||||
|
||||
const auto providerName = settings.caProvider();
|
||||
const auto providerName = settings.qrProvider();
|
||||
auto provider = providerRegistry.getProviderByName(providerName);
|
||||
|
||||
if (!provider) {
|
||||
LOG_MESSAGE(QString("No provider found with name: %1").arg(providerName));
|
||||
QString error = QString("No provider found with name: %1").arg(providerName);
|
||||
LOG_MESSAGE(error);
|
||||
RefactorResult result;
|
||||
result.success = false;
|
||||
result.errorMessage = QString("No provider found with name: %1").arg(providerName);
|
||||
result.errorMessage = error;
|
||||
result.editor = editor;
|
||||
emit refactoringCompleted(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto templateName = settings.caTemplate();
|
||||
const auto templateName = settings.qrTemplate();
|
||||
auto promptTemplate = promptManager.getChatTemplateByName(templateName);
|
||||
|
||||
if (!promptTemplate) {
|
||||
LOG_MESSAGE(QString("No template found with name: %1").arg(templateName));
|
||||
QString error = QString("No template found with name: %1").arg(templateName);
|
||||
LOG_MESSAGE(error);
|
||||
RefactorResult result;
|
||||
result.success = false;
|
||||
result.errorMessage = QString("No template found with name: %1").arg(templateName);
|
||||
result.errorMessage = error;
|
||||
result.editor = editor;
|
||||
emit refactoringCompleted(result);
|
||||
return;
|
||||
}
|
||||
@ -138,18 +144,34 @@ void QuickRefactorHandler::prepareAndSendRequest(
|
||||
config.requestType = LLMCore::RequestType::QuickRefactoring;
|
||||
config.provider = provider;
|
||||
config.promptTemplate = promptTemplate;
|
||||
config.url = QString("%1%2").arg(settings.caUrl(), provider->chatEndpoint());
|
||||
config.providerRequest = {{"model", settings.caModel()}, {"stream", true}};
|
||||
config.url = QString("%1%2").arg(settings.qrUrl(), provider->chatEndpoint());
|
||||
config.apiKey = provider->apiKey();
|
||||
|
||||
if (provider->providerID() == LLMCore::ProviderID::GoogleAI) {
|
||||
QString stream = QString{"streamGenerateContent?alt=sse"};
|
||||
config.url = QUrl(QString("%1/models/%2:%3")
|
||||
.arg(
|
||||
Settings::generalSettings().qrUrl(),
|
||||
Settings::generalSettings().qrModel(),
|
||||
stream));
|
||||
} else {
|
||||
config.url
|
||||
= QString("%1%2").arg(Settings::generalSettings().qrUrl(), provider->chatEndpoint());
|
||||
config.providerRequest
|
||||
= {{"model", Settings::generalSettings().qrModel()}, {"stream", true}};
|
||||
}
|
||||
|
||||
LLMCore::ContextData context = prepareContext(editor, range, instructions);
|
||||
|
||||
bool enableTools = Settings::quickRefactorSettings().useTools();
|
||||
bool enableThinking = Settings::quickRefactorSettings().useThinking();
|
||||
provider->prepareRequest(
|
||||
config.providerRequest,
|
||||
promptTemplate,
|
||||
context,
|
||||
LLMCore::RequestType::QuickRefactoring,
|
||||
false);
|
||||
enableTools,
|
||||
enableThinking);
|
||||
|
||||
QString requestId = QUuid::createUuid().toString();
|
||||
m_lastRequestId = requestId;
|
||||
@ -195,22 +217,75 @@ LLMCore::ContextData QuickRefactorHandler::prepareContext(
|
||||
QTextCursor cursor = editor->textCursor();
|
||||
int cursorPos = cursor.position();
|
||||
|
||||
// TODO add selecting content before and after cursor/selection
|
||||
QString fullContent = documentInfo.document->toPlainText();
|
||||
QString taggedContent = fullContent;
|
||||
Context::DocumentContextReader
|
||||
reader(documentInfo.document, documentInfo.mimeType, documentInfo.filePath);
|
||||
|
||||
QString taggedContent;
|
||||
bool readFullFile = Settings::quickRefactorSettings().readFullFile();
|
||||
|
||||
if (cursor.hasSelection()) {
|
||||
int selEnd = cursor.selectionEnd();
|
||||
int selStart = cursor.selectionStart();
|
||||
taggedContent
|
||||
.insert(selEnd, selEnd == cursorPos ? "<selection_end><cursor>" : "<selection_end>");
|
||||
taggedContent.insert(
|
||||
selStart, selStart == cursorPos ? "<cursor><selection_start>" : "<selection_start>");
|
||||
int selEnd = cursor.selectionEnd();
|
||||
|
||||
QTextBlock startBlock = documentInfo.document->findBlock(selStart);
|
||||
int startLine = startBlock.blockNumber();
|
||||
int startColumn = selStart - startBlock.position();
|
||||
|
||||
QTextBlock endBlock = documentInfo.document->findBlock(selEnd);
|
||||
int endLine = endBlock.blockNumber();
|
||||
int endColumn = selEnd - endBlock.position();
|
||||
|
||||
QString contextBefore;
|
||||
if (readFullFile) {
|
||||
contextBefore = reader.readWholeFileBefore(startLine, startColumn);
|
||||
} else {
|
||||
contextBefore = reader.getContextBefore(
|
||||
startLine, startColumn, Settings::quickRefactorSettings().readStringsBeforeCursor() + 1);
|
||||
}
|
||||
|
||||
QString selectedText = cursor.selectedText();
|
||||
selectedText.replace(QChar(0x2029), "\n");
|
||||
|
||||
QString contextAfter;
|
||||
if (readFullFile) {
|
||||
contextAfter = reader.readWholeFileAfter(endLine, endColumn);
|
||||
} else {
|
||||
contextAfter = reader.getContextAfter(
|
||||
endLine, endColumn, Settings::quickRefactorSettings().readStringsAfterCursor() + 1);
|
||||
}
|
||||
|
||||
taggedContent = contextBefore;
|
||||
if (selStart == cursorPos) {
|
||||
taggedContent += "<cursor><selection_start>" + selectedText + "<selection_end>";
|
||||
} else {
|
||||
taggedContent += "<selection_start>" + selectedText + "<selection_end><cursor>";
|
||||
}
|
||||
taggedContent += contextAfter;
|
||||
} else {
|
||||
taggedContent.insert(cursorPos, "<cursor>");
|
||||
QTextBlock block = documentInfo.document->findBlock(cursorPos);
|
||||
int line = block.blockNumber();
|
||||
int column = cursorPos - block.position();
|
||||
|
||||
QString contextBefore;
|
||||
if (readFullFile) {
|
||||
contextBefore = reader.readWholeFileBefore(line, column);
|
||||
} else {
|
||||
contextBefore = reader.getContextBefore(
|
||||
line, column, Settings::quickRefactorSettings().readStringsBeforeCursor() + 1);
|
||||
}
|
||||
|
||||
QString contextAfter;
|
||||
if (readFullFile) {
|
||||
contextAfter = reader.readWholeFileAfter(line, column);
|
||||
} else {
|
||||
contextAfter = reader.getContextAfter(
|
||||
line, column, Settings::quickRefactorSettings().readStringsAfterCursor() + 1);
|
||||
}
|
||||
|
||||
taggedContent = contextBefore + "<cursor>" + contextAfter;
|
||||
}
|
||||
|
||||
QString systemPrompt = Settings::codeCompletionSettings().quickRefactorSystemPrompt();
|
||||
QString systemPrompt = Settings::quickRefactorSettings().systemPrompt();
|
||||
|
||||
auto project = LLMCore::RulesLoader::getActiveProject();
|
||||
if (project) {
|
||||
@ -227,19 +302,67 @@ LLMCore::ContextData QuickRefactorHandler::prepareContext(
|
||||
systemPrompt += "\nLanguage: " + documentInfo.mimeType;
|
||||
systemPrompt += "\nFile path: " + documentInfo.filePath;
|
||||
|
||||
systemPrompt += "\n\nCode context with position markers:";
|
||||
systemPrompt += taggedContent;
|
||||
systemPrompt += "\n\n# Code Context with Position Markers\n" + taggedContent;
|
||||
|
||||
systemPrompt += "\n\nOutput format:";
|
||||
systemPrompt += "\n- Generate ONLY the code that should replace the current selection "
|
||||
"between<selection_start><selection_end> or be "
|
||||
"inserted at cursor position<cursor>";
|
||||
systemPrompt += "\n- Do not include any explanations, comments about the code, or markdown "
|
||||
"code block markers";
|
||||
systemPrompt += "\n- The output should be ready to insert directly into the editor";
|
||||
systemPrompt += "\n- Follow the existing code style and indentation patterns";
|
||||
systemPrompt += "\n\n# Output Requirements\n## What to Generate:";
|
||||
systemPrompt += cursor.hasSelection()
|
||||
? "\n- Generate ONLY the code that should REPLACE the selected text between "
|
||||
"<selection_start> and <selection_end> markers"
|
||||
"\n- Your output will completely replace the selected code"
|
||||
: "\n- Generate ONLY the code that should be INSERTED at the <cursor> position"
|
||||
"\n- Your output will be inserted at the cursor location";
|
||||
|
||||
systemPrompt += "\n\n## Formatting Rules:"
|
||||
"\n- Output ONLY the code itself, without ANY explanations or descriptions"
|
||||
"\n- Do NOT include markdown code blocks (no ```, no language tags)"
|
||||
"\n- Do NOT add comments explaining what you changed"
|
||||
"\n- Do NOT repeat existing code, be precise with context"
|
||||
"\n- Do NOT send in answer <cursor> or </cursor> and other tags"
|
||||
"\n- The output must be ready to insert directly into the editor as-is";
|
||||
|
||||
systemPrompt += "\n\n## Indentation and Whitespace:";
|
||||
|
||||
if (cursor.hasSelection()) {
|
||||
QTextBlock startBlock = documentInfo.document->findBlock(cursor.selectionStart());
|
||||
int leadingSpaces = 0;
|
||||
for (QChar c : startBlock.text()) {
|
||||
if (c == ' ') leadingSpaces++;
|
||||
else if (c == '\t') leadingSpaces += 4;
|
||||
else break;
|
||||
}
|
||||
if (leadingSpaces > 0) {
|
||||
systemPrompt += QString("\n- CRITICAL: The code to replace starts with %1 spaces of indentation"
|
||||
"\n- Your output MUST start with exactly %1 spaces (or equivalent tabs)"
|
||||
"\n- Each line in your output must maintain this base indentation")
|
||||
.arg(leadingSpaces);
|
||||
}
|
||||
systemPrompt += "\n- PRESERVE all indentation from the original code";
|
||||
} else {
|
||||
QTextBlock block = documentInfo.document->findBlock(cursorPos);
|
||||
QString lineText = block.text();
|
||||
int leadingSpaces = 0;
|
||||
for (QChar c : lineText) {
|
||||
if (c == ' ') leadingSpaces++;
|
||||
else if (c == '\t') leadingSpaces += 4;
|
||||
else break;
|
||||
}
|
||||
if (leadingSpaces > 0) {
|
||||
systemPrompt += QString("\n- CRITICAL: Current line has %1 spaces of indentation"
|
||||
"\n- If generating multiline code, EVERY line must start with at least %1 spaces"
|
||||
"\n- If generating single-line code, it will be inserted inline (no indentation needed)")
|
||||
.arg(leadingSpaces);
|
||||
}
|
||||
}
|
||||
|
||||
systemPrompt += "\n- Use the same indentation style (spaces or tabs) as the surrounding code"
|
||||
"\n- Maintain consistent indentation for nested blocks"
|
||||
"\n- Do NOT remove or reduce the base indentation level"
|
||||
"\n\n## Code Style:"
|
||||
"\n- Match the coding style of the surrounding code (naming, spacing, braces, etc.)"
|
||||
"\n- Preserve the original code structure when possible"
|
||||
"\n- Only change what is necessary to fulfill the user's request";
|
||||
|
||||
if (Settings::codeCompletionSettings().useOpenFilesInQuickRefactor()) {
|
||||
if (Settings::quickRefactorSettings().useOpenFilesInQuickRefactor()) {
|
||||
systemPrompt += "\n\n" + m_contextManager.openedFilesContext({documentInfo.filePath});
|
||||
}
|
||||
|
||||
@ -263,23 +386,14 @@ void QuickRefactorHandler::handleLLMResponse(
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
QString cleanedResponse = response.trimmed();
|
||||
if (cleanedResponse.startsWith("```")) {
|
||||
int firstNewLine = cleanedResponse.indexOf('\n');
|
||||
int lastFence = cleanedResponse.lastIndexOf("```");
|
||||
|
||||
if (firstNewLine != -1 && lastFence > firstNewLine) {
|
||||
cleanedResponse
|
||||
= cleanedResponse.mid(firstNewLine + 1, lastFence - firstNewLine - 1).trimmed();
|
||||
} else if (lastFence != -1) {
|
||||
cleanedResponse = cleanedResponse.mid(3, lastFence - 3).trimmed();
|
||||
}
|
||||
}
|
||||
m_isRefactoringInProgress = false;
|
||||
QString cleanedResponse = LLMCore::ResponseCleaner::clean(response);
|
||||
|
||||
RefactorResult result;
|
||||
result.newText = cleanedResponse;
|
||||
result.insertRange = m_currentRange;
|
||||
result.success = true;
|
||||
result.editor = m_currentEditor;
|
||||
|
||||
LOG_MESSAGE("Refactoring completed successfully. New code to insert: ");
|
||||
LOG_MESSAGE("---------- BEGIN REFACTORED CODE ----------");
|
||||
@ -316,6 +430,7 @@ void QuickRefactorHandler::cancelRequest()
|
||||
void QuickRefactorHandler::handleFullResponse(const QString &requestId, const QString &fullText)
|
||||
{
|
||||
if (requestId == m_lastRequestId) {
|
||||
m_activeRequests.remove(requestId);
|
||||
QJsonObject request{{"id", requestId}};
|
||||
handleLLMResponse(fullText, request, true);
|
||||
}
|
||||
@ -324,10 +439,12 @@ void QuickRefactorHandler::handleFullResponse(const QString &requestId, const QS
|
||||
void QuickRefactorHandler::handleRequestFailed(const QString &requestId, const QString &error)
|
||||
{
|
||||
if (requestId == m_lastRequestId) {
|
||||
m_activeRequests.remove(requestId);
|
||||
m_isRefactoringInProgress = false;
|
||||
RefactorResult result;
|
||||
result.success = false;
|
||||
result.errorMessage = error;
|
||||
result.editor = m_currentEditor;
|
||||
emit refactoringCompleted(result);
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ struct RefactorResult
|
||||
Utils::Text::Range insertRange;
|
||||
bool success;
|
||||
QString errorMessage;
|
||||
TextEditor::TextEditorWidget *editor{nullptr};
|
||||
};
|
||||
|
||||
class QuickRefactorHandler : public QObject
|
||||
@ -51,6 +52,7 @@ public:
|
||||
void sendRefactorRequest(TextEditor::TextEditorWidget *editor, const QString &instructions);
|
||||
|
||||
void cancelRequest();
|
||||
bool isProcessing() const { return m_isRefactoringInProgress; }
|
||||
|
||||
signals:
|
||||
void refactoringCompleted(const QodeAssist::RefactorResult &result);
|
||||
|
||||
184
README.md
@ -4,7 +4,7 @@
|
||||

|
||||
[](https://discord.gg/BGMkUsXUgf)
|
||||
|
||||
 QodeAssist is an AI-powered coding assistant plugin for Qt Creator. It provides intelligent code completion and suggestions for C++ and QML, leveraging large language models through local providers like Ollama. Enhance your coding productivity with context-aware AI assistance directly in your Qt development environment.
|
||||
 QodeAssist is a comprehensive AI-powered coding assistant plugin for Qt Creator. It provides intelligent code completion, interactive chat with multiple interface options, inline quick refactoring, and AI function calling capabilities for C++ and QML development. Supporting both local providers (Ollama, llama.cpp, LM Studio) and cloud services (Claude, OpenAI, Google AI, Mistral AI), QodeAssist enhances your productivity with context-aware AI assistance, project-specific rules, and extensive customization options directly in your Qt development environment.
|
||||
|
||||
⚠️ **Important Notice About Paid Providers**
|
||||
> When using paid providers like Claude, OpenRouter or OpenAI-compatible services:
|
||||
@ -17,12 +17,13 @@
|
||||
2. [Install Plugin](#install-plugin-to-qtcreator)
|
||||
3. [Configuration](#configuration)
|
||||
4. [Features](#features)
|
||||
5. [QtCreator Version Compatibility](#qtcreator-version-compatibility)
|
||||
6. [Hotkeys](#hotkeys)
|
||||
7. [Troubleshooting](#troubleshooting)
|
||||
8. [Development Progress](#development-progress)
|
||||
9. [Support the Development](#support-the-development-of-qodeassist)
|
||||
10. [How to Build](#how-to-build)
|
||||
5. [Context Layers](#context-layers)
|
||||
6. [QtCreator Version Compatibility](#qtcreator-version-compatibility)
|
||||
7. [Hotkeys](#hotkeys)
|
||||
8. [Troubleshooting](#troubleshooting)
|
||||
9. [Development Progress](#development-progress)
|
||||
10. [Support the Development](#support-the-development-of-qodeassist)
|
||||
11. [How to Build](#how-to-build)
|
||||
|
||||
## Overview
|
||||
|
||||
@ -30,11 +31,11 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance:
|
||||
|
||||
- **Code Completion**: Intelligent, context-aware code suggestions for C++ and QML
|
||||
- **Chat Assistant**: Multiple interface options (popup window, side panel, bottom panel)
|
||||
- **Quick Refactoring**: AI-assisted code improvements and alternative suggestions
|
||||
- **Quick Refactoring**: Inline AI-assisted code improvements directly in editor with custom instructions library
|
||||
- **File Context**: Attach or link files for better AI understanding
|
||||
- **Tool Calling**: AI can read project files, search code, and access diagnostics
|
||||
- **Multiple Providers**: Support for Ollama, Claude, OpenAI, Google AI, Mistral AI, llama.cpp, and more
|
||||
- **Customizable**: Project-specific rules and extensive model templates
|
||||
- **Customizable**: Project-specific rules, custom instructions, and extensive model templates
|
||||
|
||||
**Join our [Discord Community](https://discord.gg/BGMkUsXUgf)** to get support and connect with other users!
|
||||
|
||||
@ -150,6 +151,27 @@ QodeAssist supports multiple LLM providers. Choose your preferred provider and f
|
||||
- Context-aware suggestions
|
||||
- Multiline completions
|
||||
|
||||
#### Completion Trigger Modes
|
||||
|
||||
QodeAssist offers two trigger modes for code completion:
|
||||
|
||||
**Hint-based**
|
||||
- Shows a hint indicator near cursor when you type 3+ characters
|
||||
- Press **Space** (or custom key) to request completion
|
||||
- **Best for**: Paid APIs (Claude, OpenAI), conscious control
|
||||
- **Benefits**: No unexpected API charges, full control over requests, no workflow interruption
|
||||
- **Visual**: Clear indicator shows when completion is ready
|
||||
|
||||
**Automatic(Default)**
|
||||
- Automatically requests completion after typing threshold
|
||||
- Works immediately without additional keypresses
|
||||
- **Best for**: Local models (Ollama, llama.cpp), maximum automation
|
||||
- **Benefits**: Hands-free experience, instant suggestions
|
||||
|
||||
💡 **Tip**: Start with Hint-based to avoid unexpected costs. Switch to Automatic if using free local models.
|
||||
|
||||
Configure in: `Tools → Options → QodeAssist → Code Completion → General Settings`
|
||||
|
||||
### Chat Assistant
|
||||
- Multiple chat panels: side panel, bottom panel, and popup window
|
||||
- Chat history with auto-save and restore
|
||||
@ -159,9 +181,10 @@ QodeAssist supports multiple LLM providers. Choose your preferred provider and f
|
||||
- Extended thinking mode (Claude, other providers in plan) - Enable deeper reasoning for complex tasks
|
||||
|
||||
### Quick Refactoring
|
||||
- Fast code refactoring with AI assistance
|
||||
- Selection-based improvements
|
||||
- Alternative code suggestions
|
||||
- Inline code refactoring directly in the editor with AI assistance
|
||||
- **Custom instructions library** with search and autocomplete
|
||||
- Create, edit, and manage reusable refactoring templates
|
||||
- Combine base instructions with specific details
|
||||
- **[Learn more](docs/quick-refactoring.md)**
|
||||
|
||||
### Tools & Function Calling
|
||||
@ -170,6 +193,143 @@ QodeAssist supports multiple LLM providers. Choose your preferred provider and f
|
||||
- Access linter/compiler issues
|
||||
- Enabled by default (can be disabled)
|
||||
|
||||
## Context Layers
|
||||
|
||||
QodeAssist uses a flexible prompt composition system that adapts to different contexts. Here's how prompts are constructed for each feature:
|
||||
|
||||
<details>
|
||||
<summary><strong>Code Completion (FIM Models)</strong> - Codestral, Qwen2.5-Coder, DeepSeek-Coder (click to expand)</summary>
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ CODE COMPLETION (FIM Models) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Examples: Codestral, Qwen2.5-Coder, DeepSeek-Coder │
|
||||
│ │
|
||||
│ 1. System Prompt (from Code Completion Settings - FIM variant) │
|
||||
│ 2. Project Rules: │
|
||||
│ └─ .qodeassist/rules/completion/*.md │
|
||||
│ 3. Open Files Context (optional, if enabled): │
|
||||
│ └─ Currently open editor files │
|
||||
│ 4. Code Context: │
|
||||
│ ├─ Code before cursor (prefix) │
|
||||
│ └─ Code after cursor (suffix) │
|
||||
│ │
|
||||
│ Final Prompt: FIM_Template(Prefix: SystemPrompt + Rules + OpenFiles + │
|
||||
│ CodeBefore, │
|
||||
│ Suffix: CodeAfter) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Code Completion (Non-FIM Models)</strong> - DeepSeek-Coder-Instruct, Qwen2.5-Coder-Instruct (click to expand)</summary>
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ CODE COMPLETION (Non-FIM/Chat Models) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Examples: DeepSeek-Coder-Instruct, Qwen2.5-Coder-Instruct │
|
||||
│ │
|
||||
│ 1. System Prompt (from Code Completion Settings - Non-FIM variant) │
|
||||
│ └─ Includes response formatting instructions │
|
||||
│ 2. Project Rules: │
|
||||
│ └─ .qodeassist/rules/completion/*.md │
|
||||
│ 3. Open Files Context (optional, if enabled): │
|
||||
│ └─ Currently open editor files │
|
||||
│ 4. Code Context: │
|
||||
│ ├─ File information (language, path) │
|
||||
│ ├─ Code before cursor │
|
||||
│ ├─ <cursor> marker │
|
||||
│ └─ Code after cursor │
|
||||
│ 5. User Message: "Complete the code at cursor position" │
|
||||
│ │
|
||||
│ Final Prompt: [System: SystemPrompt + Rules] │
|
||||
│ [User: OpenFiles + Context + CompletionRequest] │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Chat Assistant</strong> - Interactive coding assistant (click to expand)</summary>
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ CHAT ASSISTANT │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 1. System Prompt (from Chat Assistant Settings) │
|
||||
│ 2. Project Rules: │
|
||||
│ ├─ .qodeassist/rules/common/*.md │
|
||||
│ └─ .qodeassist/rules/chat/*.md │
|
||||
│ 3. File Context (optional): │
|
||||
│ ├─ Attached files (manual) │
|
||||
│ ├─ Linked files (persistent) │
|
||||
│ └─ Open editor files (if auto-sync enabled) │
|
||||
│ 4. Tool Definitions (if enabled): │
|
||||
│ ├─ ReadProjectFileByName │
|
||||
│ ├─ ListProjectFiles │
|
||||
│ ├─ SearchInProject │
|
||||
│ └─ GetIssuesList │
|
||||
│ 5. Conversation History │
|
||||
│ 6. User Message │
|
||||
│ │
|
||||
│ Final Prompt: [System: SystemPrompt + Rules + Tools] │
|
||||
│ [History: Previous messages] │
|
||||
│ [User: FileContext + UserMessage] │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Quick Refactoring</strong> - Inline code improvements (click to expand)</summary>
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ QUICK REFACTORING │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 1. System Prompt (from Quick Refactor Settings) │
|
||||
│ 2. Project Rules: │
|
||||
│ ├─ .qodeassist/rules/common/*.md │
|
||||
│ └─ .qodeassist/rules/quickrefactor/*.md │
|
||||
│ 3. Code Context: │
|
||||
│ ├─ File information (language, path) │
|
||||
│ ├─ Code before selection (configurable amount) │
|
||||
│ ├─ <selection_start> marker │
|
||||
│ ├─ Selected code (or current line) │
|
||||
│ ├─ <selection_end> marker │
|
||||
│ ├─ <cursor> marker (position within selection) │
|
||||
│ └─ Code after selection (configurable amount) │
|
||||
│ 4. Refactor Instruction: │
|
||||
│ ├─ Built-in (e.g., "Improve Code", "Alternative Solution") │
|
||||
│ ├─ Custom Instruction (from library) │
|
||||
│ │ └─ ~/.config/QtProject/qtcreator/qodeassist/ │
|
||||
│ │ quick_refactor/instructions/*.json │
|
||||
│ └─ Additional Details (optional user input) │
|
||||
│ 5. Tool Definitions (if enabled) │
|
||||
│ │
|
||||
│ Final Prompt: [System: SystemPrompt + Rules] │
|
||||
│ [User: Context + Markers + Instruction + Details] │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Key Points
|
||||
|
||||
- **Project Rules** are automatically loaded from `.qodeassist/rules/` directory structure
|
||||
- **System Prompts** are configured independently for each feature in Settings
|
||||
- **FIM vs Non-FIM models** for code completion use different System Prompts:
|
||||
- FIM models: Direct completion prompt
|
||||
- Non-FIM models: Prompt includes response formatting instructions
|
||||
- **Quick Refactor** has its own provider/model configuration, independent from Chat
|
||||
- **Custom Instructions** provide reusable templates that can be augmented with specific details
|
||||
- **Tool Calling** is available for Chat and Quick Refactor when enabled
|
||||
|
||||
See [Project Rules Documentation](docs/project-rules.md) and [Quick Refactoring Guide](docs/quick-refactoring.md) for more details.
|
||||
|
||||
## QtCreator Version Compatibility
|
||||
|
||||
| Qt Creator Version | QodeAssist Version |
|
||||
|
||||
115
RefactorContextHelper.hpp
Normal file
@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (C) 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 <QString>
|
||||
#include <QTextCursor>
|
||||
#include <QTextBlock>
|
||||
|
||||
#include <texteditor/texteditor.h>
|
||||
#include <utils/textutils.h>
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
struct RefactorContext
|
||||
{
|
||||
QString originalText;
|
||||
QString textBeforeCursor;
|
||||
QString textAfterCursor;
|
||||
QString contextBefore;
|
||||
QString contextAfter;
|
||||
int startPos{0};
|
||||
int endPos{0};
|
||||
bool isInsertion{false};
|
||||
};
|
||||
|
||||
class RefactorContextHelper
|
||||
{
|
||||
public:
|
||||
static RefactorContext extractContext(TextEditor::TextEditorWidget *editor,
|
||||
const Utils::Text::Range &range,
|
||||
int contextLinesBefore = 3,
|
||||
int contextLinesAfter = 3)
|
||||
{
|
||||
RefactorContext ctx;
|
||||
if (!editor) {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
QTextDocument *doc = editor->document();
|
||||
ctx.startPos = range.begin.toPositionInDocument(doc);
|
||||
ctx.endPos = range.end.toPositionInDocument(doc);
|
||||
ctx.isInsertion = (ctx.startPos == ctx.endPos);
|
||||
|
||||
if (!ctx.isInsertion) {
|
||||
QTextCursor cursor(doc);
|
||||
cursor.setPosition(ctx.startPos);
|
||||
cursor.setPosition(ctx.endPos, QTextCursor::KeepAnchor);
|
||||
ctx.originalText = cursor.selectedText();
|
||||
ctx.originalText.replace(QChar(0x2029), "\n");
|
||||
} else {
|
||||
QTextCursor cursor(doc);
|
||||
cursor.setPosition(ctx.startPos);
|
||||
|
||||
int posInBlock = cursor.positionInBlock();
|
||||
cursor.movePosition(QTextCursor::StartOfBlock);
|
||||
cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, posInBlock);
|
||||
ctx.textBeforeCursor = cursor.selectedText();
|
||||
|
||||
cursor.setPosition(ctx.startPos);
|
||||
cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
|
||||
ctx.textAfterCursor = cursor.selectedText();
|
||||
}
|
||||
|
||||
ctx.contextBefore = extractContextLines(doc, ctx.startPos, contextLinesBefore, true);
|
||||
ctx.contextAfter = extractContextLines(doc, ctx.endPos, contextLinesAfter, false);
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private:
|
||||
static QString extractContextLines(QTextDocument *doc, int position, int lineCount, bool before)
|
||||
{
|
||||
QTextCursor cursor(doc);
|
||||
cursor.setPosition(position);
|
||||
QTextBlock currentBlock = cursor.block();
|
||||
|
||||
QStringList lines;
|
||||
|
||||
if (before) {
|
||||
QTextBlock block = currentBlock.previous();
|
||||
for (int i = 0; i < lineCount && block.isValid(); ++i) {
|
||||
lines.prepend(block.text());
|
||||
block = block.previous();
|
||||
}
|
||||
} else {
|
||||
QTextBlock block = currentBlock.next();
|
||||
for (int i = 0; i < lineCount && block.isValid(); ++i) {
|
||||
lines.append(block.text());
|
||||
block = block.next();
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace QodeAssist
|
||||
|
||||
202
RefactorSuggestion.cpp
Normal file
@ -0,0 +1,202 @@
|
||||
/*
|
||||
* 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 "RefactorSuggestion.hpp"
|
||||
#include "LLMSuggestion.hpp"
|
||||
|
||||
#include <QTextBlock>
|
||||
#include <QTextCursor>
|
||||
#include <QTextDocument>
|
||||
|
||||
#include <texteditor/texteditor.h>
|
||||
#include <logger/Logger.hpp>
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
namespace {
|
||||
QString extractLeadingWhitespace(const QString &text)
|
||||
{
|
||||
QString indent;
|
||||
int firstLineEnd = text.indexOf('\n');
|
||||
QString firstLine = (firstLineEnd != -1) ? text.left(firstLineEnd) : text;
|
||||
for (int i = 0; i < firstLine.length(); ++i) {
|
||||
if (firstLine[i].isSpace()) {
|
||||
indent += firstLine[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return indent;
|
||||
}
|
||||
} // anonymous namespace
|
||||
|
||||
RefactorSuggestion::RefactorSuggestion(const Data &suggestion, QTextDocument *sourceDocument)
|
||||
: TextEditor::TextSuggestion([&suggestion, sourceDocument]() {
|
||||
Data expandedData = suggestion;
|
||||
|
||||
int startPos = suggestion.range.begin.toPositionInDocument(sourceDocument);
|
||||
int endPos = suggestion.range.end.toPositionInDocument(sourceDocument);
|
||||
startPos = qBound(0, startPos, sourceDocument->characterCount());
|
||||
endPos = qBound(0, endPos, sourceDocument->characterCount());
|
||||
|
||||
if (startPos != endPos) {
|
||||
QTextCursor startCursor(sourceDocument);
|
||||
startCursor.setPosition(startPos);
|
||||
int startPosInBlock = startCursor.positionInBlock();
|
||||
|
||||
if (startPosInBlock > 0) {
|
||||
startCursor.movePosition(QTextCursor::StartOfBlock);
|
||||
}
|
||||
|
||||
QTextCursor endCursor(sourceDocument);
|
||||
endCursor.setPosition(endPos);
|
||||
int endPosInBlock = endCursor.positionInBlock();
|
||||
|
||||
if (endPosInBlock > 0) {
|
||||
endCursor.movePosition(QTextCursor::EndOfBlock);
|
||||
if (!endCursor.atEnd()) {
|
||||
endCursor.movePosition(QTextCursor::NextCharacter);
|
||||
}
|
||||
}
|
||||
|
||||
Utils::Text::Position expandedBegin = Utils::Text::Position::fromPositionInDocument(
|
||||
sourceDocument, startCursor.position());
|
||||
Utils::Text::Position expandedEnd = Utils::Text::Position::fromPositionInDocument(
|
||||
sourceDocument, endCursor.position());
|
||||
|
||||
expandedData.range = Utils::Text::Range(expandedBegin, expandedEnd);
|
||||
}
|
||||
|
||||
return expandedData;
|
||||
}(), sourceDocument)
|
||||
, m_suggestionData(suggestion)
|
||||
{
|
||||
const QString refactoredText = suggestion.text;
|
||||
|
||||
int startPos = suggestion.range.begin.toPositionInDocument(sourceDocument);
|
||||
int endPos = suggestion.range.end.toPositionInDocument(sourceDocument);
|
||||
startPos = qBound(0, startPos, sourceDocument->characterCount());
|
||||
endPos = qBound(0, endPos, sourceDocument->characterCount());
|
||||
|
||||
QTextCursor startCursor(sourceDocument);
|
||||
startCursor.setPosition(startPos);
|
||||
|
||||
if (startPos == endPos) {
|
||||
QTextBlock block = startCursor.block();
|
||||
QString blockText = block.text();
|
||||
int startPosInBlock = startCursor.positionInBlock();
|
||||
|
||||
QString leftText = blockText.left(startPosInBlock);
|
||||
QString rightText = blockText.mid(startPosInBlock);
|
||||
|
||||
QString displayText = leftText + refactoredText + rightText;
|
||||
replacementDocument()->setPlainText(displayText);
|
||||
|
||||
} else {
|
||||
QTextCursor fullLinesCursor(sourceDocument);
|
||||
fullLinesCursor.setPosition(startPos);
|
||||
fullLinesCursor.movePosition(QTextCursor::StartOfBlock);
|
||||
int fullLinesStart = fullLinesCursor.position();
|
||||
|
||||
fullLinesCursor.setPosition(endPos);
|
||||
fullLinesCursor.movePosition(QTextCursor::EndOfBlock);
|
||||
int fullLinesEnd = fullLinesCursor.position();
|
||||
|
||||
fullLinesCursor.setPosition(fullLinesStart);
|
||||
fullLinesCursor.setPosition(fullLinesEnd, QTextCursor::KeepAnchor);
|
||||
QString fullLinesText = fullLinesCursor.selectedText();
|
||||
fullLinesText.replace(QChar(0x2029), "\n");
|
||||
|
||||
QString oldIndent = extractLeadingWhitespace(fullLinesText);
|
||||
QString newIndent = extractLeadingWhitespace(refactoredText);
|
||||
|
||||
QString displayText = refactoredText;
|
||||
if (newIndent.length() < oldIndent.length()) {
|
||||
QString indentDiff = oldIndent.left(oldIndent.length() - newIndent.length());
|
||||
QStringList lines = refactoredText.split('\n');
|
||||
if (!lines.isEmpty() && !lines[0].trimmed().isEmpty()) {
|
||||
lines[0] = indentDiff + lines[0];
|
||||
displayText = lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
replacementDocument()->setPlainText(displayText);
|
||||
}
|
||||
}
|
||||
|
||||
bool RefactorSuggestion::apply()
|
||||
{
|
||||
const QString text = m_suggestionData.text;
|
||||
const Utils::Text::Range range = m_suggestionData.range;
|
||||
|
||||
const QTextCursor startCursor = range.begin.toTextCursor(sourceDocument());
|
||||
const QTextCursor endCursor = range.end.toTextCursor(sourceDocument());
|
||||
|
||||
const int startPos = startCursor.position();
|
||||
const int endPos = endCursor.position();
|
||||
|
||||
QTextCursor editCursor(sourceDocument());
|
||||
editCursor.beginEditBlock();
|
||||
|
||||
if (startPos == endPos) {
|
||||
editCursor.setPosition(startPos);
|
||||
editCursor.insertText(text);
|
||||
} else {
|
||||
editCursor.setPosition(startPos);
|
||||
editCursor.setPosition(endPos, QTextCursor::KeepAnchor);
|
||||
QString selectedText = editCursor.selectedText();
|
||||
selectedText.replace(QChar(0x2029), "\n");
|
||||
|
||||
QString oldIndent = extractLeadingWhitespace(selectedText);
|
||||
QString newIndent = extractLeadingWhitespace(text);
|
||||
|
||||
QString textToInsert = text;
|
||||
if (newIndent.length() < oldIndent.length()) {
|
||||
QString indentDiff = oldIndent.left(oldIndent.length() - newIndent.length());
|
||||
QStringList lines = text.split('\n');
|
||||
if (!lines.isEmpty() && !lines[0].trimmed().isEmpty()) {
|
||||
lines[0] = indentDiff + lines[0];
|
||||
textToInsert = lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
editCursor.setPosition(startPos);
|
||||
editCursor.setPosition(endPos, QTextCursor::KeepAnchor);
|
||||
editCursor.removeSelectedText();
|
||||
editCursor.insertText(textToInsert);
|
||||
}
|
||||
|
||||
editCursor.endEditBlock();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RefactorSuggestion::applyWord(TextEditor::TextEditorWidget *widget)
|
||||
{
|
||||
Q_UNUSED(widget)
|
||||
return apply();
|
||||
}
|
||||
|
||||
bool RefactorSuggestion::applyLine(TextEditor::TextEditorWidget *widget)
|
||||
{
|
||||
Q_UNUSED(widget)
|
||||
return apply();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist
|
||||
|
||||
43
RefactorSuggestion.hpp
Normal file
@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 <texteditor/texteditor.h>
|
||||
#include <texteditor/textsuggestion.h>
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
class RefactorSuggestion : public TextEditor::TextSuggestion
|
||||
{
|
||||
public:
|
||||
RefactorSuggestion(const Data &suggestion, QTextDocument *sourceDocument);
|
||||
|
||||
bool apply() override;
|
||||
|
||||
bool applyWord(TextEditor::TextEditorWidget *widget) override;
|
||||
|
||||
bool applyLine(TextEditor::TextEditorWidget *widget) override;
|
||||
|
||||
private:
|
||||
Data m_suggestionData;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist
|
||||
|
||||
210
RefactorSuggestionHoverHandler.cpp
Normal file
@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright (C) 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 "RefactorSuggestionHoverHandler.hpp"
|
||||
#include "RefactorSuggestion.hpp"
|
||||
|
||||
#include <QColor>
|
||||
#include <QHBoxLayout>
|
||||
#include <QPushButton>
|
||||
#include <QScopeGuard>
|
||||
#include <QTextBlock>
|
||||
#include <QTextCursor>
|
||||
#include <QWidget>
|
||||
|
||||
#include <texteditor/textdocumentlayout.h>
|
||||
#include <texteditor/texteditor.h>
|
||||
#include <utils/theme/theme.h>
|
||||
#include <utils/tooltip/tooltip.h>
|
||||
|
||||
#include <logger/Logger.hpp>
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
RefactorSuggestionHoverHandler::RefactorSuggestionHoverHandler()
|
||||
{
|
||||
setPriority(Priority_Suggestion);
|
||||
}
|
||||
|
||||
void RefactorSuggestionHoverHandler::setSuggestionRange(const Utils::Text::Range &range)
|
||||
{
|
||||
m_suggestionRange = range;
|
||||
m_hasSuggestion = true;
|
||||
}
|
||||
|
||||
void RefactorSuggestionHoverHandler::clearSuggestionRange()
|
||||
{
|
||||
m_hasSuggestion = false;
|
||||
}
|
||||
|
||||
void RefactorSuggestionHoverHandler::identifyMatch(
|
||||
TextEditor::TextEditorWidget *editorWidget,
|
||||
int pos,
|
||||
ReportPriority report)
|
||||
{
|
||||
|
||||
QScopeGuard cleanup([&] { report(Priority_None); });
|
||||
|
||||
if (!editorWidget->suggestionVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QTextCursor cursor(editorWidget->document());
|
||||
cursor.setPosition(pos);
|
||||
m_block = cursor.block();
|
||||
|
||||
#if QODEASSIST_QT_CREATOR_VERSION_MAJOR >= 17
|
||||
auto *suggestion = dynamic_cast<RefactorSuggestion *>(
|
||||
TextEditor::TextBlockUserData::suggestion(m_block));
|
||||
#else
|
||||
auto *userData = TextEditor::TextDocumentLayout::textUserData(m_block);
|
||||
if (!userData) {
|
||||
LOG_MESSAGE("RefactorSuggestionHoverHandler: No user data in block");
|
||||
return;
|
||||
}
|
||||
|
||||
auto *suggestion = dynamic_cast<RefactorSuggestion *>(userData->suggestion());
|
||||
#endif
|
||||
|
||||
if (!suggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup.dismiss();
|
||||
report(Priority_Suggestion);
|
||||
}
|
||||
|
||||
void RefactorSuggestionHoverHandler::operateTooltip(
|
||||
TextEditor::TextEditorWidget *editorWidget,
|
||||
const QPoint &point)
|
||||
{
|
||||
Q_UNUSED(point)
|
||||
|
||||
#if QODEASSIST_QT_CREATOR_VERSION_MAJOR >= 17
|
||||
auto *suggestion = dynamic_cast<RefactorSuggestion *>(
|
||||
TextEditor::TextBlockUserData::suggestion(m_block));
|
||||
#else
|
||||
auto *userData = TextEditor::TextDocumentLayout::textUserData(m_block);
|
||||
if (!userData) {
|
||||
LOG_MESSAGE("RefactorSuggestionHoverHandler::operateTooltip: No user data in block");
|
||||
return;
|
||||
}
|
||||
|
||||
auto *suggestion = dynamic_cast<RefactorSuggestion *>(userData->suggestion());
|
||||
#endif
|
||||
|
||||
if (!suggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto *widget = new QWidget();
|
||||
auto *layout = new QHBoxLayout(widget);
|
||||
layout->setContentsMargins(4, 3, 4, 3);
|
||||
layout->setSpacing(6);
|
||||
|
||||
const QColor normalBg = Utils::creatorColor(Utils::Theme::BackgroundColorNormal);
|
||||
const QColor hoverBg = Utils::creatorColor(Utils::Theme::BackgroundColorHover);
|
||||
const QColor selectedBg = Utils::creatorColor(Utils::Theme::BackgroundColorSelected);
|
||||
const QColor textColor = Utils::creatorColor(Utils::Theme::TextColorNormal);
|
||||
const QColor borderColor = Utils::creatorColor(Utils::Theme::SplitterColor);
|
||||
const QColor successColor = Utils::creatorColor(Utils::Theme::TextColorNormal);
|
||||
const QColor errorColor = Utils::creatorColor(Utils::Theme::TextColorError);
|
||||
|
||||
auto *applyButton = new QPushButton("✓ Apply", widget);
|
||||
applyButton->setFocusPolicy(Qt::NoFocus);
|
||||
applyButton->setToolTip("Apply refactoring (Tab)");
|
||||
applyButton->setCursor(Qt::PointingHandCursor);
|
||||
applyButton->setStyleSheet(QString(
|
||||
"QPushButton {"
|
||||
" background-color: %1;"
|
||||
" color: %2;"
|
||||
" border: 1px solid %3;"
|
||||
" border-radius: 3px;"
|
||||
" padding: 4px 12px;"
|
||||
" font-weight: bold;"
|
||||
" font-size: 11px;"
|
||||
" min-width: 60px;"
|
||||
"}"
|
||||
"QPushButton:hover {"
|
||||
" background-color: %4;"
|
||||
" border-color: %2;"
|
||||
"}"
|
||||
"QPushButton:pressed {"
|
||||
" background-color: %5;"
|
||||
"}")
|
||||
.arg(selectedBg.name())
|
||||
.arg(successColor.name())
|
||||
.arg(borderColor.name())
|
||||
.arg(selectedBg.lighter(110).name())
|
||||
.arg(selectedBg.darker(110).name()));
|
||||
QObject::connect(applyButton, &QPushButton::clicked, widget, [this]() {
|
||||
Utils::ToolTip::hide();
|
||||
if (m_applyCallback) {
|
||||
m_applyCallback();
|
||||
}
|
||||
});
|
||||
|
||||
auto *dismissButton = new QPushButton("✕ Dismiss", widget);
|
||||
dismissButton->setFocusPolicy(Qt::NoFocus);
|
||||
dismissButton->setToolTip("Dismiss refactoring (Esc)");
|
||||
dismissButton->setCursor(Qt::PointingHandCursor);
|
||||
dismissButton->setStyleSheet(QString(
|
||||
"QPushButton {"
|
||||
" background-color: %1;"
|
||||
" color: %2;"
|
||||
" border: 1px solid %3;"
|
||||
" border-radius: 3px;"
|
||||
" padding: 4px 12px;"
|
||||
" font-size: 11px;"
|
||||
" min-width: 60px;"
|
||||
"}"
|
||||
"QPushButton:hover {"
|
||||
" background-color: %4;"
|
||||
" color: %5;"
|
||||
" border-color: %5;"
|
||||
"}"
|
||||
"QPushButton:pressed {"
|
||||
" background-color: %6;"
|
||||
"}")
|
||||
.arg(normalBg.name())
|
||||
.arg(textColor.name())
|
||||
.arg(borderColor.name())
|
||||
.arg(hoverBg.name())
|
||||
.arg(errorColor.name())
|
||||
.arg(hoverBg.darker(110).name()));
|
||||
QObject::connect(dismissButton, &QPushButton::clicked, widget, [this]() {
|
||||
Utils::ToolTip::hide();
|
||||
if (m_dismissCallback) {
|
||||
m_dismissCallback();
|
||||
}
|
||||
});
|
||||
|
||||
layout->addWidget(applyButton);
|
||||
layout->addWidget(dismissButton);
|
||||
|
||||
const QRect cursorRect = editorWidget->cursorRect(editorWidget->textCursor());
|
||||
QPoint pos = editorWidget->viewport()->mapToGlobal(cursorRect.topLeft())
|
||||
- Utils::ToolTip::offsetFromPosition();
|
||||
pos.ry() -= widget->sizeHint().height();
|
||||
|
||||
Utils::ToolTip::show(pos, widget, editorWidget);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist
|
||||
|
||||
72
RefactorSuggestionHoverHandler.hpp
Normal file
@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 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 <functional>
|
||||
#include <QTextBlock>
|
||||
|
||||
#include <texteditor/basehoverhandler.h>
|
||||
#include <utils/textutils.h>
|
||||
|
||||
namespace TextEditor {
|
||||
class TextEditorWidget;
|
||||
}
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
/**
|
||||
* @brief Hover handler for refactoring suggestions
|
||||
*
|
||||
* Shows interactive tooltip with Apply/Dismiss buttons when hovering over
|
||||
* a refactoring suggestion in the editor.
|
||||
*/
|
||||
class RefactorSuggestionHoverHandler : public TextEditor::BaseHoverHandler
|
||||
{
|
||||
public:
|
||||
using ApplyCallback = std::function<void()>;
|
||||
using DismissCallback = std::function<void()>;
|
||||
|
||||
RefactorSuggestionHoverHandler();
|
||||
|
||||
void setSuggestionRange(const Utils::Text::Range &range);
|
||||
void clearSuggestionRange();
|
||||
bool hasSuggestion() const { return m_hasSuggestion; }
|
||||
|
||||
void setApplyCallback(ApplyCallback callback) { m_applyCallback = std::move(callback); }
|
||||
void setDismissCallback(DismissCallback callback) { m_dismissCallback = std::move(callback); }
|
||||
|
||||
protected:
|
||||
void identifyMatch(TextEditor::TextEditorWidget *editorWidget,
|
||||
int pos,
|
||||
ReportPriority report) override;
|
||||
|
||||
void operateTooltip(TextEditor::TextEditorWidget *editorWidget,
|
||||
const QPoint &point) override;
|
||||
|
||||
private:
|
||||
Utils::Text::Range m_suggestionRange;
|
||||
bool m_hasSuggestion = false;
|
||||
ApplyCallback m_applyCallback;
|
||||
DismissCallback m_dismissCallback;
|
||||
QTextBlock m_block;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist
|
||||
|
||||
@ -11,7 +11,12 @@ qt_add_qml_module(QodeAssistUIControls
|
||||
qml/Badge.qml
|
||||
qml/QoAButton.qml
|
||||
qml/QoATextSlider.qml
|
||||
qml/QoAComboBox.qml
|
||||
qml/FadeListItemAnimation.qml
|
||||
|
||||
RESOURCES
|
||||
icons/dropdown-arrow-light.svg
|
||||
icons/dropdown-arrow-dark.svg
|
||||
)
|
||||
|
||||
target_link_libraries(QodeAssistUIControls
|
||||
|
||||
4
UIControls/icons/dropdown-arrow-dark.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 8L0 0H12L6 8Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 148 B |
4
UIControls/icons/dropdown-arrow-light.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 8L0 0H12L6 8Z" fill="#000000"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 148 B |
161
UIControls/qml/QoAComboBox.qml
Normal file
@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright (C) 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/>.
|
||||
*/
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.Basic as Basic
|
||||
|
||||
Basic.ComboBox {
|
||||
id: control
|
||||
|
||||
implicitWidth: Math.min(contentItem.implicitWidth + 8, 300)
|
||||
implicitHeight: 30
|
||||
|
||||
indicator: Image {
|
||||
id: dropdownIcon
|
||||
|
||||
x: control.width - width - 10
|
||||
y: control.topPadding + (control.availableHeight - height) / 2
|
||||
|
||||
width: 12
|
||||
height: 8
|
||||
source: palette.window.hslLightness > 0.5
|
||||
? "qrc:/qt/qml/UIControls/icons/dropdown-arrow-light.svg"
|
||||
: "qrc:/qt/qml/UIControls/icons/dropdown-arrow-dark.svg"
|
||||
sourceSize: Qt.size(width, height)
|
||||
|
||||
rotation: control.popup.visible ? 180 : 0
|
||||
|
||||
Behavior on rotation {
|
||||
NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
id: bg
|
||||
|
||||
implicitWidth: control.implicitWidth
|
||||
implicitHeight: 30
|
||||
color: !control.enabled || !control.down ? palette.button : palette.dark
|
||||
border.color: !control.enabled || (!control.hovered && !control.visualFocus)
|
||||
? palette.mid
|
||||
: palette.highlight
|
||||
border.width: 1
|
||||
radius: 5
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 150 }
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: 150 }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: bg
|
||||
radius: bg.radius
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: Qt.alpha(palette.highlight, 0.4) }
|
||||
GradientStop { position: 1.0; color: Qt.alpha(palette.highlight, 0.2) }
|
||||
}
|
||||
opacity: control.hovered ? 0.3 : 0.01
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 250 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
leftPadding: 10
|
||||
rightPadding: 30
|
||||
text: control.displayText
|
||||
font.pixelSize: 12
|
||||
color: palette.text
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideNone
|
||||
}
|
||||
|
||||
popup: Popup {
|
||||
y: control.height + 2
|
||||
width: control.width
|
||||
implicitHeight: Math.min(contentItem.implicitHeight, 300)
|
||||
padding: 4
|
||||
|
||||
contentItem: ListView {
|
||||
clip: true
|
||||
implicitHeight: contentHeight
|
||||
model: control.popup.visible ? control.delegateModel : null
|
||||
currentIndex: control.highlightedIndex
|
||||
boundsBehavior: ListView.StopAtBounds
|
||||
highlightMoveDuration: 0
|
||||
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
policy: ScrollBar.AsNeeded
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: palette.base
|
||||
border.color: Qt.lighter(palette.mid, 1.1)
|
||||
border.width: 1
|
||||
radius: 5
|
||||
}
|
||||
|
||||
enter: Transition {
|
||||
NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: 150 }
|
||||
}
|
||||
|
||||
exit: Transition {
|
||||
NumberAnimation { property: "opacity"; from: 1.0; to: 0.0; duration: 100 }
|
||||
}
|
||||
}
|
||||
|
||||
delegate: ItemDelegate {
|
||||
width: control.width - 8
|
||||
height: 32
|
||||
|
||||
contentItem: Text {
|
||||
text: modelData
|
||||
color: palette.text
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: 10
|
||||
}
|
||||
|
||||
highlighted: control.highlightedIndex === index
|
||||
|
||||
background: Rectangle {
|
||||
radius: 4
|
||||
color: highlighted
|
||||
? Qt.alpha(palette.highlight, 0.2)
|
||||
: parent.hovered
|
||||
? (palette.window.hslLightness > 0.5
|
||||
? Qt.darker(palette.base, 1.05)
|
||||
: Qt.lighter(palette.base, 1.15))
|
||||
: "transparent"
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 100 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -70,4 +70,18 @@ QString ProjectUtils::findFileInProject(const QString &filename)
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString ProjectUtils::getProjectRoot()
|
||||
{
|
||||
QList<ProjectExplorer::Project *> projects = ProjectExplorer::ProjectManager::projects();
|
||||
|
||||
if (!projects.isEmpty()) {
|
||||
auto project = projects.first();
|
||||
if (project) {
|
||||
return project->projectDirectory().toFSPathString();
|
||||
}
|
||||
}
|
||||
|
||||
return QString();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Context
|
||||
|
||||
@ -52,6 +52,16 @@ public:
|
||||
* @return Absolute file path if found, empty string otherwise
|
||||
*/
|
||||
static QString findFileInProject(const QString &filename);
|
||||
|
||||
/**
|
||||
* @brief Get the project root directory
|
||||
*
|
||||
* Returns the root directory of the first open project.
|
||||
* If multiple projects are open, returns the first one.
|
||||
*
|
||||
* @return Absolute path to project root, or empty string if no project is open
|
||||
*/
|
||||
static QString getProjectRoot();
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Context
|
||||
|
||||
@ -28,6 +28,44 @@ ollama run qwen2.5-coder:32b
|
||||
|
||||
You're all set! QodeAssist is now ready to use in Qt Creator.
|
||||
|
||||
## Extended Thinking Mode
|
||||
|
||||
Ollama supports extended thinking mode for models that are capable of deep reasoning (such as DeepSeek-R1, QwQ, and similar reasoning models). This mode allows the model to show its step-by-step reasoning process before providing the final answer.
|
||||
|
||||
### How to Enable
|
||||
|
||||
**For Chat Assistant:**
|
||||
1. Navigate to Qt Creator > Preferences > QodeAssist > Chat Assistant
|
||||
2. In the "Extended Thinking (Claude, Ollama)" section, check "Enable extended thinking mode"
|
||||
3. Select a reasoning-capable model (e.g., deepseek-r1:8b, qwq:32b)
|
||||
4. Click Apply
|
||||
|
||||
**For Quick Refactoring:**
|
||||
1. Navigate to Qt Creator > Preferences > QodeAssist > Quick Refactor
|
||||
2. Check "Enable Thinking Mode"
|
||||
3. Configure thinking budget and max tokens as needed
|
||||
4. Click Apply
|
||||
|
||||
### Supported Models
|
||||
|
||||
Thinking mode works best with models specifically designed for reasoning:
|
||||
- **DeepSeek-R1** series (deepseek-r1:8b, deepseek-r1:14b, deepseek-r1:32b)
|
||||
- **QwQ** series (qwq:32b)
|
||||
- Other models trained for chain-of-thought reasoning
|
||||
|
||||
### How It Works
|
||||
|
||||
When thinking mode is enabled:
|
||||
1. The model generates internal reasoning (visible in the chat as "Thinking" blocks)
|
||||
2. After reasoning, it provides the final answer
|
||||
3. You can collapse/expand thinking blocks to focus on the final answer
|
||||
4. Temperature is automatically set to 1.0 for optimal reasoning performance
|
||||
|
||||
**Technical Details:**
|
||||
- Thinking mode adds the `enable_thinking: true` parameter to requests sent to Ollama
|
||||
- This is natively supported by the Ollama API for compatible models
|
||||
- Works in both Chat Assistant and Quick Refactoring contexts
|
||||
|
||||
<details>
|
||||
<summary>Example of Ollama settings: (click to expand)</summary>
|
||||
|
||||
|
||||
@ -1,18 +1,277 @@
|
||||
# Quick Refactoring Feature
|
||||
|
||||
Quick Refactoring provides AI-assisted code improvements directly in your editor. Select code, press the hotkey, and get instant refactoring suggestions.
|
||||
|
||||
## Overview
|
||||
|
||||
Quick Refactoring provides AI-assisted code refactoring with its own dedicated provider and model configuration, allowing you to use different settings than your Chat Assistant. You can use built-in quick actions or create your own custom instructions library.
|
||||
|
||||
## Setup
|
||||
|
||||
Since this is actually a small chat with redirected output, the main settings of the provider, model and template are taken from the chat settings.
|
||||
Quick Refactoring has independent configuration separate from Chat Assistant:
|
||||
|
||||
## Using
|
||||
### Provider & Model Configuration
|
||||
|
||||
The request to model consist of instructions to model, selection code and cursor position.
|
||||
Configure provider and model in: `Qt Creator → Preferences → QodeAssist → General Settings`
|
||||
|
||||
The default instruction is: "Refactor the code to improve its quality and maintainability." and sending if text field is empty.
|
||||
Under the **Quick Refactor** section, you can set:
|
||||
- **Provider**: Choose from Ollama, Claude, OpenAI, Google AI, etc.
|
||||
- **Model**: Select the specific model for refactoring tasks
|
||||
- **Template**: Choose the chat template for this provider
|
||||
- **URL**: Set the API endpoint
|
||||
- **API Key**: Configure authentication (for cloud providers)
|
||||
|
||||
Also there buttons to quick call instractions:
|
||||
- Repeat latest instruction, will activate after sending first request in QtCreator session
|
||||
- Improve current selection code
|
||||
- Suggestion alternative variant of selection code
|
||||
- Other instructions[TBD]
|
||||
This allows you to:
|
||||
- Use a faster/cheaper model for refactoring than for chat
|
||||
- Use a local model for refactoring and cloud model for chat
|
||||
- Optimize costs by using different providers for different tasks
|
||||
|
||||
### Quick Refactor Settings
|
||||
|
||||
Additional refactoring-specific options in: `Qt Creator → Preferences → QodeAssist → Quick Refactor`
|
||||
|
||||
Configure:
|
||||
- **Context Settings**: How much code context to send
|
||||
- Read full file or N lines before/after selection
|
||||
- **LLM Parameters**: Temperature, max tokens, top_p, top_k
|
||||
- **Advanced Options**: Penalties, context window size
|
||||
- **Features**: Tool calling, extended thinking mode
|
||||
- **System Prompt**: Customize the base prompt for refactoring
|
||||
- **How quick refactor looks**: Display type and sizes
|
||||
|
||||
## Using Quick Refactoring
|
||||
|
||||
### Basic Usage
|
||||
|
||||
1. **Select Code** (or place cursor for line-level refactoring)
|
||||
2. **Trigger Quick Refactor**: Press `Ctrl+Alt+R` (Windows/Linux) or `⌥⌘R` (macOS)
|
||||
3. **Choose Action**:
|
||||
- Use a built-in quick action button
|
||||
- Select a custom instruction from the dropdown
|
||||
- Type your own instruction
|
||||
4. **Get Results**: AI generates refactored code directly replacing your selection
|
||||
|
||||
### Quick Action Buttons
|
||||
|
||||
The dialog provides three built-in quick actions:
|
||||
|
||||
| Button | Description |
|
||||
|--------|-------------|
|
||||
| **Repeat Last** | Reuses the last instruction from your session (enabled after first use) |
|
||||
| **Improve Code** | Enhances readability, efficiency, and maintainability with best practices |
|
||||
| **Alternative Solution** | Suggests different implementation approaches and patterns |
|
||||
|
||||
## Custom Instructions
|
||||
|
||||
### Overview
|
||||
|
||||
Custom Instructions allow you to create a reusable library of refactoring templates. Instead of typing the same instructions repeatedly, save them once and access them instantly through the searchable dropdown.
|
||||
|
||||
**Key Features:**
|
||||
- **Quick Access**: Search and select instructions by typing
|
||||
- **Flexible**: Use as-is or add extra details for each use
|
||||
- **Manageable**: Easy create, edit, and delete interface
|
||||
- **Persistent**: Instructions saved locally and loaded on startup
|
||||
- **Accessible**: Direct access to instruction files folder
|
||||
|
||||
### Creating Custom Instructions
|
||||
|
||||
1. Click the **`+`** button in the Quick Refactor dialog
|
||||
2. Fill in the form:
|
||||
- **Name**: Short descriptive title (e.g., "Add Documentation")
|
||||
- **Instruction Body**: Detailed prompt for the LLM
|
||||
|
||||
**Example instruction:**
|
||||
|
||||
```
|
||||
Name: Add Documentation
|
||||
Body: Add comprehensive documentation to the selected code or code afer cursor following:
|
||||
Doxygen style. Include parameter descriptions, return value
|
||||
documentation, and usage examples where applicable.
|
||||
```
|
||||
|
||||
|
||||
### Using Custom Instructions
|
||||
|
||||
#### Method 1: Select and Use
|
||||
1. Open Quick Refactor dialog (`Ctrl+Alt+R` / `⌥⌘R`)
|
||||
2. Click the dropdown or start typing instruction name
|
||||
3. Select instruction (autocomplete will help)
|
||||
4. Optionally add extra details in the text field below
|
||||
5. Press OK
|
||||
|
||||
|
||||
#### Method 2: Search by Typing
|
||||
1. Open Quick Refactor dialog
|
||||
2. Start typing in the instruction dropdown (e.g., "doc...")
|
||||
3. Autocomplete shows matching instructions
|
||||
4. Select with arrow keys or click
|
||||
5. Add optional details and execute
|
||||
|
||||
**Search Features:**
|
||||
- Case-insensitive search
|
||||
- Match anywhere in instruction name
|
||||
- Keyboard navigation (arrow keys, Enter)
|
||||
- Instant filtering as you type
|
||||
|
||||
### Combining Instructions with Additional Details
|
||||
|
||||
Custom instructions serve as **base templates** that you can augment with specific requirements:
|
||||
|
||||
**Example 1 - Use instruction as-is:**
|
||||
```
|
||||
Selected: "Add Documentation"
|
||||
Additional text: [empty]
|
||||
→ Sends: "Add comprehensive documentation..."
|
||||
```
|
||||
|
||||
**Example 2 - Add specific requirements:**
|
||||
```
|
||||
Selected: "Optimize Performance"
|
||||
Additional text: "Focus on reducing memory allocations"
|
||||
→ Sends: "Optimize Performance instructions...
|
||||
|
||||
Focus on reducing memory allocations"
|
||||
```
|
||||
|
||||
This approach allows maximum flexibility while maintaining a clean instruction library.
|
||||
|
||||
### Managing Custom Instructions
|
||||
|
||||
The Quick Refactor dialog provides full CRUD operations:
|
||||
|
||||
| Button | Action | Description |
|
||||
|--------|--------|-------------|
|
||||
| **+** | Add | Create new custom instruction |
|
||||
| **✎** | Edit | Modify selected instruction |
|
||||
| **−** | Delete | Remove selected instruction (with confirmation) |
|
||||
| **📁** | Open Folder | Open instructions directory in file manager |
|
||||
|
||||
**Edit/Delete:**
|
||||
- Select an instruction from dropdown (or type its name)
|
||||
- Click Edit (✎) or Delete (−) button
|
||||
- Confirm changes
|
||||
|
||||
<!-- PLACEHOLDER_IMAGE: Screenshot showing instruction management buttons -->
|
||||
|
||||
### Storage Location
|
||||
|
||||
Custom instructions are stored as JSON files in:
|
||||
|
||||
```
|
||||
~/.config/QtProject/qtcreator/qodeassist/quick_refactor/instructions/
|
||||
```
|
||||
|
||||
**File Naming Format:**
|
||||
```
|
||||
Instruction_Name_with_underscores_{unique-uuid}.json
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
Add_Documentation_a7f3c92d-8e4b-4f1a-9c0e-1d2f3a4b5c6d.json
|
||||
Optimize_Performance_3b8e4f9a-7c2d-4e1b-8f3a-9c1d2e3f4a5b.json
|
||||
Fix_Code_Style_c5d6e7f8-9a0b-1c2d-3e4f-5a6b7c8d9e0f.json
|
||||
```
|
||||
|
||||
**File Format:**
|
||||
```json
|
||||
{
|
||||
"id": "unique-uuid",
|
||||
"name": "Add Documentation",
|
||||
"body": "Add comprehensive documentation...",
|
||||
"version": "0.1"
|
||||
}
|
||||
```
|
||||
|
||||
### Backup and Sharing
|
||||
|
||||
Since instructions are simple JSON files, you can:
|
||||
|
||||
1. **Backup**: Copy the instructions directory
|
||||
2. **Share**: Share JSON files with team members
|
||||
3. **Version Control**: Add to your dotfiles repository
|
||||
4. **Edit Manually**: Modify JSON files directly if needed
|
||||
|
||||
Click the **📁** button to quickly open the instructions folder in your file manager.
|
||||
|
||||
## Context and Scope
|
||||
|
||||
### What Gets Sent to the LLM
|
||||
|
||||
The LLM receives:
|
||||
- **Selected Code** (or current line if no selection)
|
||||
- **Context**: Surrounding code (configurable amount)
|
||||
- **File Information**: Language, file path
|
||||
- **Cursor Position**: Marked with `<cursor>` tag
|
||||
- **Selection Markers**: `<selection_start>` and `<selection_end>` tags
|
||||
- **Your Instructions**: Built-in, custom, or typed
|
||||
- **Project Rules**: If configured (see [Project Rules](project-rules.md))
|
||||
|
||||
### Context Configuration
|
||||
|
||||
Configure context amount in: `Qt Creator → Preferences → QodeAssist → Quick Refactor`
|
||||
|
||||
Options:
|
||||
- **Read Full File**: Send entire file as context
|
||||
- **Read File Parts**: Send N lines before/after selection (configurable in "Read Strings Before/After Cursor")
|
||||
|
||||
## Advanced Settings
|
||||
|
||||
Access all refactoring settings in: `Qt Creator → Preferences → QodeAssist → Quick Refactor`
|
||||
|
||||
### Available Options:
|
||||
|
||||
**Context Settings:**
|
||||
- Read full file vs. file parts
|
||||
- Number of lines before/after cursor
|
||||
|
||||
**LLM Parameters:**
|
||||
- Temperature (creativity/randomness)
|
||||
- Max tokens (response length)
|
||||
- Top P (nucleus sampling)
|
||||
- Top K (vocabulary filtering)
|
||||
- Presence penalty
|
||||
- Frequency penalty
|
||||
|
||||
**Ollama-specific:**
|
||||
- Lifetime parameter
|
||||
- Context window size
|
||||
|
||||
**Features:**
|
||||
- Enable/disable tool calling
|
||||
- Extended thinking mode (for supported models)
|
||||
- Thinking budget and max tokens
|
||||
|
||||
**Customization:**
|
||||
- System prompt editing
|
||||
- Use open files in context (optional)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Instruction Not Found
|
||||
- Ensure you've typed the exact name or selected from dropdown
|
||||
- Check if instruction file exists in instructions directory
|
||||
- Reload Qt Creator if instructions were added externally
|
||||
|
||||
### Poor Results
|
||||
- Try adding more specific details in the additional text field
|
||||
- Adjust context settings to provide more/less code
|
||||
- Use extended thinking mode for complex refactorings
|
||||
- Check if your model supports the complexity of the task
|
||||
|
||||
### Instructions Not Loading
|
||||
- Verify folder exists: `~/.config/QtProject/qtcreator/qodeassist/quick_refactor/instructions/`
|
||||
- Check JSON file format validity
|
||||
- Review Qt Creator logs for parsing errors
|
||||
- Try restarting Qt Creator
|
||||
|
||||
Fully local setup for offline or secure environments.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Project Rules](project-rules.md) - Project-specific AI behavior customization
|
||||
- [File Context](file-context.md) - Attaching files to chat context
|
||||
- [Ignoring Files](ignoring-files.md) - Exclude files from AI context
|
||||
- [Provider Configuration](../README.md#configuration) - Setting up LLM providers
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -36,6 +36,14 @@ enum ToolPermission {
|
||||
NetworkAccess = 1 << 2
|
||||
};
|
||||
Q_DECLARE_FLAGS(ToolPermissions, ToolPermission)
|
||||
Q_DECLARE_OPERATORS_FOR_FLAGS(ToolPermissions)
|
||||
|
||||
enum class RunToolsFilter {
|
||||
ALL, // Run all tools (no filtering)
|
||||
OnlyRead, // Run only read tools (FileSystemRead + None)
|
||||
OnlyWrite, // Run only write tools (FileSystemWrite)
|
||||
OnlyNetworking // Run only network tools (NetworkAccess)
|
||||
};
|
||||
|
||||
class BaseTool : public QObject
|
||||
{
|
||||
|
||||
@ -18,6 +18,7 @@ add_library(LLMCore STATIC
|
||||
BaseTool.hpp BaseTool.cpp
|
||||
ContentBlocks.hpp
|
||||
RulesLoader.hpp RulesLoader.cpp
|
||||
ResponseCleaner.hpp
|
||||
)
|
||||
|
||||
target_link_libraries(LLMCore
|
||||
|
||||
@ -68,6 +68,54 @@ private:
|
||||
QString m_text;
|
||||
};
|
||||
|
||||
class ImageContent : public ContentBlock
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
enum class ImageSourceType { Base64, Url };
|
||||
|
||||
ImageContent(const QString &data, const QString &mediaType, ImageSourceType sourceType = ImageSourceType::Base64)
|
||||
: ContentBlock()
|
||||
, m_data(data)
|
||||
, m_mediaType(mediaType)
|
||||
, m_sourceType(sourceType)
|
||||
{}
|
||||
|
||||
QString type() const override { return "image"; }
|
||||
QString data() const { return m_data; }
|
||||
QString mediaType() const { return m_mediaType; }
|
||||
ImageSourceType sourceType() const { return m_sourceType; }
|
||||
|
||||
QJsonValue toJson(ProviderFormat format) const override
|
||||
{
|
||||
if (format == ProviderFormat::Claude) {
|
||||
QJsonObject source;
|
||||
if (m_sourceType == ImageSourceType::Base64) {
|
||||
source["type"] = "base64";
|
||||
source["media_type"] = m_mediaType;
|
||||
source["data"] = m_data;
|
||||
} else {
|
||||
source["type"] = "url";
|
||||
source["url"] = m_data;
|
||||
}
|
||||
return QJsonObject{{"type", "image"}, {"source", source}};
|
||||
} else { // OpenAI format
|
||||
QJsonObject imageUrl;
|
||||
if (m_sourceType == ImageSourceType::Base64) {
|
||||
imageUrl["url"] = QString("data:%1;base64,%2").arg(m_mediaType, m_data);
|
||||
} else {
|
||||
imageUrl["url"] = m_data;
|
||||
}
|
||||
return QJsonObject{{"type", "image_url"}, {"image_url", imageUrl}};
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
QString m_data;
|
||||
QString m_mediaType;
|
||||
ImageSourceType m_sourceType;
|
||||
};
|
||||
|
||||
class ToolUseContent : public ContentBlock
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
@ -24,6 +24,15 @@
|
||||
|
||||
namespace QodeAssist::LLMCore {
|
||||
|
||||
struct ImageAttachment
|
||||
{
|
||||
QString data; // Base64 encoded data or URL
|
||||
QString mediaType; // e.g., "image/png", "image/jpeg", "image/webp", "image/gif"
|
||||
bool isUrl = false; // true if data is URL, false if base64
|
||||
|
||||
bool operator==(const ImageAttachment &) const = default;
|
||||
};
|
||||
|
||||
struct Message
|
||||
{
|
||||
QString role;
|
||||
@ -31,6 +40,7 @@ struct Message
|
||||
QString signature;
|
||||
bool isThinking = false;
|
||||
bool isRedacted = false;
|
||||
std::optional<QVector<ImageAttachment>> images;
|
||||
|
||||
// clang-format off
|
||||
bool operator==(const Message&) const = default;
|
||||
|
||||
@ -53,7 +53,8 @@ public:
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled)
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
= 0;
|
||||
virtual QList<QString> getInstalledModels(const QString &url) = 0;
|
||||
virtual QList<QString> validateRequest(const QJsonObject &request, TemplateType type) = 0;
|
||||
@ -66,6 +67,7 @@ public:
|
||||
|
||||
virtual bool supportsTools() const { return false; };
|
||||
virtual bool supportThinking() const { return false; };
|
||||
virtual bool supportImage() const { return false; };
|
||||
|
||||
virtual void cancelRequest(const RequestID &requestId);
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ enum class ProviderID {
|
||||
Claude,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenAIResponses,
|
||||
MistralAI,
|
||||
OpenRouter,
|
||||
GoogleAI,
|
||||
|
||||
119
llmcore/ResponseCleaner.hpp
Normal file
@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 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 <QString>
|
||||
#include <QStringList>
|
||||
#include <QRegularExpression>
|
||||
|
||||
namespace QodeAssist::LLMCore {
|
||||
|
||||
class ResponseCleaner
|
||||
{
|
||||
public:
|
||||
static QString clean(const QString &response)
|
||||
{
|
||||
QString cleaned = removeCodeBlocks(response);
|
||||
cleaned = trimWhitespace(cleaned);
|
||||
cleaned = removeExplanations(cleaned);
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private:
|
||||
static QString removeCodeBlocks(const QString &text)
|
||||
{
|
||||
if (!text.contains("```")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
QRegularExpression codeBlockRegex("```\\w*\\n([\\s\\S]*?)```");
|
||||
QRegularExpressionMatch match = codeBlockRegex.match(text);
|
||||
if (match.hasMatch()) {
|
||||
return match.captured(1);
|
||||
}
|
||||
|
||||
int firstFence = text.indexOf("```");
|
||||
int lastFence = text.lastIndexOf("```");
|
||||
if (firstFence != -1 && lastFence > firstFence) {
|
||||
int firstNewLine = text.indexOf('\n', firstFence);
|
||||
if (firstNewLine != -1) {
|
||||
return text.mid(firstNewLine + 1, lastFence - firstNewLine - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
static QString trimWhitespace(const QString &text)
|
||||
{
|
||||
QString result = text;
|
||||
while (result.startsWith('\n') || result.startsWith('\r')) {
|
||||
result = result.mid(1);
|
||||
}
|
||||
while (result.endsWith('\n') || result.endsWith('\r')) {
|
||||
result.chop(1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static QString removeExplanations(const QString &text)
|
||||
{
|
||||
static const QStringList explanationPrefixes = {
|
||||
"here's the", "here is the", "here's", "here is",
|
||||
"the refactored", "refactored code:", "code:",
|
||||
"i've refactored", "i refactored", "i've changed", "i changed"
|
||||
};
|
||||
|
||||
QStringList lines = text.split('\n');
|
||||
int startLine = 0;
|
||||
|
||||
for (int i = 0; i < qMin(3, lines.size()); ++i) {
|
||||
QString line = lines[i].trimmed().toLower();
|
||||
bool isExplanation = false;
|
||||
|
||||
for (const QString &prefix : explanationPrefixes) {
|
||||
if (line.startsWith(prefix) || line.contains(prefix + " code")) {
|
||||
isExplanation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.length() < 50 && line.endsWith(':')) {
|
||||
isExplanation = true;
|
||||
}
|
||||
|
||||
if (isExplanation) {
|
||||
startLine = i + 1;
|
||||
} else if (!line.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startLine > 0 && startLine < lines.size()) {
|
||||
lines = lines.mid(startLine);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::LLMCore
|
||||
|
||||
@ -39,6 +39,24 @@ void ClaudeMessage::handleContentBlockStart(
|
||||
if (blockType == "text") {
|
||||
addCurrentContent<LLMCore::TextContent>();
|
||||
|
||||
} else if (blockType == "image") {
|
||||
QJsonObject source = data["source"].toObject();
|
||||
QString sourceType = source["type"].toString();
|
||||
QString imageData;
|
||||
QString mediaType;
|
||||
LLMCore::ImageContent::ImageSourceType imgSourceType = LLMCore::ImageContent::ImageSourceType::Base64;
|
||||
|
||||
if (sourceType == "base64") {
|
||||
imageData = source["data"].toString();
|
||||
mediaType = source["media_type"].toString();
|
||||
imgSourceType = LLMCore::ImageContent::ImageSourceType::Base64;
|
||||
} else if (sourceType == "url") {
|
||||
imageData = source["url"].toString();
|
||||
imgSourceType = LLMCore::ImageContent::ImageSourceType::Url;
|
||||
}
|
||||
|
||||
addCurrentContent<LLMCore::ImageContent>(imageData, mediaType, imgSourceType);
|
||||
|
||||
} else if (blockType == "tool_use") {
|
||||
QString toolId = data["id"].toString();
|
||||
QString toolName = data["name"].toString();
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProviderSettings.hpp"
|
||||
|
||||
@ -76,7 +77,8 @@ void ClaudeProvider::prepareRequest(
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled)
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name()));
|
||||
@ -93,28 +95,46 @@ void ClaudeProvider::prepareRequest(
|
||||
request["stream"] = true;
|
||||
};
|
||||
|
||||
auto applyThinkingMode = [&request](const auto &settings) {
|
||||
QJsonObject thinkingObj;
|
||||
thinkingObj["type"] = "enabled";
|
||||
thinkingObj["budget_tokens"] = settings.thinkingBudgetTokens();
|
||||
request["thinking"] = thinkingObj;
|
||||
request["max_tokens"] = settings.thinkingMaxTokens();
|
||||
request["temperature"] = 1.0;
|
||||
};
|
||||
|
||||
if (type == LLMCore::RequestType::CodeCompletion) {
|
||||
applyModelParams(Settings::codeCompletionSettings());
|
||||
request["temperature"] = Settings::codeCompletionSettings().temperature();
|
||||
} else if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
const auto &qrSettings = Settings::quickRefactorSettings();
|
||||
applyModelParams(qrSettings);
|
||||
|
||||
if (isThinkingEnabled) {
|
||||
applyThinkingMode(qrSettings);
|
||||
} else {
|
||||
request["temperature"] = qrSettings.temperature();
|
||||
}
|
||||
} else {
|
||||
const auto &chatSettings = Settings::chatAssistantSettings();
|
||||
applyModelParams(chatSettings);
|
||||
|
||||
if (chatSettings.enableThinkingMode()) {
|
||||
QJsonObject thinkingObj;
|
||||
thinkingObj["type"] = "enabled";
|
||||
thinkingObj["budget_tokens"] = chatSettings.thinkingBudgetTokens();
|
||||
request["thinking"] = thinkingObj;
|
||||
request["max_tokens"] = chatSettings.thinkingMaxTokens();
|
||||
request["temperature"] = 1.0;
|
||||
if (isThinkingEnabled) {
|
||||
applyThinkingMode(chatSettings);
|
||||
} else {
|
||||
request["temperature"] = chatSettings.temperature();
|
||||
}
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
LLMCore::RunToolsFilter filter = LLMCore::RunToolsFilter::ALL;
|
||||
if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
filter = LLMCore::RunToolsFilter::OnlyRead;
|
||||
}
|
||||
|
||||
auto toolsDefinitions = m_toolsManager->getToolsDefinitions(
|
||||
LLMCore::ToolSchemaFormat::Claude);
|
||||
LLMCore::ToolSchemaFormat::Claude, filter);
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
LOG_MESSAGE(QString("Added %1 tools to Claude request").arg(toolsDefinitions.size()));
|
||||
@ -237,6 +257,10 @@ bool ClaudeProvider::supportThinking() const {
|
||||
return true;
|
||||
};
|
||||
|
||||
bool ClaudeProvider::supportImage() const {
|
||||
return true;
|
||||
};
|
||||
|
||||
void ClaudeProvider::cancelRequest(const LLMCore::RequestID &requestId)
|
||||
{
|
||||
LOG_MESSAGE(QString("ClaudeProvider: Cancelling request %1").arg(requestId));
|
||||
|
||||
@ -42,7 +42,8 @@ public:
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled) override;
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled) override;
|
||||
QList<QString> getInstalledModels(const QString &url) override;
|
||||
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
|
||||
QString apiKey() const override;
|
||||
@ -54,6 +55,7 @@ public:
|
||||
|
||||
bool supportsTools() const override;
|
||||
bool supportThinking() const override;
|
||||
bool supportImage() const override;
|
||||
void cancelRequest(const LLMCore::RequestID &requestId) override;
|
||||
|
||||
public slots:
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProviderSettings.hpp"
|
||||
|
||||
@ -76,7 +77,8 @@ void GoogleAIProvider::prepareRequest(
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled)
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name()));
|
||||
@ -97,57 +99,56 @@ void GoogleAIProvider::prepareRequest(
|
||||
request["generationConfig"] = generationConfig;
|
||||
};
|
||||
|
||||
auto applyThinkingMode = [&request](const auto &settings) {
|
||||
QJsonObject generationConfig;
|
||||
generationConfig["maxOutputTokens"] = settings.thinkingMaxTokens();
|
||||
|
||||
if (settings.useTopP())
|
||||
generationConfig["topP"] = settings.topP();
|
||||
if (settings.useTopK())
|
||||
generationConfig["topK"] = settings.topK();
|
||||
|
||||
generationConfig["temperature"] = 1.0;
|
||||
|
||||
QJsonObject thinkingConfig;
|
||||
thinkingConfig["includeThoughts"] = true;
|
||||
int budgetTokens = settings.thinkingBudgetTokens();
|
||||
if (budgetTokens != -1) {
|
||||
thinkingConfig["thinkingBudget"] = budgetTokens;
|
||||
}
|
||||
|
||||
generationConfig["thinkingConfig"] = thinkingConfig;
|
||||
request["generationConfig"] = generationConfig;
|
||||
};
|
||||
|
||||
if (type == LLMCore::RequestType::CodeCompletion) {
|
||||
applyModelParams(Settings::codeCompletionSettings());
|
||||
} else if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
const auto &qrSettings = Settings::quickRefactorSettings();
|
||||
|
||||
if (isThinkingEnabled) {
|
||||
applyThinkingMode(qrSettings);
|
||||
} else {
|
||||
applyModelParams(qrSettings);
|
||||
}
|
||||
} else {
|
||||
const auto &chatSettings = Settings::chatAssistantSettings();
|
||||
|
||||
if (chatSettings.enableThinkingMode()) {
|
||||
QJsonObject generationConfig;
|
||||
generationConfig["maxOutputTokens"] = chatSettings.thinkingMaxTokens();
|
||||
|
||||
if (chatSettings.useTopP())
|
||||
generationConfig["topP"] = chatSettings.topP();
|
||||
if (chatSettings.useTopK())
|
||||
generationConfig["topK"] = chatSettings.topK();
|
||||
|
||||
// Set temperature to 1.0 for thinking mode
|
||||
generationConfig["temperature"] = 1.0;
|
||||
|
||||
// Add thinkingConfig
|
||||
QJsonObject thinkingConfig;
|
||||
int budgetTokens = chatSettings.thinkingBudgetTokens();
|
||||
|
||||
// Dynamic thinking: -1 (let model decide)
|
||||
// Disabled: 0 (no thinking)
|
||||
// Custom budget: positive integer
|
||||
if (budgetTokens == -1) {
|
||||
// Dynamic thinking - omit budget to let model decide
|
||||
thinkingConfig["includeThoughts"] = true;
|
||||
} else if (budgetTokens == 0) {
|
||||
// Disabled thinking
|
||||
thinkingConfig["thinkingBudget"] = 0;
|
||||
thinkingConfig["includeThoughts"] = false;
|
||||
} else {
|
||||
// Custom budget
|
||||
thinkingConfig["thinkingBudget"] = budgetTokens;
|
||||
thinkingConfig["includeThoughts"] = true;
|
||||
}
|
||||
|
||||
generationConfig["thinkingConfig"] = thinkingConfig;
|
||||
request["generationConfig"] = generationConfig;
|
||||
|
||||
LOG_MESSAGE(QString("Google AI thinking mode enabled: budget=%1 tokens, maxTokens=%2")
|
||||
.arg(budgetTokens)
|
||||
.arg(chatSettings.thinkingMaxTokens()));
|
||||
|
||||
if (isThinkingEnabled) {
|
||||
applyThinkingMode(chatSettings);
|
||||
} else {
|
||||
applyModelParams(chatSettings);
|
||||
}
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
LLMCore::RunToolsFilter filter = LLMCore::RunToolsFilter::ALL;
|
||||
if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
filter = LLMCore::RunToolsFilter::OnlyRead;
|
||||
}
|
||||
|
||||
auto toolsDefinitions = m_toolsManager->getToolsDefinitions(
|
||||
LLMCore::ToolSchemaFormat::Google);
|
||||
LLMCore::ToolSchemaFormat::Google, filter);
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
LOG_MESSAGE(QString("Added %1 tools to Google AI request").arg(toolsDefinitions.size()));
|
||||
@ -272,6 +273,11 @@ bool GoogleAIProvider::supportThinking() const
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GoogleAIProvider::supportImage() const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void GoogleAIProvider::cancelRequest(const LLMCore::RequestID &requestId)
|
||||
{
|
||||
LOG_MESSAGE(QString("GoogleAIProvider: Cancelling request %1").arg(requestId));
|
||||
@ -524,6 +530,11 @@ void GoogleAIProvider::emitPendingThinkingBlocks(const QString &requestId)
|
||||
|
||||
for (int i = alreadyEmitted; i < totalBlocks; ++i) {
|
||||
auto thinkingContent = thinkingBlocks[i];
|
||||
|
||||
if (thinkingContent->thinking().trimmed().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
emit thinkingBlockReceived(
|
||||
requestId,
|
||||
thinkingContent->thinking(),
|
||||
|
||||
@ -41,7 +41,8 @@ public:
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled) override;
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled) override;
|
||||
QList<QString> getInstalledModels(const QString &url) override;
|
||||
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
|
||||
QString apiKey() const override;
|
||||
@ -53,6 +54,7 @@ public:
|
||||
|
||||
bool supportsTools() const override;
|
||||
bool supportThinking() const override;
|
||||
bool supportImage() const override;
|
||||
void cancelRequest(const LLMCore::RequestID &requestId) override;
|
||||
|
||||
public slots:
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProviderSettings.hpp"
|
||||
|
||||
@ -162,6 +163,11 @@ bool LMStudioProvider::supportsTools() const
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LMStudioProvider::supportImage() const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void LMStudioProvider::cancelRequest(const LLMCore::RequestID &requestId)
|
||||
{
|
||||
LOG_MESSAGE(QString("LMStudioProvider: Cancelling request %1").arg(requestId));
|
||||
@ -223,7 +229,8 @@ void LMStudioProvider::prepareRequest(
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled)
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name()));
|
||||
@ -247,13 +254,20 @@ void LMStudioProvider::prepareRequest(
|
||||
|
||||
if (type == LLMCore::RequestType::CodeCompletion) {
|
||||
applyModelParams(Settings::codeCompletionSettings());
|
||||
} else if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
applyModelParams(Settings::quickRefactorSettings());
|
||||
} else {
|
||||
applyModelParams(Settings::chatAssistantSettings());
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
LLMCore::RunToolsFilter filter = LLMCore::RunToolsFilter::ALL;
|
||||
if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
filter = LLMCore::RunToolsFilter::OnlyRead;
|
||||
}
|
||||
|
||||
auto toolsDefinitions = m_toolsManager->getToolsDefinitions(
|
||||
LLMCore::ToolSchemaFormat::OpenAI);
|
||||
LLMCore::ToolSchemaFormat::OpenAI, filter);
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
LOG_MESSAGE(QString("Added %1 tools to LMStudio request").arg(toolsDefinitions.size()));
|
||||
|
||||
@ -41,7 +41,8 @@ public:
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled) override;
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled) override;
|
||||
QList<QString> getInstalledModels(const QString &url) override;
|
||||
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
|
||||
QString apiKey() const override;
|
||||
@ -52,6 +53,7 @@ public:
|
||||
const LLMCore::RequestID &requestId, const QUrl &url, const QJsonObject &payload) override;
|
||||
|
||||
bool supportsTools() const override;
|
||||
bool supportImage() const override;
|
||||
void cancelRequest(const LLMCore::RequestID &requestId) override;
|
||||
|
||||
public slots:
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
|
||||
#include <QEventLoop>
|
||||
@ -74,7 +75,8 @@ void LlamaCppProvider::prepareRequest(
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled)
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name()));
|
||||
@ -98,13 +100,20 @@ void LlamaCppProvider::prepareRequest(
|
||||
|
||||
if (type == LLMCore::RequestType::CodeCompletion) {
|
||||
applyModelParams(Settings::codeCompletionSettings());
|
||||
} else if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
applyModelParams(Settings::quickRefactorSettings());
|
||||
} else {
|
||||
applyModelParams(Settings::chatAssistantSettings());
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
LLMCore::RunToolsFilter filter = LLMCore::RunToolsFilter::ALL;
|
||||
if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
filter = LLMCore::RunToolsFilter::OnlyRead;
|
||||
}
|
||||
|
||||
auto toolsDefinitions = m_toolsManager->getToolsDefinitions(
|
||||
LLMCore::ToolSchemaFormat::OpenAI);
|
||||
LLMCore::ToolSchemaFormat::OpenAI, filter);
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
LOG_MESSAGE(QString("Added %1 tools to llama.cpp request").arg(toolsDefinitions.size()));
|
||||
@ -197,6 +206,11 @@ bool LlamaCppProvider::supportsTools() const
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LlamaCppProvider::supportImage() const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void LlamaCppProvider::cancelRequest(const LLMCore::RequestID &requestId)
|
||||
{
|
||||
LOG_MESSAGE(QString("LlamaCppProvider: Cancelling request %1").arg(requestId));
|
||||
|
||||
@ -41,7 +41,8 @@ public:
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled) override;
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled) override;
|
||||
QList<QString> getInstalledModels(const QString &url) override;
|
||||
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
|
||||
QString apiKey() const override;
|
||||
@ -52,6 +53,7 @@ public:
|
||||
const LLMCore::RequestID &requestId, const QUrl &url, const QJsonObject &payload) override;
|
||||
|
||||
bool supportsTools() const override;
|
||||
bool supportImage() const override;
|
||||
void cancelRequest(const LLMCore::RequestID &requestId) override;
|
||||
|
||||
public slots:
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProviderSettings.hpp"
|
||||
|
||||
@ -183,6 +184,11 @@ bool MistralAIProvider::supportsTools() const
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MistralAIProvider::supportImage() const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void MistralAIProvider::cancelRequest(const LLMCore::RequestID &requestId)
|
||||
{
|
||||
LOG_MESSAGE(QString("MistralAIProvider: Cancelling request %1").arg(requestId));
|
||||
@ -244,7 +250,8 @@ void MistralAIProvider::prepareRequest(
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled)
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name()));
|
||||
@ -268,13 +275,20 @@ void MistralAIProvider::prepareRequest(
|
||||
|
||||
if (type == LLMCore::RequestType::CodeCompletion) {
|
||||
applyModelParams(Settings::codeCompletionSettings());
|
||||
} else if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
applyModelParams(Settings::quickRefactorSettings());
|
||||
} else {
|
||||
applyModelParams(Settings::chatAssistantSettings());
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
LLMCore::RunToolsFilter filter = LLMCore::RunToolsFilter::ALL;
|
||||
if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
filter = LLMCore::RunToolsFilter::OnlyRead;
|
||||
}
|
||||
|
||||
auto toolsDefinitions = m_toolsManager->getToolsDefinitions(
|
||||
LLMCore::ToolSchemaFormat::OpenAI);
|
||||
LLMCore::ToolSchemaFormat::OpenAI, filter);
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
LOG_MESSAGE(QString("Added %1 tools to Mistral request").arg(toolsDefinitions.size()));
|
||||
|
||||
@ -41,7 +41,8 @@ public:
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled) override;
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled) override;
|
||||
QList<QString> getInstalledModels(const QString &url) override;
|
||||
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
|
||||
QString apiKey() const override;
|
||||
@ -52,6 +53,7 @@ public:
|
||||
const LLMCore::RequestID &requestId, const QUrl &url, const QJsonObject &payload) override;
|
||||
|
||||
bool supportsTools() const override;
|
||||
bool supportImage() const override;
|
||||
void cancelRequest(const LLMCore::RequestID &requestId) override;
|
||||
|
||||
public slots:
|
||||
|
||||
@ -70,6 +70,22 @@ void OllamaMessage::handleToolCall(const QJsonObject &toolCall)
|
||||
LOG_MESSAGE(
|
||||
QString("OllamaMessage: Structured tool call detected - name=%1, id=%2").arg(name, toolId));
|
||||
}
|
||||
|
||||
void OllamaMessage::handleThinkingDelta(const QString &thinking)
|
||||
{
|
||||
LLMCore::ThinkingContent *thinkingContent = getOrCreateThinkingContent();
|
||||
thinkingContent->appendThinking(thinking);
|
||||
}
|
||||
|
||||
void OllamaMessage::handleThinkingComplete(const QString &signature)
|
||||
{
|
||||
if (m_currentThinkingContent) {
|
||||
m_currentThinkingContent->setSignature(signature);
|
||||
LOG_MESSAGE(QString("OllamaMessage: Set thinking signature, length=%1")
|
||||
.arg(signature.length()));
|
||||
}
|
||||
}
|
||||
|
||||
void OllamaMessage::handleDone(bool done)
|
||||
{
|
||||
m_done = done;
|
||||
@ -216,6 +232,7 @@ QJsonObject OllamaMessage::toProviderFormat() const
|
||||
|
||||
QString textContent;
|
||||
QJsonArray toolCalls;
|
||||
QString thinkingContent;
|
||||
|
||||
for (auto block : m_currentBlocks) {
|
||||
if (!block)
|
||||
@ -228,9 +245,15 @@ QJsonObject OllamaMessage::toProviderFormat() const
|
||||
toolCall["type"] = "function";
|
||||
toolCall["function"] = QJsonObject{{"name", tool->name()}, {"arguments", tool->input()}};
|
||||
toolCalls.append(toolCall);
|
||||
} else if (auto thinking = qobject_cast<LLMCore::ThinkingContent *>(block)) {
|
||||
thinkingContent += thinking->thinking();
|
||||
}
|
||||
}
|
||||
|
||||
if (!thinkingContent.isEmpty()) {
|
||||
message["thinking"] = thinkingContent;
|
||||
}
|
||||
|
||||
if (!textContent.isEmpty()) {
|
||||
message["content"] = textContent;
|
||||
}
|
||||
@ -275,6 +298,17 @@ QList<LLMCore::ToolUseContent *> OllamaMessage::getCurrentToolUseContent() const
|
||||
return toolBlocks;
|
||||
}
|
||||
|
||||
QList<LLMCore::ThinkingContent *> OllamaMessage::getCurrentThinkingContent() const
|
||||
{
|
||||
QList<LLMCore::ThinkingContent *> thinkingBlocks;
|
||||
for (auto block : m_currentBlocks) {
|
||||
if (auto thinkingContent = qobject_cast<LLMCore::ThinkingContent *>(block)) {
|
||||
thinkingBlocks.append(thinkingContent);
|
||||
}
|
||||
}
|
||||
return thinkingBlocks;
|
||||
}
|
||||
|
||||
void OllamaMessage::startNewContinuation()
|
||||
{
|
||||
LOG_MESSAGE(QString("OllamaMessage: Starting new continuation"));
|
||||
@ -284,6 +318,7 @@ void OllamaMessage::startNewContinuation()
|
||||
m_done = false;
|
||||
m_state = LLMCore::MessageState::Building;
|
||||
m_contentAddedToTextBlock = false;
|
||||
m_currentThinkingContent = nullptr;
|
||||
}
|
||||
|
||||
void OllamaMessage::updateStateFromDone()
|
||||
@ -309,4 +344,22 @@ LLMCore::TextContent *OllamaMessage::getOrCreateTextContent()
|
||||
return addCurrentContent<LLMCore::TextContent>();
|
||||
}
|
||||
|
||||
LLMCore::ThinkingContent *OllamaMessage::getOrCreateThinkingContent()
|
||||
{
|
||||
if (m_currentThinkingContent) {
|
||||
return m_currentThinkingContent;
|
||||
}
|
||||
|
||||
for (auto block : m_currentBlocks) {
|
||||
if (auto thinkingContent = qobject_cast<LLMCore::ThinkingContent *>(block)) {
|
||||
m_currentThinkingContent = thinkingContent;
|
||||
return m_currentThinkingContent;
|
||||
}
|
||||
}
|
||||
|
||||
m_currentThinkingContent = addCurrentContent<LLMCore::ThinkingContent>();
|
||||
LOG_MESSAGE(QString("OllamaMessage: Created new ThinkingContent block"));
|
||||
return m_currentThinkingContent;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
|
||||
@ -31,6 +31,8 @@ public:
|
||||
|
||||
void handleContentDelta(const QString &content);
|
||||
void handleToolCall(const QJsonObject &toolCall);
|
||||
void handleThinkingDelta(const QString &thinking);
|
||||
void handleThinkingComplete(const QString &signature);
|
||||
void handleDone(bool done);
|
||||
|
||||
QJsonObject toProviderFormat() const;
|
||||
@ -38,6 +40,7 @@ public:
|
||||
|
||||
LLMCore::MessageState state() const { return m_state; }
|
||||
QList<LLMCore::ToolUseContent *> getCurrentToolUseContent() const;
|
||||
QList<LLMCore::ThinkingContent *> getCurrentThinkingContent() const;
|
||||
QList<LLMCore::ContentBlock *> currentBlocks() const { return m_currentBlocks; }
|
||||
|
||||
void startNewContinuation();
|
||||
@ -48,11 +51,13 @@ private:
|
||||
QList<LLMCore::ContentBlock *> m_currentBlocks;
|
||||
QString m_accumulatedContent;
|
||||
bool m_contentAddedToTextBlock = false;
|
||||
LLMCore::ThinkingContent *m_currentThinkingContent = nullptr;
|
||||
|
||||
void updateStateFromDone();
|
||||
bool tryParseToolCall();
|
||||
bool isLikelyToolCallJson(const QString &content) const;
|
||||
LLMCore::TextContent *getOrCreateTextContent();
|
||||
LLMCore::ThinkingContent *getOrCreateThinkingContent();
|
||||
|
||||
template<typename T, typename... Args>
|
||||
T *addCurrentContent(Args &&...args)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* Copyright (C) 2024-2025 Petr Mironychev
|
||||
*
|
||||
* This file is part of QodeAssist.
|
||||
@ -29,6 +29,7 @@
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProviderSettings.hpp"
|
||||
|
||||
@ -75,7 +76,8 @@ void OllamaProvider::prepareRequest(
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled)
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name()));
|
||||
@ -102,15 +104,41 @@ void OllamaProvider::prepareRequest(
|
||||
request["keep_alive"] = settings.ollamaLivetime();
|
||||
};
|
||||
|
||||
auto applyThinkingMode = [&request]() {
|
||||
request["enable_thinking"] = true;
|
||||
QJsonObject options = request["options"].toObject();
|
||||
options["temperature"] = 1.0;
|
||||
request["options"] = options;
|
||||
};
|
||||
|
||||
if (type == LLMCore::RequestType::CodeCompletion) {
|
||||
applySettings(Settings::codeCompletionSettings());
|
||||
} else if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
const auto &qrSettings = Settings::quickRefactorSettings();
|
||||
applySettings(qrSettings);
|
||||
|
||||
if (isThinkingEnabled) {
|
||||
applyThinkingMode();
|
||||
LOG_MESSAGE(QString("OllamaProvider: Thinking mode enabled for QuickRefactoring"));
|
||||
}
|
||||
} else {
|
||||
applySettings(Settings::chatAssistantSettings());
|
||||
const auto &chatSettings = Settings::chatAssistantSettings();
|
||||
applySettings(chatSettings);
|
||||
|
||||
if (isThinkingEnabled) {
|
||||
applyThinkingMode();
|
||||
LOG_MESSAGE(QString("OllamaProvider: Thinking mode enabled for Chat"));
|
||||
}
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
LLMCore::RunToolsFilter filter = LLMCore::RunToolsFilter::ALL;
|
||||
if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
filter = LLMCore::RunToolsFilter::OnlyRead;
|
||||
}
|
||||
|
||||
auto toolsDefinitions = m_toolsManager->toolsFactory()->getToolsDefinitions(
|
||||
LLMCore::ToolSchemaFormat::Ollama);
|
||||
LLMCore::ToolSchemaFormat::Ollama, filter);
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
LOG_MESSAGE(
|
||||
@ -159,6 +187,7 @@ QList<QString> OllamaProvider::validateRequest(const QJsonObject &request, LLMCo
|
||||
{"prompt", {}},
|
||||
{"suffix", {}},
|
||||
{"system", {}},
|
||||
{"images", QJsonArray{}},
|
||||
{"options",
|
||||
QJsonObject{
|
||||
{"temperature", {}},
|
||||
@ -173,7 +202,7 @@ QList<QString> OllamaProvider::validateRequest(const QJsonObject &request, LLMCo
|
||||
{"keep_alive", {}},
|
||||
{"model", {}},
|
||||
{"stream", {}},
|
||||
{"messages", QJsonArray{{QJsonObject{{"role", {}}, {"content", {}}}}}},
|
||||
{"messages", QJsonArray{{QJsonObject{{"role", {}}, {"content", {}}, {"images", QJsonArray{}}}}}},
|
||||
{"tools", QJsonArray{}},
|
||||
{"options",
|
||||
QJsonObject{
|
||||
@ -232,6 +261,16 @@ bool OllamaProvider::supportsTools() const
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OllamaProvider::supportImage() const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OllamaProvider::supportThinking() const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void OllamaProvider::cancelRequest(const LLMCore::RequestID &requestId)
|
||||
{
|
||||
LOG_MESSAGE(QString("OllamaProvider: Cancelling request %1").arg(requestId));
|
||||
@ -390,12 +429,43 @@ void OllamaProvider::processStreamData(const QString &requestId, const QJsonObje
|
||||
LOG_MESSAGE(QString("Cleared message state for continuation request %1").arg(requestId));
|
||||
}
|
||||
|
||||
if (data.contains("thinking")) {
|
||||
QString thinkingDelta = data["thinking"].toString();
|
||||
if (!thinkingDelta.isEmpty()) {
|
||||
message->handleThinkingDelta(thinkingDelta);
|
||||
LOG_MESSAGE(QString("OllamaProvider: Received thinking delta, length=%1")
|
||||
.arg(thinkingDelta.length()));
|
||||
}
|
||||
}
|
||||
|
||||
if (data.contains("message")) {
|
||||
QJsonObject messageObj = data["message"].toObject();
|
||||
|
||||
if (messageObj.contains("thinking")) {
|
||||
QString thinkingDelta = messageObj["thinking"].toString();
|
||||
if (!thinkingDelta.isEmpty()) {
|
||||
message->handleThinkingDelta(thinkingDelta);
|
||||
|
||||
if (!m_thinkingStarted.contains(requestId)) {
|
||||
auto thinkingBlocks = message->getCurrentThinkingContent();
|
||||
if (!thinkingBlocks.isEmpty() && thinkingBlocks.first()) {
|
||||
QString currentThinking = thinkingBlocks.first()->thinking();
|
||||
QString displayThinking = currentThinking.length() > 50
|
||||
? QString("%1...").arg(currentThinking.left(50))
|
||||
: currentThinking;
|
||||
|
||||
emit thinkingBlockReceived(requestId, displayThinking, "");
|
||||
m_thinkingStarted.insert(requestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messageObj.contains("content")) {
|
||||
QString content = messageObj["content"].toString();
|
||||
if (!content.isEmpty()) {
|
||||
emitThinkingBlocks(requestId, message);
|
||||
|
||||
message->handleContentDelta(content);
|
||||
|
||||
bool hasTextContent = false;
|
||||
@ -445,6 +515,13 @@ void OllamaProvider::processStreamData(const QString &requestId, const QJsonObje
|
||||
}
|
||||
|
||||
if (data["done"].toBool()) {
|
||||
if (data.contains("signature")) {
|
||||
QString signature = data["signature"].toString();
|
||||
message->handleThinkingComplete(signature);
|
||||
LOG_MESSAGE(QString("OllamaProvider: Set thinking signature, length=%1")
|
||||
.arg(signature.length()));
|
||||
}
|
||||
|
||||
message->handleDone(true);
|
||||
handleMessageComplete(requestId);
|
||||
}
|
||||
@ -457,6 +534,8 @@ void OllamaProvider::handleMessageComplete(const QString &requestId)
|
||||
|
||||
OllamaMessage *message = m_messages[requestId];
|
||||
|
||||
emitThinkingBlocks(requestId, message);
|
||||
|
||||
if (message->state() == LLMCore::MessageState::RequiresToolExecution) {
|
||||
LOG_MESSAGE(QString("Ollama message requires tool execution for %1").arg(requestId));
|
||||
|
||||
@ -502,6 +581,32 @@ void OllamaProvider::cleanupRequest(const LLMCore::RequestID &requestId)
|
||||
m_dataBuffers.remove(requestId);
|
||||
m_requestUrls.remove(requestId);
|
||||
m_originalRequests.remove(requestId);
|
||||
m_thinkingEmitted.remove(requestId);
|
||||
m_thinkingStarted.remove(requestId);
|
||||
m_toolsManager->cleanupRequest(requestId);
|
||||
}
|
||||
|
||||
void OllamaProvider::emitThinkingBlocks(const QString &requestId, OllamaMessage *message)
|
||||
{
|
||||
if (!message || m_thinkingEmitted.contains(requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto thinkingBlocks = message->getCurrentThinkingContent();
|
||||
if (thinkingBlocks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto thinkingContent : thinkingBlocks) {
|
||||
emit thinkingBlockReceived(
|
||||
requestId, thinkingContent->thinking(), thinkingContent->signature());
|
||||
LOG_MESSAGE(QString("Emitted thinking block for request %1, thinking length=%2, signature "
|
||||
"length=%3")
|
||||
.arg(requestId)
|
||||
.arg(thinkingContent->thinking().length())
|
||||
.arg(thinkingContent->signature().length()));
|
||||
}
|
||||
m_thinkingEmitted.insert(requestId);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
|
||||
@ -42,7 +42,8 @@ public:
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled) override;
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled) override;
|
||||
QList<QString> getInstalledModels(const QString &url) override;
|
||||
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
|
||||
QString apiKey() const override;
|
||||
@ -53,6 +54,8 @@ public:
|
||||
const LLMCore::RequestID &requestId, const QUrl &url, const QJsonObject &payload) override;
|
||||
|
||||
bool supportsTools() const override;
|
||||
bool supportImage() const override;
|
||||
bool supportThinking() const override;
|
||||
void cancelRequest(const LLMCore::RequestID &requestId) override;
|
||||
|
||||
public slots:
|
||||
@ -71,10 +74,13 @@ private:
|
||||
void processStreamData(const QString &requestId, const QJsonObject &data);
|
||||
void handleMessageComplete(const QString &requestId);
|
||||
void cleanupRequest(const LLMCore::RequestID &requestId);
|
||||
void emitThinkingBlocks(const QString &requestId, OllamaMessage *message);
|
||||
|
||||
QHash<QodeAssist::LLMCore::RequestID, OllamaMessage *> m_messages;
|
||||
QHash<QodeAssist::LLMCore::RequestID, QUrl> m_requestUrls;
|
||||
QHash<QodeAssist::LLMCore::RequestID, QJsonObject> m_originalRequests;
|
||||
QSet<QString> m_thinkingEmitted;
|
||||
QSet<QString> m_thinkingStarted;
|
||||
Tools::ToolsManager *m_toolsManager;
|
||||
};
|
||||
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProviderSettings.hpp"
|
||||
|
||||
@ -74,7 +75,8 @@ void OpenAICompatProvider::prepareRequest(
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled)
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name()));
|
||||
@ -98,13 +100,20 @@ void OpenAICompatProvider::prepareRequest(
|
||||
|
||||
if (type == LLMCore::RequestType::CodeCompletion) {
|
||||
applyModelParams(Settings::codeCompletionSettings());
|
||||
} else if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
applyModelParams(Settings::quickRefactorSettings());
|
||||
} else {
|
||||
applyModelParams(Settings::chatAssistantSettings());
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
LLMCore::RunToolsFilter filter = LLMCore::RunToolsFilter::ALL;
|
||||
if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
filter = LLMCore::RunToolsFilter::OnlyRead;
|
||||
}
|
||||
|
||||
auto toolsDefinitions = m_toolsManager->getToolsDefinitions(
|
||||
LLMCore::ToolSchemaFormat::OpenAI);
|
||||
LLMCore::ToolSchemaFormat::OpenAI, filter);
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
LOG_MESSAGE(
|
||||
@ -183,6 +192,11 @@ bool OpenAICompatProvider::supportsTools() const
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpenAICompatProvider::supportImage() const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void OpenAICompatProvider::cancelRequest(const LLMCore::RequestID &requestId)
|
||||
{
|
||||
LOG_MESSAGE(QString("OpenAICompatProvider: Cancelling request %1").arg(requestId));
|
||||
|
||||
@ -41,7 +41,8 @@ public:
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled) override;
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled) override;
|
||||
QList<QString> getInstalledModels(const QString &url) override;
|
||||
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
|
||||
QString apiKey() const override;
|
||||
@ -52,6 +53,7 @@ public:
|
||||
const LLMCore::RequestID &requestId, const QUrl &url, const QJsonObject &payload) override;
|
||||
|
||||
bool supportsTools() const override;
|
||||
bool supportImage() const override;
|
||||
void cancelRequest(const LLMCore::RequestID &requestId) override;
|
||||
|
||||
public slots:
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProviderSettings.hpp"
|
||||
|
||||
@ -75,7 +76,8 @@ void OpenAIProvider::prepareRequest(
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled)
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name()));
|
||||
@ -118,13 +120,20 @@ void OpenAIProvider::prepareRequest(
|
||||
|
||||
if (type == LLMCore::RequestType::CodeCompletion) {
|
||||
applyModelParams(Settings::codeCompletionSettings());
|
||||
} else if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
applyModelParams(Settings::quickRefactorSettings());
|
||||
} else {
|
||||
applyModelParams(Settings::chatAssistantSettings());
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
LLMCore::RunToolsFilter filter = LLMCore::RunToolsFilter::ALL;
|
||||
if (type == LLMCore::RequestType::QuickRefactoring) {
|
||||
filter = LLMCore::RunToolsFilter::OnlyRead;
|
||||
}
|
||||
|
||||
auto toolsDefinitions = m_toolsManager->getToolsDefinitions(
|
||||
LLMCore::ToolSchemaFormat::OpenAI);
|
||||
LLMCore::ToolSchemaFormat::OpenAI, filter);
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
LOG_MESSAGE(QString("Added %1 tools to OpenAI request").arg(toolsDefinitions.size()));
|
||||
@ -239,6 +248,11 @@ bool OpenAIProvider::supportsTools() const
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpenAIProvider::supportImage() const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void OpenAIProvider::cancelRequest(const LLMCore::RequestID &requestId)
|
||||
{
|
||||
LOG_MESSAGE(QString("OpenAIProvider: Cancelling request %1").arg(requestId));
|
||||
|
||||
@ -41,7 +41,8 @@ public:
|
||||
LLMCore::PromptTemplate *prompt,
|
||||
LLMCore::ContextData context,
|
||||
LLMCore::RequestType type,
|
||||
bool isToolsEnabled) override;
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled) override;
|
||||
QList<QString> getInstalledModels(const QString &url) override;
|
||||
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
|
||||
QString apiKey() const override;
|
||||
@ -52,6 +53,7 @@ public:
|
||||
const LLMCore::RequestID &requestId, const QUrl &url, const QJsonObject &payload) override;
|
||||
|
||||
bool supportsTools() const override;
|
||||
bool supportImage() const override;
|
||||
void cancelRequest(const LLMCore::RequestID &requestId) override;
|
||||
|
||||
public slots:
|
||||
|
||||
54
providers/OpenAIResponses/CancelResponseRequest.hpp
Normal file
@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 <QString>
|
||||
|
||||
namespace QodeAssist::OpenAIResponses {
|
||||
|
||||
struct CancelResponseRequest
|
||||
{
|
||||
QString responseId;
|
||||
|
||||
QString buildUrl(const QString &baseUrl) const
|
||||
{
|
||||
return QString("%1/v1/responses/%2/cancel").arg(baseUrl, responseId);
|
||||
}
|
||||
|
||||
bool isValid() const { return !responseId.isEmpty(); }
|
||||
};
|
||||
|
||||
class CancelResponseRequestBuilder
|
||||
{
|
||||
public:
|
||||
CancelResponseRequestBuilder &setResponseId(const QString &id)
|
||||
{
|
||||
m_request.responseId = id;
|
||||
return *this;
|
||||
}
|
||||
|
||||
CancelResponseRequest build() const { return m_request; }
|
||||
|
||||
private:
|
||||
CancelResponseRequest m_request;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::OpenAIResponses
|
||||
|
||||
69
providers/OpenAIResponses/DeleteResponseRequest.hpp
Normal file
@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::OpenAIResponses {
|
||||
|
||||
struct DeleteResponseRequest
|
||||
{
|
||||
QString responseId;
|
||||
|
||||
QString buildUrl(const QString &baseUrl) const
|
||||
{
|
||||
return QString("%1/v1/responses/%2").arg(baseUrl, responseId);
|
||||
}
|
||||
|
||||
bool isValid() const { return !responseId.isEmpty(); }
|
||||
};
|
||||
|
||||
class DeleteResponseRequestBuilder
|
||||
{
|
||||
public:
|
||||
DeleteResponseRequestBuilder &setResponseId(const QString &id)
|
||||
{
|
||||
m_request.responseId = id;
|
||||
return *this;
|
||||
}
|
||||
|
||||
DeleteResponseRequest build() const { return m_request; }
|
||||
|
||||
private:
|
||||
DeleteResponseRequest m_request;
|
||||
};
|
||||
|
||||
struct DeleteResponseResult
|
||||
{
|
||||
bool success = false;
|
||||
QString message;
|
||||
|
||||
static DeleteResponseResult fromJson(const QJsonObject &obj)
|
||||
{
|
||||
DeleteResponseResult result;
|
||||
result.success = obj["success"].toBool();
|
||||
result.message = obj["message"].toString();
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::OpenAIResponses
|
||||
|
||||
120
providers/OpenAIResponses/GetResponseRequest.hpp
Normal file
@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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 <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <optional>
|
||||
|
||||
namespace QodeAssist::OpenAIResponses {
|
||||
|
||||
struct GetResponseRequest
|
||||
{
|
||||
QString responseId;
|
||||
std::optional<QStringList> include;
|
||||
std::optional<bool> includeObfuscation;
|
||||
std::optional<int> startingAfter;
|
||||
std::optional<bool> stream;
|
||||
|
||||
QString buildUrl(const QString &baseUrl) const
|
||||
{
|
||||
QString url = QString("%1/v1/responses/%2").arg(baseUrl, responseId);
|
||||
QStringList queryParams;
|
||||
|
||||
if (include && !include->isEmpty()) {
|
||||
for (const auto &item : *include) {
|
||||
queryParams.append(QString("include=%1").arg(item));
|
||||
}
|
||||
}
|
||||
|
||||
if (includeObfuscation) {
|
||||
queryParams.append(
|
||||
QString("include_obfuscation=%1").arg(*includeObfuscation ? "true" : "false"));
|
||||
}
|
||||
|
||||
if (startingAfter) {
|
||||
queryParams.append(QString("starting_after=%1").arg(*startingAfter));
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
queryParams.append(QString("stream=%1").arg(*stream ? "true" : "false"));
|
||||
}
|
||||
|
||||
if (!queryParams.isEmpty()) {
|
||||
url += "?" + queryParams.join("&");
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
bool isValid() const { return !responseId.isEmpty(); }
|
||||
};
|
||||
|
||||
class GetResponseRequestBuilder
|
||||
{
|
||||
public:
|
||||
GetResponseRequestBuilder &setResponseId(const QString &id)
|
||||
{
|
||||
m_request.responseId = id;
|
||||
return *this;
|
||||
}
|
||||
|
||||
GetResponseRequestBuilder &setInclude(const QStringList &include)
|
||||
{
|
||||
m_request.include = include;
|
||||
return *this;
|
||||
}
|
||||
|
||||
GetResponseRequestBuilder &addInclude(const QString &item)
|
||||
{
|
||||
if (!m_request.include) {
|
||||
m_request.include = QStringList();
|
||||
}
|
||||
m_request.include->append(item);
|
||||
return *this;
|
||||
}
|
||||
|
||||
GetResponseRequestBuilder &setIncludeObfuscation(bool enabled)
|
||||
{
|
||||
m_request.includeObfuscation = enabled;
|
||||
return *this;
|
||||
}
|
||||
|
||||
GetResponseRequestBuilder &setStartingAfter(int sequence)
|
||||
{
|
||||
m_request.startingAfter = sequence;
|
||||
return *this;
|
||||
}
|
||||
|
||||
GetResponseRequestBuilder &setStream(bool enabled)
|
||||
{
|
||||
m_request.stream = enabled;
|
||||
return *this;
|
||||
}
|
||||
|
||||
GetResponseRequest build() const { return m_request; }
|
||||
|
||||
private:
|
||||
GetResponseRequest m_request;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::OpenAIResponses
|
||||
|
||||
219
providers/OpenAIResponses/InputTokensRequest.hpp
Normal file
@ -0,0 +1,219 @@
|
||||
/*
|
||||
* 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 "ModelRequest.hpp"
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::OpenAIResponses {
|
||||
|
||||
struct InputTokensRequest
|
||||
{
|
||||
std::optional<QString> conversation;
|
||||
std::optional<QJsonArray> input;
|
||||
std::optional<QString> instructions;
|
||||
std::optional<QString> model;
|
||||
std::optional<bool> parallelToolCalls;
|
||||
std::optional<QString> previousResponseId;
|
||||
std::optional<QJsonObject> reasoning;
|
||||
std::optional<QJsonObject> text;
|
||||
std::optional<QJsonValue> toolChoice;
|
||||
std::optional<QJsonArray> tools;
|
||||
std::optional<QString> truncation;
|
||||
|
||||
QString buildUrl(const QString &baseUrl) const
|
||||
{
|
||||
return QString("%1/v1/responses/input_tokens").arg(baseUrl);
|
||||
}
|
||||
|
||||
QJsonObject toJson() const
|
||||
{
|
||||
QJsonObject obj;
|
||||
|
||||
if (conversation)
|
||||
obj["conversation"] = *conversation;
|
||||
if (input)
|
||||
obj["input"] = *input;
|
||||
if (instructions)
|
||||
obj["instructions"] = *instructions;
|
||||
if (model)
|
||||
obj["model"] = *model;
|
||||
if (parallelToolCalls)
|
||||
obj["parallel_tool_calls"] = *parallelToolCalls;
|
||||
if (previousResponseId)
|
||||
obj["previous_response_id"] = *previousResponseId;
|
||||
if (reasoning)
|
||||
obj["reasoning"] = *reasoning;
|
||||
if (text)
|
||||
obj["text"] = *text;
|
||||
if (toolChoice)
|
||||
obj["tool_choice"] = *toolChoice;
|
||||
if (tools)
|
||||
obj["tools"] = *tools;
|
||||
if (truncation)
|
||||
obj["truncation"] = *truncation;
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
bool isValid() const { return input.has_value() || previousResponseId.has_value(); }
|
||||
};
|
||||
|
||||
class InputTokensRequestBuilder
|
||||
{
|
||||
public:
|
||||
InputTokensRequestBuilder &setConversation(const QString &conversationId)
|
||||
{
|
||||
m_request.conversation = conversationId;
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setInput(const QJsonArray &input)
|
||||
{
|
||||
m_request.input = input;
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &addInputMessage(const Message &message)
|
||||
{
|
||||
if (!m_request.input) {
|
||||
m_request.input = QJsonArray();
|
||||
}
|
||||
m_request.input->append(message.toJson());
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setInstructions(const QString &instructions)
|
||||
{
|
||||
m_request.instructions = instructions;
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setModel(const QString &model)
|
||||
{
|
||||
m_request.model = model;
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setParallelToolCalls(bool enabled)
|
||||
{
|
||||
m_request.parallelToolCalls = enabled;
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setPreviousResponseId(const QString &responseId)
|
||||
{
|
||||
m_request.previousResponseId = responseId;
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setReasoning(const QJsonObject &reasoning)
|
||||
{
|
||||
m_request.reasoning = reasoning;
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setReasoningEffort(ReasoningEffort effort)
|
||||
{
|
||||
QString effortStr;
|
||||
switch (effort) {
|
||||
case ReasoningEffort::None:
|
||||
effortStr = "none";
|
||||
break;
|
||||
case ReasoningEffort::Minimal:
|
||||
effortStr = "minimal";
|
||||
break;
|
||||
case ReasoningEffort::Low:
|
||||
effortStr = "low";
|
||||
break;
|
||||
case ReasoningEffort::Medium:
|
||||
effortStr = "medium";
|
||||
break;
|
||||
case ReasoningEffort::High:
|
||||
effortStr = "high";
|
||||
break;
|
||||
}
|
||||
m_request.reasoning = QJsonObject{{"effort", effortStr}};
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setText(const QJsonObject &text)
|
||||
{
|
||||
m_request.text = text;
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setTextFormat(const TextFormatOptions &format)
|
||||
{
|
||||
m_request.text = format.toJson();
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setToolChoice(const QJsonValue &toolChoice)
|
||||
{
|
||||
m_request.toolChoice = toolChoice;
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setTools(const QJsonArray &tools)
|
||||
{
|
||||
m_request.tools = tools;
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &addTool(const Tool &tool)
|
||||
{
|
||||
if (!m_request.tools) {
|
||||
m_request.tools = QJsonArray();
|
||||
}
|
||||
m_request.tools->append(tool.toJson());
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequestBuilder &setTruncation(const QString &truncation)
|
||||
{
|
||||
m_request.truncation = truncation;
|
||||
return *this;
|
||||
}
|
||||
|
||||
InputTokensRequest build() const { return m_request; }
|
||||
|
||||
private:
|
||||
InputTokensRequest m_request;
|
||||
};
|
||||
|
||||
struct InputTokensResponse
|
||||
{
|
||||
QString object;
|
||||
int inputTokens = 0;
|
||||
|
||||
static InputTokensResponse fromJson(const QJsonObject &obj)
|
||||
{
|
||||
InputTokensResponse result;
|
||||
result.object = obj["object"].toString();
|
||||
result.inputTokens = obj["input_tokens"].toInt();
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::OpenAIResponses
|
||||
|
||||
143
providers/OpenAIResponses/ItemTypesReference.hpp
Normal file
@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
namespace QodeAssist::OpenAIResponses {
|
||||
|
||||
/*
|
||||
* REFERENCE: Item Types in List Input Items Response
|
||||
* ===================================================
|
||||
*
|
||||
* The `data` array in ListInputItemsResponse can contain various item types.
|
||||
* This file serves as a reference for all possible item types.
|
||||
*
|
||||
* EXISTING TYPES (already implemented):
|
||||
* -------------------------------------
|
||||
* - MessageOutput (in ResponseObject.hpp)
|
||||
* - FunctionCall (in ResponseObject.hpp)
|
||||
* - ReasoningOutput (in ResponseObject.hpp)
|
||||
* - FileSearchCall (in ResponseObject.hpp)
|
||||
* - CodeInterpreterCall (in ResponseObject.hpp)
|
||||
* - Message (in ModelRequest.hpp) - for input messages
|
||||
*
|
||||
* ADDITIONAL TYPES (to be implemented if needed):
|
||||
* -----------------------------------------------
|
||||
*
|
||||
* 1. Computer Tool Call (computer_call)
|
||||
* - Computer use tool for UI automation
|
||||
* - Properties: action, call_id, id, pending_safety_checks, status, type
|
||||
* - Actions: click, double_click, drag, keypress, move, screenshot, scroll, type, wait
|
||||
*
|
||||
* 2. Computer Tool Call Output (computer_call_output)
|
||||
* - Output from computer tool
|
||||
* - Properties: call_id, id, output, type, acknowledged_safety_checks, status
|
||||
*
|
||||
* 3. Web Search Tool Call (web_search_call)
|
||||
* - Web search results
|
||||
* - Properties: action, id, status, type
|
||||
* - Actions: search, open_page, find
|
||||
*
|
||||
* 4. Image Generation Call (image_generation_call)
|
||||
* - AI image generation request
|
||||
* - Properties: id, result (base64), status, type
|
||||
*
|
||||
* 5. Local Shell Call (local_shell_call)
|
||||
* - Execute shell commands locally
|
||||
* - Properties: action (exec), call_id, id, status, type
|
||||
* - Action properties: command, env, timeout_ms, user, working_directory
|
||||
*
|
||||
* 6. Local Shell Call Output (local_shell_call_output)
|
||||
* - Output from local shell execution
|
||||
* - Properties: id, output (JSON string), type, status
|
||||
*
|
||||
* 7. Shell Tool Call (shell_call)
|
||||
* - Managed shell environment execution
|
||||
* - Properties: action, call_id, id, status, type, created_by
|
||||
*
|
||||
* 8. Shell Call Output (shell_call_output)
|
||||
* - Output from shell tool
|
||||
* - Properties: call_id, id, max_output_length, output (array), type, created_by
|
||||
* - Output chunks: outcome (exit/timeout), stderr, stdout
|
||||
*
|
||||
* 9. Apply Patch Tool Call (apply_patch_call)
|
||||
* - File diff operations
|
||||
* - Properties: call_id, id, operation, status, type, created_by
|
||||
* - Operations: create_file, delete_file, update_file
|
||||
*
|
||||
* 10. Apply Patch Tool Call Output (apply_patch_call_output)
|
||||
* - Output from patch operations
|
||||
* - Properties: call_id, id, status, type, created_by, output
|
||||
*
|
||||
* 11. MCP List Tools (mcp_list_tools)
|
||||
* - List of tools from MCP server
|
||||
* - Properties: id, server_label, tools (array), type, error
|
||||
*
|
||||
* 12. MCP Approval Request (mcp_approval_request)
|
||||
* - Request for human approval
|
||||
* - Properties: arguments, id, name, server_label, type
|
||||
*
|
||||
* 13. MCP Approval Response (mcp_approval_response)
|
||||
* - Response to approval request
|
||||
* - Properties: approval_request_id, approve (bool), id, type, reason
|
||||
*
|
||||
* 14. MCP Tool Call (mcp_call)
|
||||
* - Tool invocation on MCP server
|
||||
* - Properties: arguments, id, name, server_label, type
|
||||
* - Optional: approval_request_id, error, output, status
|
||||
*
|
||||
* 15. Custom Tool Call (custom_tool_call)
|
||||
* - User-defined tool call
|
||||
* - Properties: call_id, input, name, type, id
|
||||
*
|
||||
* 16. Custom Tool Call Output (custom_tool_call_output)
|
||||
* - Output from custom tool
|
||||
* - Properties: call_id, output (string or array), type, id
|
||||
*
|
||||
* 17. Item Reference (item_reference)
|
||||
* - Internal reference to another item
|
||||
* - Properties: id, type
|
||||
*
|
||||
* USAGE:
|
||||
* ------
|
||||
* When parsing ListInputItemsResponse.data array:
|
||||
* 1. Check item["type"] field
|
||||
* 2. Use appropriate parser based on type
|
||||
* 3. For existing types, use ResponseObject.hpp or ModelRequest.hpp
|
||||
* 4. For additional types, implement parsers as needed
|
||||
*
|
||||
* EXAMPLE:
|
||||
* --------
|
||||
* for (const auto &itemValue : response.data) {
|
||||
* const QJsonObject itemObj = itemValue.toObject();
|
||||
* const QString type = itemObj["type"].toString();
|
||||
*
|
||||
* if (type == "message") {
|
||||
* // Use MessageOutput or Message
|
||||
* } else if (type == "function_call") {
|
||||
* // Use FunctionCall
|
||||
* } else if (type == "computer_call") {
|
||||
* // Implement ComputerCall parser
|
||||
* }
|
||||
* // ... handle other types
|
||||
* }
|
||||
*/
|
||||
|
||||
} // namespace QodeAssist::OpenAIResponses
|
||||
|
||||
166
providers/OpenAIResponses/ListInputItemsRequest.hpp
Normal file
@ -0,0 +1,166 @@
|
||||
/*
|
||||
* 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 <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <optional>
|
||||
|
||||
namespace QodeAssist::OpenAIResponses {
|
||||
|
||||
enum class SortOrder { Ascending, Descending };
|
||||
|
||||
struct ListInputItemsRequest
|
||||
{
|
||||
QString responseId;
|
||||
std::optional<QString> after;
|
||||
std::optional<QStringList> include;
|
||||
std::optional<int> limit;
|
||||
std::optional<SortOrder> order;
|
||||
|
||||
QString buildUrl(const QString &baseUrl) const
|
||||
{
|
||||
QString url = QString("%1/v1/responses/%2/input_items").arg(baseUrl, responseId);
|
||||
QStringList queryParams;
|
||||
|
||||
if (after) {
|
||||
queryParams.append(QString("after=%1").arg(*after));
|
||||
}
|
||||
|
||||
if (include && !include->isEmpty()) {
|
||||
for (const auto &item : *include) {
|
||||
queryParams.append(QString("include=%1").arg(item));
|
||||
}
|
||||
}
|
||||
|
||||
if (limit) {
|
||||
queryParams.append(QString("limit=%1").arg(*limit));
|
||||
}
|
||||
|
||||
if (order) {
|
||||
QString orderStr = (*order == SortOrder::Ascending) ? "asc" : "desc";
|
||||
queryParams.append(QString("order=%1").arg(orderStr));
|
||||
}
|
||||
|
||||
if (!queryParams.isEmpty()) {
|
||||
url += "?" + queryParams.join("&");
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
bool isValid() const
|
||||
{
|
||||
if (responseId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (limit && (*limit < 1 || *limit > 100)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
class ListInputItemsRequestBuilder
|
||||
{
|
||||
public:
|
||||
ListInputItemsRequestBuilder &setResponseId(const QString &id)
|
||||
{
|
||||
m_request.responseId = id;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ListInputItemsRequestBuilder &setAfter(const QString &itemId)
|
||||
{
|
||||
m_request.after = itemId;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ListInputItemsRequestBuilder &setInclude(const QStringList &include)
|
||||
{
|
||||
m_request.include = include;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ListInputItemsRequestBuilder &addInclude(const QString &item)
|
||||
{
|
||||
if (!m_request.include) {
|
||||
m_request.include = QStringList();
|
||||
}
|
||||
m_request.include->append(item);
|
||||
return *this;
|
||||
}
|
||||
|
||||
ListInputItemsRequestBuilder &setLimit(int limit)
|
||||
{
|
||||
m_request.limit = limit;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ListInputItemsRequestBuilder &setOrder(SortOrder order)
|
||||
{
|
||||
m_request.order = order;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ListInputItemsRequestBuilder &setAscendingOrder()
|
||||
{
|
||||
m_request.order = SortOrder::Ascending;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ListInputItemsRequestBuilder &setDescendingOrder()
|
||||
{
|
||||
m_request.order = SortOrder::Descending;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ListInputItemsRequest build() const { return m_request; }
|
||||
|
||||
private:
|
||||
ListInputItemsRequest m_request;
|
||||
};
|
||||
|
||||
struct ListInputItemsResponse
|
||||
{
|
||||
QJsonArray data;
|
||||
QString firstId;
|
||||
QString lastId;
|
||||
bool hasMore = false;
|
||||
QString object;
|
||||
|
||||
static ListInputItemsResponse fromJson(const QJsonObject &obj)
|
||||
{
|
||||
ListInputItemsResponse result;
|
||||
result.data = obj["data"].toArray();
|
||||
result.firstId = obj["first_id"].toString();
|
||||
result.lastId = obj["last_id"].toString();
|
||||
result.hasMore = obj["has_more"].toBool();
|
||||
result.object = obj["object"].toString();
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::OpenAIResponses
|
||||
|
||||
354
providers/OpenAIResponses/ModelRequest.hpp
Normal file
@ -0,0 +1,354 @@
|
||||
/*
|
||||
* 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 <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QVariant>
|
||||
#include <optional>
|
||||
#include <variant>
|
||||
|
||||
namespace QodeAssist::OpenAIResponses {
|
||||
|
||||
enum class Role { User, Assistant, System, Developer };
|
||||
|
||||
enum class MessageStatus { InProgress, Completed, Incomplete };
|
||||
|
||||
enum class ReasoningEffort { None, Minimal, Low, Medium, High };
|
||||
|
||||
enum class TextFormat { Text, JsonSchema, JsonObject };
|
||||
|
||||
struct InputText
|
||||
{
|
||||
QString text;
|
||||
|
||||
QJsonObject toJson() const
|
||||
{
|
||||
return QJsonObject{{"type", "input_text"}, {"text", text}};
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !text.isEmpty(); }
|
||||
};
|
||||
|
||||
struct InputImage
|
||||
{
|
||||
std::optional<QString> fileId;
|
||||
std::optional<QString> imageUrl;
|
||||
QString detail = "auto";
|
||||
|
||||
QJsonObject toJson() const
|
||||
{
|
||||
QJsonObject obj{{"type", "input_image"}, {"detail", detail}};
|
||||
if (fileId)
|
||||
obj["file_id"] = *fileId;
|
||||
if (imageUrl)
|
||||
obj["image_url"] = *imageUrl;
|
||||
return obj;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return fileId.has_value() || imageUrl.has_value(); }
|
||||
};
|
||||
|
||||
struct InputFile
|
||||
{
|
||||
std::optional<QString> fileId;
|
||||
std::optional<QString> fileUrl;
|
||||
std::optional<QString> fileData;
|
||||
std::optional<QString> filename;
|
||||
|
||||
QJsonObject toJson() const
|
||||
{
|
||||
QJsonObject obj{{"type", "input_file"}};
|
||||
if (fileId)
|
||||
obj["file_id"] = *fileId;
|
||||
if (fileUrl)
|
||||
obj["file_url"] = *fileUrl;
|
||||
if (fileData)
|
||||
obj["file_data"] = *fileData;
|
||||
if (filename)
|
||||
obj["filename"] = *filename;
|
||||
return obj;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept
|
||||
{
|
||||
return fileId.has_value() || fileUrl.has_value() || fileData.has_value();
|
||||
}
|
||||
};
|
||||
|
||||
class MessageContent
|
||||
{
|
||||
public:
|
||||
MessageContent(QString text) : m_variant(std::move(text)) {}
|
||||
MessageContent(InputText text) : m_variant(std::move(text)) {}
|
||||
MessageContent(InputImage image) : m_variant(std::move(image)) {}
|
||||
MessageContent(InputFile file) : m_variant(std::move(file)) {}
|
||||
|
||||
QJsonValue toJson() const
|
||||
{
|
||||
return std::visit([](const auto &content) -> QJsonValue {
|
||||
using T = std::decay_t<decltype(content)>;
|
||||
if constexpr (std::is_same_v<T, QString>) {
|
||||
return content;
|
||||
} else {
|
||||
return content.toJson();
|
||||
}
|
||||
}, m_variant);
|
||||
}
|
||||
|
||||
bool isValid() const noexcept
|
||||
{
|
||||
return std::visit([](const auto &content) -> bool {
|
||||
using T = std::decay_t<decltype(content)>;
|
||||
if constexpr (std::is_same_v<T, QString>) {
|
||||
return !content.isEmpty();
|
||||
} else {
|
||||
return content.isValid();
|
||||
}
|
||||
}, m_variant);
|
||||
}
|
||||
|
||||
private:
|
||||
std::variant<QString, InputText, InputImage, InputFile> m_variant;
|
||||
};
|
||||
|
||||
struct Message
|
||||
{
|
||||
Role role;
|
||||
QList<MessageContent> content;
|
||||
std::optional<MessageStatus> status;
|
||||
|
||||
QJsonObject toJson() const
|
||||
{
|
||||
QJsonObject obj;
|
||||
obj["role"] = roleToString(role);
|
||||
|
||||
if (content.size() == 1) {
|
||||
obj["content"] = content[0].toJson();
|
||||
} else {
|
||||
QJsonArray arr;
|
||||
for (const auto &c : content) {
|
||||
arr.append(c.toJson());
|
||||
}
|
||||
obj["content"] = arr;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
obj["status"] = statusToString(*status);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept
|
||||
{
|
||||
if (content.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const auto &c : content) {
|
||||
if (!c.isValid()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static QString roleToString(Role r) noexcept
|
||||
{
|
||||
switch (r) {
|
||||
case Role::User:
|
||||
return "user";
|
||||
case Role::Assistant:
|
||||
return "assistant";
|
||||
case Role::System:
|
||||
return "system";
|
||||
case Role::Developer:
|
||||
return "developer";
|
||||
}
|
||||
return "user";
|
||||
}
|
||||
|
||||
static QString statusToString(MessageStatus s) noexcept
|
||||
{
|
||||
switch (s) {
|
||||
case MessageStatus::InProgress:
|
||||
return "in_progress";
|
||||
case MessageStatus::Completed:
|
||||
return "completed";
|
||||
case MessageStatus::Incomplete:
|
||||
return "incomplete";
|
||||
}
|
||||
return "in_progress";
|
||||
}
|
||||
};
|
||||
|
||||
struct FunctionTool
|
||||
{
|
||||
QString name;
|
||||
QJsonObject parameters;
|
||||
std::optional<QString> description;
|
||||
bool strict = true;
|
||||
|
||||
QJsonObject toJson() const
|
||||
{
|
||||
QJsonObject obj{{"type", "function"},
|
||||
{"name", name},
|
||||
{"parameters", parameters},
|
||||
{"strict", strict}};
|
||||
if (description)
|
||||
obj["description"] = *description;
|
||||
return obj;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept
|
||||
{
|
||||
return !name.isEmpty() && !parameters.isEmpty();
|
||||
}
|
||||
};
|
||||
|
||||
struct FileSearchTool
|
||||
{
|
||||
QStringList vectorStoreIds;
|
||||
std::optional<int> maxNumResults;
|
||||
std::optional<double> scoreThreshold;
|
||||
|
||||
QJsonObject toJson() const
|
||||
{
|
||||
QJsonObject obj{{"type", "file_search"}};
|
||||
QJsonArray ids;
|
||||
for (const auto &id : vectorStoreIds) {
|
||||
ids.append(id);
|
||||
}
|
||||
obj["vector_store_ids"] = ids;
|
||||
|
||||
if (maxNumResults)
|
||||
obj["max_num_results"] = *maxNumResults;
|
||||
if (scoreThreshold)
|
||||
obj["score_threshold"] = *scoreThreshold;
|
||||
return obj;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept
|
||||
{
|
||||
return !vectorStoreIds.isEmpty();
|
||||
}
|
||||
};
|
||||
|
||||
struct WebSearchTool
|
||||
{
|
||||
QString searchContextSize = "medium";
|
||||
|
||||
QJsonObject toJson() const
|
||||
{
|
||||
return QJsonObject{{"type", "web_search"}, {"search_context_size", searchContextSize}};
|
||||
}
|
||||
|
||||
bool isValid() const noexcept
|
||||
{
|
||||
return !searchContextSize.isEmpty();
|
||||
}
|
||||
};
|
||||
|
||||
struct CodeInterpreterTool
|
||||
{
|
||||
QString container;
|
||||
|
||||
QJsonObject toJson() const
|
||||
{
|
||||
return QJsonObject{{"type", "code_interpreter"}, {"container", container}};
|
||||
}
|
||||
|
||||
bool isValid() const noexcept
|
||||
{
|
||||
return !container.isEmpty();
|
||||
}
|
||||
};
|
||||
|
||||
class Tool
|
||||
{
|
||||
public:
|
||||
Tool(FunctionTool tool) : m_variant(std::move(tool)) {}
|
||||
Tool(FileSearchTool tool) : m_variant(std::move(tool)) {}
|
||||
Tool(WebSearchTool tool) : m_variant(std::move(tool)) {}
|
||||
Tool(CodeInterpreterTool tool) : m_variant(std::move(tool)) {}
|
||||
|
||||
QJsonObject toJson() const
|
||||
{
|
||||
return std::visit([](const auto &t) { return t.toJson(); }, m_variant);
|
||||
}
|
||||
|
||||
bool isValid() const noexcept
|
||||
{
|
||||
return std::visit([](const auto &t) { return t.isValid(); }, m_variant);
|
||||
}
|
||||
|
||||
private:
|
||||
std::variant<FunctionTool, FileSearchTool, WebSearchTool, CodeInterpreterTool> m_variant;
|
||||
};
|
||||
|
||||
struct TextFormatOptions
|
||||
{
|
||||
TextFormat type = TextFormat::Text;
|
||||
std::optional<QString> name;
|
||||
std::optional<QJsonObject> schema;
|
||||
std::optional<QString> description;
|
||||
std::optional<bool> strict;
|
||||
|
||||
QJsonObject toJson() const
|
||||
{
|
||||
QJsonObject obj;
|
||||
|
||||
switch (type) {
|
||||
case TextFormat::Text:
|
||||
obj["type"] = "text";
|
||||
break;
|
||||
case TextFormat::JsonSchema:
|
||||
obj["type"] = "json_schema";
|
||||
if (name)
|
||||
obj["name"] = *name;
|
||||
if (schema)
|
||||
obj["schema"] = *schema;
|
||||
if (description)
|
||||
obj["description"] = *description;
|
||||
if (strict)
|
||||
obj["strict"] = *strict;
|
||||
break;
|
||||
case TextFormat::JsonObject:
|
||||
obj["type"] = "json_object";
|
||||
break;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept
|
||||
{
|
||||
if (type == TextFormat::JsonSchema) {
|
||||
return name.has_value() && schema.has_value();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::OpenAIResponses
|
||||
|
||||
562
providers/OpenAIResponses/ResponseObject.hpp
Normal file
@ -0,0 +1,562 @@
|
||||
/*
|
||||
* 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 <optional>
|
||||
#include <variant>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::OpenAIResponses {
|
||||
|
||||
enum class ResponseStatus { Completed, Failed, InProgress, Cancelled, Queued, Incomplete };
|
||||
|
||||
enum class ItemStatus { InProgress, Completed, Incomplete };
|
||||
|
||||
struct FileCitation
|
||||
{
|
||||
QString fileId;
|
||||
QString filename;
|
||||
int index = 0;
|
||||
|
||||
static FileCitation fromJson(const QJsonObject &obj)
|
||||
{
|
||||
return {obj["file_id"].toString(), obj["filename"].toString(), obj["index"].toInt()};
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !fileId.isEmpty(); }
|
||||
};
|
||||
|
||||
struct UrlCitation
|
||||
{
|
||||
QString url;
|
||||
QString title;
|
||||
int startIndex = 0;
|
||||
int endIndex = 0;
|
||||
|
||||
static UrlCitation fromJson(const QJsonObject &obj)
|
||||
{
|
||||
return {
|
||||
obj["url"].toString(),
|
||||
obj["title"].toString(),
|
||||
obj["start_index"].toInt(),
|
||||
obj["end_index"].toInt()};
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !url.isEmpty(); }
|
||||
};
|
||||
|
||||
struct OutputText
|
||||
{
|
||||
QString text;
|
||||
QList<FileCitation> fileCitations;
|
||||
QList<UrlCitation> urlCitations;
|
||||
|
||||
static OutputText fromJson(const QJsonObject &obj)
|
||||
{
|
||||
OutputText result;
|
||||
result.text = obj["text"].toString();
|
||||
|
||||
if (obj.contains("annotations")) {
|
||||
const QJsonArray annotations = obj["annotations"].toArray();
|
||||
result.fileCitations.reserve(annotations.size());
|
||||
result.urlCitations.reserve(annotations.size());
|
||||
|
||||
for (const auto &annValue : annotations) {
|
||||
const QJsonObject ann = annValue.toObject();
|
||||
const QString type = ann["type"].toString();
|
||||
if (type == "file_citation") {
|
||||
result.fileCitations.append(FileCitation::fromJson(ann));
|
||||
} else if (type == "url_citation") {
|
||||
result.urlCitations.append(UrlCitation::fromJson(ann));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !text.isEmpty(); }
|
||||
};
|
||||
|
||||
struct Refusal
|
||||
{
|
||||
QString refusal;
|
||||
|
||||
static Refusal fromJson(const QJsonObject &obj)
|
||||
{
|
||||
return {obj["refusal"].toString()};
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !refusal.isEmpty(); }
|
||||
};
|
||||
|
||||
struct MessageOutput
|
||||
{
|
||||
QString id;
|
||||
QString role;
|
||||
ItemStatus status = ItemStatus::InProgress;
|
||||
QList<OutputText> outputTexts;
|
||||
QList<Refusal> refusals;
|
||||
|
||||
static MessageOutput fromJson(const QJsonObject &obj)
|
||||
{
|
||||
MessageOutput result;
|
||||
result.id = obj["id"].toString();
|
||||
result.role = obj["role"].toString();
|
||||
|
||||
const QString statusStr = obj["status"].toString();
|
||||
if (statusStr == "in_progress")
|
||||
result.status = ItemStatus::InProgress;
|
||||
else if (statusStr == "completed")
|
||||
result.status = ItemStatus::Completed;
|
||||
else
|
||||
result.status = ItemStatus::Incomplete;
|
||||
|
||||
if (obj.contains("content")) {
|
||||
const QJsonArray content = obj["content"].toArray();
|
||||
result.outputTexts.reserve(content.size());
|
||||
result.refusals.reserve(content.size());
|
||||
|
||||
for (const auto &item : content) {
|
||||
const QJsonObject itemObj = item.toObject();
|
||||
const QString type = itemObj["type"].toString();
|
||||
|
||||
if (type == "output_text") {
|
||||
result.outputTexts.append(OutputText::fromJson(itemObj));
|
||||
} else if (type == "refusal") {
|
||||
result.refusals.append(Refusal::fromJson(itemObj));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !id.isEmpty(); }
|
||||
bool hasContent() const noexcept { return !outputTexts.isEmpty() || !refusals.isEmpty(); }
|
||||
};
|
||||
|
||||
struct FunctionCall
|
||||
{
|
||||
QString id;
|
||||
QString callId;
|
||||
QString name;
|
||||
QString arguments;
|
||||
ItemStatus status = ItemStatus::InProgress;
|
||||
|
||||
static FunctionCall fromJson(const QJsonObject &obj)
|
||||
{
|
||||
FunctionCall result;
|
||||
result.id = obj["id"].toString();
|
||||
result.callId = obj["call_id"].toString();
|
||||
result.name = obj["name"].toString();
|
||||
result.arguments = obj["arguments"].toString();
|
||||
|
||||
const QString statusStr = obj["status"].toString();
|
||||
if (statusStr == "in_progress")
|
||||
result.status = ItemStatus::InProgress;
|
||||
else if (statusStr == "completed")
|
||||
result.status = ItemStatus::Completed;
|
||||
else
|
||||
result.status = ItemStatus::Incomplete;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !id.isEmpty() && !callId.isEmpty() && !name.isEmpty(); }
|
||||
};
|
||||
|
||||
struct ReasoningOutput
|
||||
{
|
||||
QString id;
|
||||
ItemStatus status = ItemStatus::InProgress;
|
||||
QString summaryText;
|
||||
QString encryptedContent;
|
||||
QList<QString> contentTexts;
|
||||
|
||||
static ReasoningOutput fromJson(const QJsonObject &obj)
|
||||
{
|
||||
ReasoningOutput result;
|
||||
result.id = obj["id"].toString();
|
||||
|
||||
const QString statusStr = obj["status"].toString();
|
||||
if (statusStr == "in_progress")
|
||||
result.status = ItemStatus::InProgress;
|
||||
else if (statusStr == "completed")
|
||||
result.status = ItemStatus::Completed;
|
||||
else
|
||||
result.status = ItemStatus::Incomplete;
|
||||
|
||||
if (obj.contains("summary")) {
|
||||
const QJsonArray summary = obj["summary"].toArray();
|
||||
for (const auto &item : summary) {
|
||||
const QJsonObject itemObj = item.toObject();
|
||||
if (itemObj["type"].toString() == "summary_text") {
|
||||
result.summaryText = itemObj["text"].toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.contains("content")) {
|
||||
const QJsonArray content = obj["content"].toArray();
|
||||
result.contentTexts.reserve(content.size());
|
||||
|
||||
for (const auto &item : content) {
|
||||
const QJsonObject itemObj = item.toObject();
|
||||
if (itemObj["type"].toString() == "reasoning_text") {
|
||||
result.contentTexts.append(itemObj["text"].toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.contains("encrypted_content")) {
|
||||
result.encryptedContent = obj["encrypted_content"].toString();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !id.isEmpty(); }
|
||||
bool hasContent() const noexcept
|
||||
{
|
||||
return !summaryText.isEmpty() || !contentTexts.isEmpty() || !encryptedContent.isEmpty();
|
||||
}
|
||||
};
|
||||
|
||||
struct FileSearchResult
|
||||
{
|
||||
QString fileId;
|
||||
QString filename;
|
||||
QString text;
|
||||
double score = 0.0;
|
||||
|
||||
static FileSearchResult fromJson(const QJsonObject &obj)
|
||||
{
|
||||
return {
|
||||
obj["file_id"].toString(),
|
||||
obj["filename"].toString(),
|
||||
obj["text"].toString(),
|
||||
obj["score"].toDouble()};
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !fileId.isEmpty(); }
|
||||
};
|
||||
|
||||
struct FileSearchCall
|
||||
{
|
||||
QString id;
|
||||
QString status;
|
||||
QStringList queries;
|
||||
QList<FileSearchResult> results;
|
||||
|
||||
static FileSearchCall fromJson(const QJsonObject &obj)
|
||||
{
|
||||
FileSearchCall result;
|
||||
result.id = obj["id"].toString();
|
||||
result.status = obj["status"].toString();
|
||||
|
||||
if (obj.contains("queries")) {
|
||||
const QJsonArray queries = obj["queries"].toArray();
|
||||
result.queries.reserve(queries.size());
|
||||
|
||||
for (const auto &q : queries) {
|
||||
result.queries.append(q.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.contains("results")) {
|
||||
const QJsonArray results = obj["results"].toArray();
|
||||
result.results.reserve(results.size());
|
||||
|
||||
for (const auto &r : results) {
|
||||
result.results.append(FileSearchResult::fromJson(r.toObject()));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !id.isEmpty(); }
|
||||
};
|
||||
|
||||
struct CodeInterpreterOutput
|
||||
{
|
||||
QString type;
|
||||
QString logs;
|
||||
QString imageUrl;
|
||||
|
||||
static CodeInterpreterOutput fromJson(const QJsonObject &obj)
|
||||
{
|
||||
CodeInterpreterOutput result;
|
||||
result.type = obj["type"].toString();
|
||||
if (result.type == "logs") {
|
||||
result.logs = obj["logs"].toString();
|
||||
} else if (result.type == "image") {
|
||||
result.imageUrl = obj["url"].toString();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept
|
||||
{
|
||||
return !type.isEmpty() && (!logs.isEmpty() || !imageUrl.isEmpty());
|
||||
}
|
||||
};
|
||||
|
||||
struct CodeInterpreterCall
|
||||
{
|
||||
QString id;
|
||||
QString containerId;
|
||||
std::optional<QString> code;
|
||||
QString status;
|
||||
QList<CodeInterpreterOutput> outputs;
|
||||
|
||||
static CodeInterpreterCall fromJson(const QJsonObject &obj)
|
||||
{
|
||||
CodeInterpreterCall result;
|
||||
result.id = obj["id"].toString();
|
||||
result.containerId = obj["container_id"].toString();
|
||||
result.status = obj["status"].toString();
|
||||
|
||||
if (obj.contains("code") && !obj["code"].isNull()) {
|
||||
result.code = obj["code"].toString();
|
||||
}
|
||||
|
||||
if (obj.contains("outputs")) {
|
||||
const QJsonArray outputs = obj["outputs"].toArray();
|
||||
result.outputs.reserve(outputs.size());
|
||||
|
||||
for (const auto &o : outputs) {
|
||||
result.outputs.append(CodeInterpreterOutput::fromJson(o.toObject()));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !id.isEmpty() && !containerId.isEmpty(); }
|
||||
};
|
||||
|
||||
class OutputItem
|
||||
{
|
||||
public:
|
||||
enum class Type { Message, FunctionCall, Reasoning, FileSearch, CodeInterpreter, Unknown };
|
||||
|
||||
explicit OutputItem(const MessageOutput &msg)
|
||||
: m_type(Type::Message)
|
||||
, m_data(msg)
|
||||
{}
|
||||
explicit OutputItem(const FunctionCall &call)
|
||||
: m_type(Type::FunctionCall)
|
||||
, m_data(call)
|
||||
{}
|
||||
explicit OutputItem(const ReasoningOutput &reasoning)
|
||||
: m_type(Type::Reasoning)
|
||||
, m_data(reasoning)
|
||||
{}
|
||||
explicit OutputItem(const FileSearchCall &search)
|
||||
: m_type(Type::FileSearch)
|
||||
, m_data(search)
|
||||
{}
|
||||
explicit OutputItem(const CodeInterpreterCall &interpreter)
|
||||
: m_type(Type::CodeInterpreter)
|
||||
, m_data(interpreter)
|
||||
{}
|
||||
|
||||
Type type() const { return m_type; }
|
||||
|
||||
const MessageOutput *asMessage() const
|
||||
{
|
||||
return std::holds_alternative<MessageOutput>(m_data) ? &std::get<MessageOutput>(m_data)
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
const FunctionCall *asFunctionCall() const
|
||||
{
|
||||
return std::holds_alternative<FunctionCall>(m_data) ? &std::get<FunctionCall>(m_data)
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
const ReasoningOutput *asReasoning() const
|
||||
{
|
||||
return std::holds_alternative<ReasoningOutput>(m_data) ? &std::get<ReasoningOutput>(m_data)
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
const FileSearchCall *asFileSearch() const
|
||||
{
|
||||
return std::holds_alternative<FileSearchCall>(m_data) ? &std::get<FileSearchCall>(m_data)
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
const CodeInterpreterCall *asCodeInterpreter() const
|
||||
{
|
||||
return std::holds_alternative<CodeInterpreterCall>(m_data)
|
||||
? &std::get<CodeInterpreterCall>(m_data)
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
static OutputItem fromJson(const QJsonObject &obj)
|
||||
{
|
||||
const QString type = obj["type"].toString();
|
||||
|
||||
if (type == "message") {
|
||||
return OutputItem(MessageOutput::fromJson(obj));
|
||||
} else if (type == "function_call") {
|
||||
return OutputItem(FunctionCall::fromJson(obj));
|
||||
} else if (type == "reasoning") {
|
||||
return OutputItem(ReasoningOutput::fromJson(obj));
|
||||
} else if (type == "file_search_call") {
|
||||
return OutputItem(FileSearchCall::fromJson(obj));
|
||||
} else if (type == "code_interpreter_call") {
|
||||
return OutputItem(CodeInterpreterCall::fromJson(obj));
|
||||
}
|
||||
|
||||
return OutputItem(MessageOutput{});
|
||||
}
|
||||
|
||||
private:
|
||||
Type m_type;
|
||||
std::variant<MessageOutput, FunctionCall, ReasoningOutput, FileSearchCall, CodeInterpreterCall>
|
||||
m_data;
|
||||
};
|
||||
|
||||
struct Usage
|
||||
{
|
||||
int inputTokens = 0;
|
||||
int outputTokens = 0;
|
||||
int totalTokens = 0;
|
||||
|
||||
static Usage fromJson(const QJsonObject &obj)
|
||||
{
|
||||
return {
|
||||
obj["input_tokens"].toInt(),
|
||||
obj["output_tokens"].toInt(),
|
||||
obj["total_tokens"].toInt()
|
||||
};
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return totalTokens > 0; }
|
||||
};
|
||||
|
||||
struct ResponseError
|
||||
{
|
||||
QString code;
|
||||
QString message;
|
||||
|
||||
static ResponseError fromJson(const QJsonObject &obj)
|
||||
{
|
||||
return {obj["code"].toString(), obj["message"].toString()};
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !code.isEmpty() && !message.isEmpty(); }
|
||||
};
|
||||
|
||||
struct Response
|
||||
{
|
||||
QString id;
|
||||
qint64 createdAt = 0;
|
||||
QString model;
|
||||
ResponseStatus status = ResponseStatus::InProgress;
|
||||
QList<OutputItem> output;
|
||||
QString outputText;
|
||||
std::optional<Usage> usage;
|
||||
std::optional<ResponseError> error;
|
||||
std::optional<QString> conversationId;
|
||||
|
||||
static Response fromJson(const QJsonObject &obj)
|
||||
{
|
||||
Response result;
|
||||
result.id = obj["id"].toString();
|
||||
result.createdAt = obj["created_at"].toInteger();
|
||||
result.model = obj["model"].toString();
|
||||
|
||||
const QString statusStr = obj["status"].toString();
|
||||
if (statusStr == "completed")
|
||||
result.status = ResponseStatus::Completed;
|
||||
else if (statusStr == "failed")
|
||||
result.status = ResponseStatus::Failed;
|
||||
else if (statusStr == "in_progress")
|
||||
result.status = ResponseStatus::InProgress;
|
||||
else if (statusStr == "cancelled")
|
||||
result.status = ResponseStatus::Cancelled;
|
||||
else if (statusStr == "queued")
|
||||
result.status = ResponseStatus::Queued;
|
||||
else
|
||||
result.status = ResponseStatus::Incomplete;
|
||||
|
||||
if (obj.contains("output")) {
|
||||
const QJsonArray output = obj["output"].toArray();
|
||||
result.output.reserve(output.size());
|
||||
|
||||
for (const auto &item : output) {
|
||||
result.output.append(OutputItem::fromJson(item.toObject()));
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.contains("output_text")) {
|
||||
result.outputText = obj["output_text"].toString();
|
||||
}
|
||||
|
||||
if (obj.contains("usage")) {
|
||||
result.usage = Usage::fromJson(obj["usage"].toObject());
|
||||
}
|
||||
|
||||
if (obj.contains("error")) {
|
||||
result.error = ResponseError::fromJson(obj["error"].toObject());
|
||||
}
|
||||
|
||||
if (obj.contains("conversation")) {
|
||||
const QJsonObject conv = obj["conversation"].toObject();
|
||||
result.conversationId = conv["id"].toString();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
QString getAggregatedText() const
|
||||
{
|
||||
if (!outputText.isEmpty()) {
|
||||
return outputText;
|
||||
}
|
||||
|
||||
QString aggregated;
|
||||
for (const auto &item : output) {
|
||||
if (const auto *msg = item.asMessage()) {
|
||||
for (const auto &text : msg->outputTexts) {
|
||||
aggregated += text.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return aggregated;
|
||||
}
|
||||
|
||||
bool isValid() const noexcept { return !id.isEmpty(); }
|
||||
bool hasError() const noexcept { return error.has_value(); }
|
||||
bool isCompleted() const noexcept { return status == ResponseStatus::Completed; }
|
||||
bool isFailed() const noexcept { return status == ResponseStatus::Failed; }
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::OpenAIResponses
|
||||
|
||||
246
providers/OpenAIResponsesMessage.cpp
Normal file
@ -0,0 +1,246 @@
|
||||
/*
|
||||
* 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 "OpenAIResponsesMessage.hpp"
|
||||
#include "OpenAIResponses/ResponseObject.hpp"
|
||||
|
||||
#include "logger/Logger.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
OpenAIResponsesMessage::OpenAIResponsesMessage(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
void OpenAIResponsesMessage::handleItemDelta(const QJsonObject &item)
|
||||
{
|
||||
using namespace QodeAssist::OpenAIResponses;
|
||||
|
||||
const QString itemType = item["type"].toString();
|
||||
|
||||
if (itemType == "message" || (itemType.isEmpty() && item.contains("content"))) {
|
||||
OutputItem outputItem = OutputItem::fromJson(item);
|
||||
|
||||
if (const auto *msg = outputItem.asMessage()) {
|
||||
for (const auto &outputText : msg->outputTexts) {
|
||||
if (!outputText.text.isEmpty()) {
|
||||
auto textItem = getOrCreateTextItem();
|
||||
textItem->appendText(outputText.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void OpenAIResponsesMessage::handleToolCallStart(const QString &callId, const QString &name)
|
||||
{
|
||||
auto toolContent = new LLMCore::ToolUseContent(callId, name);
|
||||
toolContent->setParent(this);
|
||||
m_items.append(toolContent);
|
||||
m_toolCalls[callId] = toolContent;
|
||||
m_pendingToolArguments[callId] = "";
|
||||
}
|
||||
|
||||
void OpenAIResponsesMessage::handleToolCallDelta(const QString &callId, const QString &argumentsDelta)
|
||||
{
|
||||
if (m_pendingToolArguments.contains(callId)) {
|
||||
m_pendingToolArguments[callId] += argumentsDelta;
|
||||
}
|
||||
}
|
||||
|
||||
void OpenAIResponsesMessage::handleToolCallComplete(const QString &callId)
|
||||
{
|
||||
if (m_pendingToolArguments.contains(callId) && m_toolCalls.contains(callId)) {
|
||||
QString jsonArgs = m_pendingToolArguments[callId];
|
||||
QJsonObject argsObject;
|
||||
|
||||
if (!jsonArgs.isEmpty()) {
|
||||
QJsonDocument doc = QJsonDocument::fromJson(jsonArgs.toUtf8());
|
||||
if (doc.isObject()) {
|
||||
argsObject = doc.object();
|
||||
}
|
||||
}
|
||||
|
||||
m_toolCalls[callId]->setInput(argsObject);
|
||||
m_pendingToolArguments.remove(callId);
|
||||
}
|
||||
}
|
||||
|
||||
void OpenAIResponsesMessage::handleReasoningStart(const QString &itemId)
|
||||
{
|
||||
auto thinkingContent = new LLMCore::ThinkingContent();
|
||||
thinkingContent->setParent(this);
|
||||
m_items.append(thinkingContent);
|
||||
m_thinkingBlocks[itemId] = thinkingContent;
|
||||
}
|
||||
|
||||
void OpenAIResponsesMessage::handleReasoningDelta(const QString &itemId, const QString &text)
|
||||
{
|
||||
if (m_thinkingBlocks.contains(itemId)) {
|
||||
m_thinkingBlocks[itemId]->appendThinking(text);
|
||||
}
|
||||
}
|
||||
|
||||
void OpenAIResponsesMessage::handleReasoningComplete(const QString &itemId)
|
||||
{
|
||||
Q_UNUSED(itemId);
|
||||
}
|
||||
|
||||
void OpenAIResponsesMessage::handleStatus(const QString &status)
|
||||
{
|
||||
m_status = status;
|
||||
updateStateFromStatus();
|
||||
}
|
||||
|
||||
QList<QJsonObject> OpenAIResponsesMessage::toItemsFormat() const
|
||||
{
|
||||
QList<QJsonObject> items;
|
||||
|
||||
QString textContent;
|
||||
QList<LLMCore::ToolUseContent *> toolCalls;
|
||||
|
||||
for (const auto *block : m_items) {
|
||||
if (const auto *text = qobject_cast<const LLMCore::TextContent *>(block)) {
|
||||
textContent += text->text();
|
||||
} else if (auto *tool = qobject_cast<LLMCore::ToolUseContent *>(
|
||||
const_cast<LLMCore::ContentBlock *>(block))) {
|
||||
toolCalls.append(tool);
|
||||
}
|
||||
}
|
||||
|
||||
if (!textContent.isEmpty()) {
|
||||
QJsonObject message;
|
||||
message["role"] = "assistant";
|
||||
message["content"] = textContent;
|
||||
items.append(message);
|
||||
}
|
||||
|
||||
for (const auto *tool : toolCalls) {
|
||||
QJsonObject functionCallItem;
|
||||
functionCallItem["type"] = "function_call";
|
||||
functionCallItem["call_id"] = tool->id();
|
||||
functionCallItem["name"] = tool->name();
|
||||
functionCallItem["arguments"] = QString::fromUtf8(
|
||||
QJsonDocument(tool->input()).toJson(QJsonDocument::Compact));
|
||||
items.append(functionCallItem);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
QList<LLMCore::ToolUseContent *> OpenAIResponsesMessage::getCurrentToolUseContent() const
|
||||
{
|
||||
QList<LLMCore::ToolUseContent *> toolBlocks;
|
||||
for (auto *block : m_items) {
|
||||
if (auto *toolContent = qobject_cast<LLMCore::ToolUseContent *>(block)) {
|
||||
toolBlocks.append(toolContent);
|
||||
}
|
||||
}
|
||||
return toolBlocks;
|
||||
}
|
||||
|
||||
QList<LLMCore::ThinkingContent *> OpenAIResponsesMessage::getCurrentThinkingContent() const
|
||||
{
|
||||
QList<LLMCore::ThinkingContent *> thinkingBlocks;
|
||||
for (auto *block : m_items) {
|
||||
if (auto *thinkingContent = qobject_cast<LLMCore::ThinkingContent *>(block)) {
|
||||
thinkingBlocks.append(thinkingContent);
|
||||
}
|
||||
}
|
||||
return thinkingBlocks;
|
||||
}
|
||||
|
||||
QJsonArray OpenAIResponsesMessage::createToolResultItems(const QHash<QString, QString> &toolResults) const
|
||||
{
|
||||
QJsonArray items;
|
||||
|
||||
for (const auto *toolContent : getCurrentToolUseContent()) {
|
||||
if (toolResults.contains(toolContent->id())) {
|
||||
QJsonObject toolResultItem;
|
||||
toolResultItem["type"] = "function_call_output";
|
||||
toolResultItem["call_id"] = toolContent->id();
|
||||
toolResultItem["output"] = toolResults[toolContent->id()];
|
||||
items.append(toolResultItem);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
QString OpenAIResponsesMessage::accumulatedText() const
|
||||
{
|
||||
QString text;
|
||||
for (const auto *block : m_items) {
|
||||
if (const auto *textContent = qobject_cast<const LLMCore::TextContent *>(block)) {
|
||||
text += textContent->text();
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
void OpenAIResponsesMessage::updateStateFromStatus()
|
||||
{
|
||||
using namespace QodeAssist::OpenAIResponses;
|
||||
|
||||
if (m_status == "completed") {
|
||||
if (!getCurrentToolUseContent().isEmpty()) {
|
||||
m_state = LLMCore::MessageState::RequiresToolExecution;
|
||||
} else {
|
||||
m_state = LLMCore::MessageState::Complete;
|
||||
}
|
||||
} else if (m_status == "in_progress") {
|
||||
m_state = LLMCore::MessageState::Building;
|
||||
} else if (m_status == "failed" || m_status == "cancelled" || m_status == "incomplete") {
|
||||
m_state = LLMCore::MessageState::Final;
|
||||
} else {
|
||||
m_state = LLMCore::MessageState::Building;
|
||||
}
|
||||
}
|
||||
|
||||
LLMCore::TextContent *OpenAIResponsesMessage::getOrCreateTextItem()
|
||||
{
|
||||
for (auto *block : m_items) {
|
||||
if (auto *textContent = qobject_cast<LLMCore::TextContent *>(block)) {
|
||||
return textContent;
|
||||
}
|
||||
}
|
||||
|
||||
auto *textContent = new LLMCore::TextContent();
|
||||
textContent->setParent(this);
|
||||
m_items.append(textContent);
|
||||
return textContent;
|
||||
}
|
||||
|
||||
void OpenAIResponsesMessage::startNewContinuation()
|
||||
{
|
||||
m_toolCalls.clear();
|
||||
m_thinkingBlocks.clear();
|
||||
|
||||
qDeleteAll(m_items);
|
||||
m_items.clear();
|
||||
|
||||
m_pendingToolArguments.clear();
|
||||
m_status.clear();
|
||||
m_state = LLMCore::MessageState::Building;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
|
||||
67
providers/OpenAIResponsesMessage.hpp
Normal file
@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 <llmcore/ContentBlocks.hpp>
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
class OpenAIResponsesMessage : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit OpenAIResponsesMessage(QObject *parent = nullptr);
|
||||
|
||||
void handleItemDelta(const QJsonObject &item);
|
||||
void handleToolCallStart(const QString &callId, const QString &name);
|
||||
void handleToolCallDelta(const QString &callId, const QString &argumentsDelta);
|
||||
void handleToolCallComplete(const QString &callId);
|
||||
void handleReasoningStart(const QString &itemId);
|
||||
void handleReasoningDelta(const QString &itemId, const QString &text);
|
||||
void handleReasoningComplete(const QString &itemId);
|
||||
void handleStatus(const QString &status);
|
||||
|
||||
QList<QJsonObject> toItemsFormat() const;
|
||||
QJsonArray createToolResultItems(const QHash<QString, QString> &toolResults) const;
|
||||
|
||||
LLMCore::MessageState state() const noexcept { return m_state; }
|
||||
QString accumulatedText() const;
|
||||
QList<LLMCore::ToolUseContent *> getCurrentToolUseContent() const;
|
||||
QList<LLMCore::ThinkingContent *> getCurrentThinkingContent() const;
|
||||
|
||||
bool hasToolCalls() const noexcept { return !m_toolCalls.isEmpty(); }
|
||||
bool hasThinkingContent() const noexcept { return !m_thinkingBlocks.isEmpty(); }
|
||||
|
||||
void startNewContinuation();
|
||||
|
||||
private:
|
||||
QString m_status;
|
||||
LLMCore::MessageState m_state = LLMCore::MessageState::Building;
|
||||
QList<LLMCore::ContentBlock *> m_items;
|
||||
QHash<QString, QString> m_pendingToolArguments;
|
||||
QHash<QString, LLMCore::ToolUseContent *> m_toolCalls;
|
||||
QHash<QString, LLMCore::ThinkingContent *> m_thinkingBlocks;
|
||||
|
||||
void updateStateFromStatus();
|
||||
LLMCore::TextContent *getOrCreateTextItem();
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
|
||||