mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-06-13 01:39:13 -04:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcf5796ad7 | ||
|
|
033c0e8652 | ||
|
|
ea67ba0e2a | ||
|
|
0cf915c4a5 | ||
|
|
99caa853d5 | ||
|
|
278624d412 | ||
|
|
f8adf4d264 | ||
|
|
bfcd8dc1fb | ||
|
|
33321b2499 | ||
|
|
362533a5c0 | ||
|
|
d180d189e4 | ||
|
|
0774084ad9 | ||
|
|
282f48d9fb | ||
|
|
8cbeb7132e | ||
|
|
af898bd255 | ||
|
|
66e25300e8 | ||
|
|
fcc651fd75 | ||
|
|
dc016ce533 | ||
|
|
725de4a2c3 | ||
|
|
8d3313d16b | ||
|
|
abdcab3c7d | ||
|
|
abadc2262c | ||
|
|
31ad99af61 | ||
|
|
fb887967ed | ||
|
|
97236c6069 | ||
|
|
51ebe3e523 | ||
|
|
e193d1e1fa | ||
|
|
ca3baa7597 | ||
|
|
b33a1c2d43 | ||
|
|
c4e34bb3d9 | ||
|
|
b9e0b5a00c | ||
|
|
3f4bda51cd | ||
|
|
7483c78777 | ||
|
|
a3ad314cd4 | ||
|
|
74c899c8c3 | ||
|
|
6addcedfd0 | ||
|
|
eb7fc2f7b4 | ||
|
|
a06320d1c4 | ||
|
|
b1ca6823b8 | ||
|
|
cc2d42f6d7 | ||
|
|
4faeb90dc0 | ||
|
|
9f7497d15c | ||
|
|
cab2f0a55e | ||
|
|
7704bffd88 | ||
|
|
3b421f60af | ||
|
|
86f4635080 | ||
|
|
f21757b9b3 | ||
|
|
9bb6d55687 |
2
.github/workflows/build_cmake.yml
vendored
2
.github/workflows/build_cmake.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
}
|
||||
- {
|
||||
qt_version: "6.10.3",
|
||||
qt_creator_version: "19.0.1"
|
||||
qt_creator_version: "19.0.2"
|
||||
}
|
||||
|
||||
steps:
|
||||
|
||||
@@ -2,6 +2,10 @@ cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
project(QodeAssist)
|
||||
|
||||
option(QODEASSIST_EXPERIMENTAL
|
||||
"Enable experimental features" OFF)
|
||||
message(STATUS "QodeAssist experimental features: ${QODEASSIST_EXPERIMENTAL}")
|
||||
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
@@ -14,7 +18,9 @@ find_package(QtCreator REQUIRED COMPONENTS Core)
|
||||
find_package(Qt6 COMPONENTS Core Gui Quick Widgets Network Svg Test LinguistTools REQUIRED)
|
||||
find_package(GTest)
|
||||
|
||||
qt_standard_project_setup(I18N_TRANSLATED_LANGUAGES en)
|
||||
qt_standard_project_setup(I18N_TRANSLATED_LANGUAGES
|
||||
en cs zh_CN zh_TW da de fr hr ja pl ru sl sv uk
|
||||
)
|
||||
|
||||
# IDE_VERSION is defined by QtCreator package
|
||||
string(REGEX MATCH "([0-9]+)\\.([0-9]+)\\.([0-9]+)" version_match ${IDE_VERSION})
|
||||
@@ -34,10 +40,10 @@ add_definitions(
|
||||
-DQODEASSIST_QT_CREATOR_VERSION_PATCH=${QODEASSIST_QT_CREATOR_VERSION_PATCH}
|
||||
)
|
||||
|
||||
add_subdirectory(sources/external/llmqore)
|
||||
add_subdirectory(sources)
|
||||
add_subdirectory(logger)
|
||||
add_subdirectory(pluginllmcore)
|
||||
add_subdirectory(settings)
|
||||
add_subdirectory(logger)
|
||||
add_subdirectory(UIControls)
|
||||
add_subdirectory(ChatView)
|
||||
add_subdirectory(context)
|
||||
@@ -64,6 +70,9 @@ add_qtc_plugin(QodeAssist
|
||||
QtCreator::CPlusPlus
|
||||
LLMQore
|
||||
PluginLLMCore
|
||||
ProvidersConfig
|
||||
Agents
|
||||
Skills
|
||||
QodeAssistChatViewplugin
|
||||
SOURCES
|
||||
.github/workflows/build_cmake.yml
|
||||
@@ -93,6 +102,7 @@ add_qtc_plugin(QodeAssist
|
||||
templates/Qwen3CoderFIM.hpp
|
||||
templates/OpenAIResponses.hpp
|
||||
providers/Providers.hpp
|
||||
providers/ProviderUrlUtils.hpp
|
||||
providers/OllamaProvider.hpp providers/OllamaProvider.cpp
|
||||
providers/OllamaCompatProvider.hpp providers/OllamaCompatProvider.cpp
|
||||
providers/ClaudeProvider.hpp providers/ClaudeProvider.cpp
|
||||
@@ -106,6 +116,9 @@ add_qtc_plugin(QodeAssist
|
||||
providers/LlamaCppProvider.hpp providers/LlamaCppProvider.cpp
|
||||
providers/CodestralProvider.hpp providers/CodestralProvider.cpp
|
||||
providers/OpenAIResponsesProvider.hpp providers/OpenAIResponsesProvider.cpp
|
||||
providers/QwenProvider.hpp providers/QwenProvider.cpp
|
||||
providers/QwenResponsesProvider.hpp providers/QwenResponsesProvider.cpp
|
||||
providers/DeepSeekProvider.hpp providers/DeepSeekProvider.cpp
|
||||
QodeAssist.qrc
|
||||
LSPCompletion.hpp
|
||||
LLMSuggestion.hpp LLMSuggestion.cpp
|
||||
@@ -114,6 +127,9 @@ add_qtc_plugin(QodeAssist
|
||||
QodeAssistClient.hpp QodeAssistClient.cpp
|
||||
chat/ChatOutputPane.h chat/ChatOutputPane.cpp
|
||||
chat/NavigationPanel.hpp chat/NavigationPanel.cpp
|
||||
chat/ChatDocument.hpp chat/ChatDocument.cpp
|
||||
chat/ChatEditor.hpp chat/ChatEditor.cpp
|
||||
chat/ChatEditorFactory.hpp chat/ChatEditorFactory.cpp
|
||||
ConfigurationManager.hpp ConfigurationManager.cpp
|
||||
CodeHandler.hpp CodeHandler.cpp
|
||||
UpdateStatusWidget.hpp UpdateStatusWidget.cpp
|
||||
@@ -146,12 +162,19 @@ add_qtc_plugin(QodeAssist
|
||||
tools/ReadFileTool.hpp tools/ReadFileTool.cpp
|
||||
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp
|
||||
tools/TodoTool.hpp tools/TodoTool.cpp
|
||||
tools/ReadOriginalHistoryTool.hpp tools/ReadOriginalHistoryTool.cpp
|
||||
tools/SkillTool.hpp tools/SkillTool.cpp
|
||||
mcp/McpServerManager.hpp mcp/McpServerManager.cpp
|
||||
mcp/McpServerConnection.hpp mcp/McpServerConnection.cpp
|
||||
mcp/McpClientsManager.hpp mcp/McpClientsManager.cpp
|
||||
settings/McpClientsListAspect.hpp settings/McpClientsListAspect.cpp
|
||||
)
|
||||
|
||||
if(QODEASSIST_EXPERIMENTAL)
|
||||
target_compile_definitions(QodeAssist PRIVATE QODEASSIST_EXPERIMENTAL)
|
||||
target_link_libraries(QodeAssist PRIVATE QodeAssistAgentPipelines)
|
||||
endif()
|
||||
|
||||
get_target_property(QtCreatorCorePath QtCreator::Core LOCATION)
|
||||
find_program(QtCreatorExecutable
|
||||
NAMES
|
||||
@@ -173,5 +196,5 @@ endif()
|
||||
qt_add_translations(TARGETS QodeAssist
|
||||
TS_FILE_DIR ${CMAKE_CURRENT_LIST_DIR}/resources/translations
|
||||
RESOURCE_PREFIX "/translations"
|
||||
LUPDATE_OPTIONS -no-obsolete
|
||||
LUPDATE_OPTIONS -no-obsolete -locations none
|
||||
)
|
||||
|
||||
123
ChatView/AgentRoleController.cpp
Normal file
123
ChatView/AgentRoleController.cpp
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "AgentRoleController.hpp"
|
||||
|
||||
#include <utils/aspects.h>
|
||||
|
||||
#include "AgentRole.hpp"
|
||||
#include "ChatAssistantSettings.hpp"
|
||||
#include "GeneralSettings.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
AgentRoleController::AgentRoleController(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
connect(
|
||||
&Settings::chatAssistantSettings().systemPrompt,
|
||||
&Utils::BaseAspect::changed,
|
||||
this,
|
||||
&AgentRoleController::baseSystemPromptChanged);
|
||||
|
||||
loadAvailableRoles();
|
||||
}
|
||||
|
||||
QStringList AgentRoleController::availableRoles() const
|
||||
{
|
||||
return m_availableRoles;
|
||||
}
|
||||
|
||||
QString AgentRoleController::currentRole() const
|
||||
{
|
||||
return m_currentRole;
|
||||
}
|
||||
|
||||
QString AgentRoleController::baseSystemPrompt() const
|
||||
{
|
||||
return Settings::chatAssistantSettings().systemPrompt();
|
||||
}
|
||||
|
||||
QString AgentRoleController::currentRoleDescription() const
|
||||
{
|
||||
const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
|
||||
if (lastRoleId.isEmpty())
|
||||
return Settings::AgentRolesManager::getNoRole().description;
|
||||
|
||||
const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
|
||||
if (role.id.isEmpty())
|
||||
return Settings::AgentRolesManager::getNoRole().description;
|
||||
|
||||
return role.description;
|
||||
}
|
||||
|
||||
QString AgentRoleController::currentRoleSystemPrompt() const
|
||||
{
|
||||
const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
|
||||
if (lastRoleId.isEmpty())
|
||||
return QString();
|
||||
|
||||
const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
|
||||
if (role.id.isEmpty())
|
||||
return QString();
|
||||
|
||||
return role.systemPrompt;
|
||||
}
|
||||
|
||||
void AgentRoleController::loadAvailableRoles()
|
||||
{
|
||||
const QList<Settings::AgentRole> roles = Settings::AgentRolesManager::loadAllRoles();
|
||||
|
||||
m_availableRoles.clear();
|
||||
m_availableRoles.append(Settings::AgentRolesManager::getNoRole().name);
|
||||
|
||||
for (const auto &role : roles)
|
||||
m_availableRoles.append(role.name);
|
||||
|
||||
const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
|
||||
m_currentRole = Settings::AgentRolesManager::getNoRole().name;
|
||||
|
||||
if (!lastRoleId.isEmpty()) {
|
||||
for (const auto &role : roles) {
|
||||
if (role.id == lastRoleId) {
|
||||
m_currentRole = role.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit availableRolesChanged();
|
||||
emit currentRoleChanged();
|
||||
}
|
||||
|
||||
void AgentRoleController::applyRole(const QString &roleName)
|
||||
{
|
||||
auto &settings = Settings::chatAssistantSettings();
|
||||
|
||||
if (roleName == Settings::AgentRolesManager::getNoRole().name) {
|
||||
settings.lastUsedRoleId.setValue("");
|
||||
settings.writeSettings();
|
||||
m_currentRole = roleName;
|
||||
emit currentRoleChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<Settings::AgentRole> roles = Settings::AgentRolesManager::loadAllRoles();
|
||||
|
||||
for (const auto &role : roles) {
|
||||
if (role.name == roleName) {
|
||||
settings.lastUsedRoleId.setValue(role.id);
|
||||
settings.writeSettings();
|
||||
m_currentRole = role.name;
|
||||
emit currentRoleChanged();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AgentRoleController::openSettings()
|
||||
{
|
||||
Settings::showSettings(Utils::Id("QodeAssist.AgentRoles"));
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
38
ChatView/AgentRoleController.hpp
Normal file
38
ChatView/AgentRoleController.hpp
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class AgentRoleController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit AgentRoleController(QObject *parent = nullptr);
|
||||
|
||||
QStringList availableRoles() const;
|
||||
QString currentRole() const;
|
||||
QString baseSystemPrompt() const;
|
||||
QString currentRoleDescription() const;
|
||||
QString currentRoleSystemPrompt() const;
|
||||
|
||||
void loadAvailableRoles();
|
||||
void applyRole(const QString &roleName);
|
||||
void openSettings();
|
||||
|
||||
signals:
|
||||
void availableRolesChanged();
|
||||
void currentRoleChanged();
|
||||
void baseSystemPromptChanged();
|
||||
|
||||
private:
|
||||
QStringList m_availableRoles;
|
||||
QString m_currentRole;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -23,9 +23,11 @@ qt_add_qml_module(QodeAssistChatView
|
||||
qml/controls/FileMentionPopup.qml
|
||||
qml/controls/FileEditsActionBar.qml
|
||||
qml/controls/ContextViewer.qml
|
||||
qml/controls/SkillCommandPopup.qml
|
||||
qml/controls/Toast.qml
|
||||
qml/controls/TopBar.qml
|
||||
qml/controls/SplitDropZone.qml
|
||||
qml/controls/MessageNavigator.qml
|
||||
|
||||
RESOURCES
|
||||
icons/attach-file-light.svg
|
||||
@@ -43,9 +45,11 @@ qt_add_qml_module(QodeAssistChatView
|
||||
icons/window-unlock.svg
|
||||
icons/chat-icon.svg
|
||||
icons/chat-pause-icon.svg
|
||||
icons/new-chat-icon.svg
|
||||
icons/rules-icon.svg
|
||||
icons/context-icon.svg
|
||||
icons/open-in-editor.svg
|
||||
icons/open-in-window.svg
|
||||
icons/apply-changes-button.svg
|
||||
icons/undo-changes-button.svg
|
||||
icons/reject-changes-button.svg
|
||||
@@ -55,6 +59,7 @@ qt_add_qml_module(QodeAssistChatView
|
||||
icons/tools-icon-off.svg
|
||||
icons/settings-icon.svg
|
||||
icons/compress-icon.svg
|
||||
icons/open-in-code.svg
|
||||
|
||||
SOURCES
|
||||
ChatWidget.hpp ChatWidget.cpp
|
||||
@@ -69,7 +74,13 @@ qt_add_qml_module(QodeAssistChatView
|
||||
FileItem.hpp FileItem.cpp
|
||||
ChatFileManager.hpp ChatFileManager.cpp
|
||||
ChatCompressor.hpp ChatCompressor.cpp
|
||||
AgentRoleController.hpp AgentRoleController.cpp
|
||||
ChatConfigurationController.hpp ChatConfigurationController.cpp
|
||||
FileEditController.hpp FileEditController.cpp
|
||||
InputTokenCounter.hpp InputTokenCounter.cpp
|
||||
ChatHistoryStore.hpp ChatHistoryStore.cpp
|
||||
FileMentionItem.hpp FileMentionItem.cpp
|
||||
SessionFileRegistry.hpp SessionFileRegistry.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(QodeAssistChatView
|
||||
@@ -86,8 +97,9 @@ target_link_libraries(QodeAssistChatView
|
||||
QodeAssistUIControlsplugin
|
||||
QodeAssistLogger
|
||||
LLMQore
|
||||
Skills
|
||||
)
|
||||
|
||||
target_include_directories(QodeAssistChatView
|
||||
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_SOURCE_DIR}
|
||||
)
|
||||
|
||||
@@ -228,6 +228,8 @@ bool ChatCompressor::createCompressedChatFile(
|
||||
summaryMessage["images"] = QJsonArray();
|
||||
|
||||
root["messages"] = QJsonArray{summaryMessage};
|
||||
root["compressedFrom"] = sourcePath;
|
||||
root["compressedAt"] = QDateTime::currentDateTime().toString(Qt::ISODate);
|
||||
|
||||
if (QFile::exists(destPath))
|
||||
QFile::remove(destPath);
|
||||
|
||||
99
ChatView/ChatConfigurationController.cpp
Normal file
99
ChatView/ChatConfigurationController.cpp
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "ChatConfigurationController.hpp"
|
||||
|
||||
#include <utils/aspects.h>
|
||||
|
||||
#include "ConfigurationManager.hpp"
|
||||
#include "GeneralSettings.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatConfigurationController::ChatConfigurationController(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
auto &settings = Settings::generalSettings();
|
||||
connect(
|
||||
&settings.caProvider,
|
||||
&Utils::BaseAspect::changed,
|
||||
this,
|
||||
&ChatConfigurationController::updateCurrentConfiguration);
|
||||
connect(
|
||||
&settings.caModel,
|
||||
&Utils::BaseAspect::changed,
|
||||
this,
|
||||
&ChatConfigurationController::updateCurrentConfiguration);
|
||||
|
||||
loadAvailableConfigurations();
|
||||
}
|
||||
|
||||
QStringList ChatConfigurationController::availableConfigurations() const
|
||||
{
|
||||
return m_availableConfigurations;
|
||||
}
|
||||
|
||||
QString ChatConfigurationController::currentConfiguration() const
|
||||
{
|
||||
return m_currentConfiguration;
|
||||
}
|
||||
|
||||
void ChatConfigurationController::updateCurrentConfiguration()
|
||||
{
|
||||
auto &settings = Settings::generalSettings();
|
||||
m_currentConfiguration
|
||||
= QString("%1 - %2").arg(settings.caProvider.value(), settings.caModel.value());
|
||||
emit currentConfigurationChanged();
|
||||
}
|
||||
|
||||
void ChatConfigurationController::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);
|
||||
}
|
||||
|
||||
updateCurrentConfiguration();
|
||||
|
||||
emit availableConfigurationsChanged();
|
||||
}
|
||||
|
||||
void ChatConfigurationController::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.caCustomEndpoint.setValue(config.customEndpoint);
|
||||
|
||||
settings.writeSettings();
|
||||
|
||||
m_currentConfiguration = QString("%1 - %2").arg(config.provider, config.model);
|
||||
emit currentConfigurationChanged();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
35
ChatView/ChatConfigurationController.hpp
Normal file
35
ChatView/ChatConfigurationController.hpp
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatConfigurationController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatConfigurationController(QObject *parent = nullptr);
|
||||
|
||||
QStringList availableConfigurations() const;
|
||||
QString currentConfiguration() const;
|
||||
|
||||
void loadAvailableConfigurations();
|
||||
void applyConfiguration(const QString &configName);
|
||||
|
||||
signals:
|
||||
void availableConfigurationsChanged();
|
||||
void currentConfigurationChanged();
|
||||
|
||||
private:
|
||||
void updateCurrentConfiguration();
|
||||
|
||||
QStringList m_availableConfigurations;
|
||||
QString m_currentConfiguration;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
228
ChatView/ChatHistoryStore.cpp
Normal file
228
ChatView/ChatHistoryStore.cpp
Normal file
@@ -0,0 +1,228 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "ChatHistoryStore.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QUrl>
|
||||
|
||||
#include <coreplugin/icore.h>
|
||||
#include <projectexplorer/project.h>
|
||||
#include <projectexplorer/projectmanager.h>
|
||||
|
||||
#include "ChatModel.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include "ProjectSettings.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatHistoryStore::ChatHistoryStore(ChatModel *chatModel, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_chatModel(chatModel)
|
||||
{}
|
||||
|
||||
QString ChatHistoryStore::historyDir() const
|
||||
{
|
||||
QString path;
|
||||
|
||||
if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
|
||||
Settings::ProjectSettings projectSettings(project);
|
||||
path = projectSettings.chatHistoryPath().toFSPathString();
|
||||
} else {
|
||||
QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
|
||||
path = baseDir.filePath("qodeassist/chat_history");
|
||||
}
|
||||
|
||||
QDir dir(path);
|
||||
if (!dir.exists() && !dir.mkpath(".")) {
|
||||
LOG_MESSAGE(QString("Failed to create directory: %1").arg(path));
|
||||
return QString();
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
QString ChatHistoryStore::suggestedFileName() const
|
||||
{
|
||||
QString shortMessage;
|
||||
|
||||
if (m_chatModel->rowCount() > 0) {
|
||||
QString firstMessage
|
||||
= m_chatModel->data(m_chatModel->index(0), ChatModel::Content).toString();
|
||||
shortMessage = firstMessage.split('\n').first().simplified().left(30);
|
||||
|
||||
if (shortMessage.isEmpty()) {
|
||||
QVariantList images
|
||||
= m_chatModel->data(m_chatModel->index(0), ChatModel::Images).toList();
|
||||
if (!images.isEmpty()) {
|
||||
shortMessage = "image_chat";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return generateChatFileName(shortMessage, historyDir());
|
||||
}
|
||||
|
||||
QString ChatHistoryStore::autosaveFilePath(const QString &recentFilePath) const
|
||||
{
|
||||
if (!recentFilePath.isEmpty()) {
|
||||
return recentFilePath;
|
||||
}
|
||||
|
||||
QString dir = historyDir();
|
||||
if (dir.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
return QDir(dir).filePath(suggestedFileName() + ".json");
|
||||
}
|
||||
|
||||
QString ChatHistoryStore::autosaveFilePath(
|
||||
const QString &recentFilePath, const QString &firstMessage, bool hasImageAttachments) const
|
||||
{
|
||||
if (!recentFilePath.isEmpty()) {
|
||||
return recentFilePath;
|
||||
}
|
||||
|
||||
QString dir = historyDir();
|
||||
if (dir.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString shortMessage = firstMessage.split('\n').first().simplified().left(30);
|
||||
|
||||
if (shortMessage.isEmpty() && hasImageAttachments) {
|
||||
shortMessage = "image_chat";
|
||||
}
|
||||
|
||||
QString fileName = generateChatFileName(shortMessage, dir);
|
||||
return QDir(dir).filePath(fileName + ".json");
|
||||
}
|
||||
|
||||
SerializationResult ChatHistoryStore::save(const QString &filePath) const
|
||||
{
|
||||
return ChatSerializer::saveToFile(m_chatModel, filePath);
|
||||
}
|
||||
|
||||
SerializationResult ChatHistoryStore::load(const QString &filePath) const
|
||||
{
|
||||
return ChatSerializer::loadFromFile(m_chatModel, filePath);
|
||||
}
|
||||
|
||||
void ChatHistoryStore::showSaveDialog()
|
||||
{
|
||||
QString initialDir = historyDir();
|
||||
|
||||
QFileDialog *dialog = new QFileDialog(nullptr, tr("Save Chat History"));
|
||||
dialog->setAcceptMode(QFileDialog::AcceptSave);
|
||||
dialog->setFileMode(QFileDialog::AnyFile);
|
||||
dialog->setNameFilter(tr("JSON files (*.json)"));
|
||||
dialog->setDefaultSuffix("json");
|
||||
if (!initialDir.isEmpty()) {
|
||||
dialog->setDirectory(initialDir);
|
||||
dialog->selectFile(suggestedFileName() + ".json");
|
||||
}
|
||||
|
||||
connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
|
||||
if (result == QFileDialog::Accepted) {
|
||||
QStringList files = dialog->selectedFiles();
|
||||
if (!files.isEmpty()) {
|
||||
emit saveRequested(files.first());
|
||||
}
|
||||
}
|
||||
dialog->deleteLater();
|
||||
});
|
||||
|
||||
dialog->open();
|
||||
}
|
||||
|
||||
void ChatHistoryStore::showLoadDialog()
|
||||
{
|
||||
QString initialDir = historyDir();
|
||||
|
||||
QFileDialog *dialog = new QFileDialog(nullptr, tr("Load Chat History"));
|
||||
dialog->setAcceptMode(QFileDialog::AcceptOpen);
|
||||
dialog->setFileMode(QFileDialog::ExistingFile);
|
||||
dialog->setNameFilter(tr("JSON files (*.json)"));
|
||||
if (!initialDir.isEmpty()) {
|
||||
dialog->setDirectory(initialDir);
|
||||
}
|
||||
|
||||
connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
|
||||
if (result == QFileDialog::Accepted) {
|
||||
QStringList files = dialog->selectedFiles();
|
||||
if (!files.isEmpty()) {
|
||||
emit loadRequested(files.first());
|
||||
}
|
||||
}
|
||||
dialog->deleteLater();
|
||||
});
|
||||
|
||||
dialog->open();
|
||||
}
|
||||
|
||||
void ChatHistoryStore::openHistoryFolder() const
|
||||
{
|
||||
QString path;
|
||||
if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
|
||||
Settings::ProjectSettings projectSettings(project);
|
||||
path = projectSettings.chatHistoryPath().toFSPathString();
|
||||
} else {
|
||||
QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
|
||||
path = baseDir.filePath("qodeassist/chat_history");
|
||||
}
|
||||
|
||||
QDir dir(path);
|
||||
if (!dir.exists()) {
|
||||
dir.mkpath(".");
|
||||
}
|
||||
|
||||
QUrl url = QUrl::fromLocalFile(dir.absolutePath());
|
||||
QDesktopServices::openUrl(url);
|
||||
}
|
||||
|
||||
QString ChatHistoryStore::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;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
47
ChatView/ChatHistoryStore.hpp
Normal file
47
ChatView/ChatHistoryStore.hpp
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
#include "ChatSerializer.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatModel;
|
||||
|
||||
class ChatHistoryStore : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatHistoryStore(ChatModel *chatModel, QObject *parent = nullptr);
|
||||
|
||||
QString historyDir() const;
|
||||
QString suggestedFileName() const;
|
||||
QString autosaveFilePath(const QString &recentFilePath) const;
|
||||
QString autosaveFilePath(
|
||||
const QString &recentFilePath,
|
||||
const QString &firstMessage,
|
||||
bool hasImageAttachments) const;
|
||||
|
||||
SerializationResult save(const QString &filePath) const;
|
||||
SerializationResult load(const QString &filePath) const;
|
||||
|
||||
void showSaveDialog();
|
||||
void showLoadDialog();
|
||||
void openHistoryFolder() const;
|
||||
|
||||
signals:
|
||||
void saveRequested(const QString &filePath);
|
||||
void loadRequested(const QString &filePath);
|
||||
|
||||
private:
|
||||
QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
|
||||
|
||||
ChatModel *m_chatModel;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -11,7 +11,6 @@
|
||||
#include <QUrl>
|
||||
#include <QtQml>
|
||||
|
||||
#include "ChatAssistantSettings.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include "context/ChangesManager.h"
|
||||
|
||||
@@ -20,14 +19,6 @@ namespace QodeAssist::Chat {
|
||||
ChatModel::ChatModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
auto &settings = Settings::chatAssistantSettings();
|
||||
|
||||
connect(
|
||||
&settings.chatTokensThreshold,
|
||||
&Utils::BaseAspect::changed,
|
||||
this,
|
||||
&ChatModel::tokensThresholdChanged);
|
||||
|
||||
connect(&Context::ChangesManager::instance(),
|
||||
&Context::ChangesManager::fileEditApplied,
|
||||
this,
|
||||
@@ -86,6 +77,16 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
|
||||
case Roles::IsRedacted: {
|
||||
return message.isRedacted;
|
||||
}
|
||||
case Roles::PromptTokens:
|
||||
return message.promptTokens;
|
||||
case Roles::CompletionTokens:
|
||||
return message.completionTokens;
|
||||
case Roles::CachedPromptTokens:
|
||||
return message.cachedPromptTokens;
|
||||
case Roles::ReasoningTokens:
|
||||
return message.reasoningTokens;
|
||||
case Roles::TotalTokens:
|
||||
return message.promptTokens + message.completionTokens;
|
||||
case Roles::Images: {
|
||||
QVariantList imagesList;
|
||||
for (const auto &image : message.images) {
|
||||
@@ -124,6 +125,11 @@ QHash<int, QByteArray> ChatModel::roleNames() const
|
||||
roles[Roles::Attachments] = "attachments";
|
||||
roles[Roles::IsRedacted] = "isRedacted";
|
||||
roles[Roles::Images] = "images";
|
||||
roles[Roles::PromptTokens] = "promptTokens";
|
||||
roles[Roles::CompletionTokens] = "completionTokens";
|
||||
roles[Roles::CachedPromptTokens] = "cachedPromptTokens";
|
||||
roles[Roles::ReasoningTokens] = "reasoningTokens";
|
||||
roles[Roles::TotalTokens] = "totalTokens";
|
||||
return roles;
|
||||
}
|
||||
|
||||
@@ -207,6 +213,7 @@ void ChatModel::clear()
|
||||
m_messages.clear();
|
||||
endResetModel();
|
||||
emit modelReseted();
|
||||
emit sessionUsageChanged();
|
||||
}
|
||||
|
||||
QList<MessagePart> ChatModel::processMessageContent(const QString &content) const
|
||||
@@ -310,12 +317,6 @@ QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) con
|
||||
return messages;
|
||||
}
|
||||
|
||||
int ChatModel::tokensThreshold() const
|
||||
{
|
||||
auto &settings = Settings::chatAssistantSettings();
|
||||
return settings.chatTokensThreshold();
|
||||
}
|
||||
|
||||
QString ChatModel::lastMessageId() const
|
||||
{
|
||||
return !m_messages.isEmpty() ? m_messages.last().id : "";
|
||||
@@ -330,11 +331,37 @@ void ChatModel::resetModelTo(int index)
|
||||
beginRemoveRows(QModelIndex(), index, m_messages.size() - 1);
|
||||
m_messages.remove(index, m_messages.size() - index);
|
||||
endRemoveRows();
|
||||
emit sessionUsageChanged();
|
||||
}
|
||||
}
|
||||
|
||||
QVariantList ChatModel::userMessagePreviews(int maxLength) const
|
||||
{
|
||||
QVariantList result;
|
||||
const int limit = maxLength > 4 ? maxLength : 80;
|
||||
for (int i = 0; i < m_messages.size(); ++i) {
|
||||
if (m_messages[i].role != ChatRole::User)
|
||||
continue;
|
||||
QString preview = m_messages[i].content;
|
||||
preview.replace(QLatin1Char('\n'), QLatin1Char(' '));
|
||||
preview.replace(QLatin1Char('\r'), QLatin1Char(' '));
|
||||
preview.replace(QLatin1Char('\t'), QLatin1Char(' '));
|
||||
preview = preview.simplified();
|
||||
if (preview.size() > limit)
|
||||
preview = preview.left(limit - 1).trimmed() + QChar(0x2026);
|
||||
QVariantMap entry;
|
||||
entry[QStringLiteral("messageIndex")] = i;
|
||||
entry[QStringLiteral("preview")] = preview;
|
||||
result.append(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void ChatModel::addToolExecutionStatus(
|
||||
const QString &requestId, const QString &toolId, const QString &toolName)
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QJsonObject &toolArguments)
|
||||
{
|
||||
QString content = toolName;
|
||||
|
||||
@@ -345,11 +372,15 @@ void ChatModel::addToolExecutionStatus(
|
||||
&& m_messages.last().role == ChatRole::Tool) {
|
||||
Message &lastMessage = m_messages.last();
|
||||
lastMessage.content = content;
|
||||
lastMessage.toolName = toolName;
|
||||
lastMessage.toolArguments = toolArguments;
|
||||
LOG_MESSAGE(QString("Updated existing tool message at index %1").arg(m_messages.size() - 1));
|
||||
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
|
||||
} else {
|
||||
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
|
||||
Message newMessage{ChatRole::Tool, content, toolId};
|
||||
newMessage.toolName = toolName;
|
||||
newMessage.toolArguments = toolArguments;
|
||||
m_messages.append(newMessage);
|
||||
endInsertRows();
|
||||
LOG_MESSAGE(QString("Created new tool message at index %1 with toolId=%2")
|
||||
@@ -358,6 +389,38 @@ void ChatModel::addToolExecutionStatus(
|
||||
}
|
||||
}
|
||||
|
||||
void ChatModel::dropTrailingAssistantMessage(const QString &requestId)
|
||||
{
|
||||
if (m_messages.isEmpty())
|
||||
return;
|
||||
|
||||
const Message &last = m_messages.last();
|
||||
if (last.role != ChatRole::Assistant || last.id != requestId)
|
||||
return;
|
||||
|
||||
const int idx = m_messages.size() - 1;
|
||||
beginRemoveRows(QModelIndex(), idx, idx);
|
||||
m_messages.removeLast();
|
||||
endRemoveRows();
|
||||
LOG_MESSAGE(QString("Dropped leaked pre-tool assistant message at index %1").arg(idx));
|
||||
}
|
||||
|
||||
void ChatModel::setToolMessageData(
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QJsonObject &toolArguments,
|
||||
const QString &toolResult)
|
||||
{
|
||||
for (int i = 0; i < m_messages.size(); ++i) {
|
||||
if (m_messages[i].role == ChatRole::Tool && m_messages[i].id == toolId) {
|
||||
m_messages[i].toolName = toolName;
|
||||
m_messages[i].toolArguments = toolArguments;
|
||||
m_messages[i].toolResult = toolResult;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ChatModel::updateToolResult(
|
||||
const QString &requestId, const QString &toolId, const QString &toolName, const QString &result)
|
||||
{
|
||||
@@ -377,6 +440,8 @@ void ChatModel::updateToolResult(
|
||||
for (int i = m_messages.size() - 1; i >= 0; --i) {
|
||||
if (m_messages[i].id == toolId && m_messages[i].role == ChatRole::Tool) {
|
||||
m_messages[i].content = toolName + "\n" + result;
|
||||
m_messages[i].toolName = toolName;
|
||||
m_messages[i].toolResult = result;
|
||||
emit dataChanged(index(i), index(i));
|
||||
toolMessageFound = true;
|
||||
LOG_MESSAGE(QString("Updated tool result at index %1").arg(i));
|
||||
@@ -507,6 +572,62 @@ void ChatModel::updateMessageContent(const QString &messageId, const QString &ne
|
||||
}
|
||||
}
|
||||
|
||||
void ChatModel::setMessageUsage(
|
||||
const QString &messageId,
|
||||
int promptTokens,
|
||||
int completionTokens,
|
||||
int cachedPromptTokens,
|
||||
int reasoningTokens)
|
||||
{
|
||||
for (int i = 0; i < m_messages.size(); ++i) {
|
||||
if (m_messages[i].id != messageId)
|
||||
continue;
|
||||
m_messages[i].promptTokens = promptTokens;
|
||||
m_messages[i].completionTokens = completionTokens;
|
||||
m_messages[i].cachedPromptTokens = cachedPromptTokens;
|
||||
m_messages[i].reasoningTokens = reasoningTokens;
|
||||
emit dataChanged(
|
||||
index(i),
|
||||
index(i),
|
||||
{Roles::PromptTokens,
|
||||
Roles::CompletionTokens,
|
||||
Roles::CachedPromptTokens,
|
||||
Roles::ReasoningTokens,
|
||||
Roles::TotalTokens});
|
||||
emit sessionUsageChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int ChatModel::sessionPromptTokens() const
|
||||
{
|
||||
int total = 0;
|
||||
for (const auto &m : m_messages)
|
||||
total += m.promptTokens;
|
||||
return total;
|
||||
}
|
||||
|
||||
int ChatModel::sessionCompletionTokens() const
|
||||
{
|
||||
int total = 0;
|
||||
for (const auto &m : m_messages)
|
||||
total += m.completionTokens;
|
||||
return total;
|
||||
}
|
||||
|
||||
int ChatModel::sessionCachedPromptTokens() const
|
||||
{
|
||||
int total = 0;
|
||||
for (const auto &m : m_messages)
|
||||
total += m.cachedPromptTokens;
|
||||
return total;
|
||||
}
|
||||
|
||||
int ChatModel::sessionTotalTokens() const
|
||||
{
|
||||
return sessionPromptTokens() + sessionCompletionTokens();
|
||||
}
|
||||
|
||||
void ChatModel::setLoadingFromHistory(bool loading)
|
||||
{
|
||||
m_loadingFromHistory = loading;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QtQmlIntegration>
|
||||
|
||||
#include "context/ContentFile.hpp"
|
||||
@@ -17,14 +18,28 @@ namespace QodeAssist::Chat {
|
||||
class ChatModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(int tokensThreshold READ tokensThreshold NOTIFY tokensThresholdChanged FINAL)
|
||||
Q_PROPERTY(int sessionPromptTokens READ sessionPromptTokens NOTIFY sessionUsageChanged FINAL)
|
||||
Q_PROPERTY(int sessionCompletionTokens READ sessionCompletionTokens NOTIFY sessionUsageChanged FINAL)
|
||||
Q_PROPERTY(int sessionCachedPromptTokens READ sessionCachedPromptTokens NOTIFY sessionUsageChanged FINAL)
|
||||
Q_PROPERTY(int sessionTotalTokens READ sessionTotalTokens NOTIFY sessionUsageChanged FINAL)
|
||||
QML_ELEMENT
|
||||
|
||||
public:
|
||||
enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking };
|
||||
Q_ENUM(ChatRole)
|
||||
|
||||
enum Roles { RoleType = Qt::UserRole, Content, Attachments, IsRedacted, Images };
|
||||
enum Roles {
|
||||
RoleType = Qt::UserRole,
|
||||
Content,
|
||||
Attachments,
|
||||
IsRedacted,
|
||||
Images,
|
||||
PromptTokens,
|
||||
CompletionTokens,
|
||||
CachedPromptTokens,
|
||||
ReasoningTokens,
|
||||
TotalTokens
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
struct ImageAttachment
|
||||
@@ -44,6 +59,15 @@ public:
|
||||
|
||||
QList<Context::ContentFile> attachments;
|
||||
QList<ImageAttachment> images;
|
||||
|
||||
QString toolName;
|
||||
QJsonObject toolArguments;
|
||||
QString toolResult;
|
||||
|
||||
int promptTokens = 0;
|
||||
int completionTokens = 0;
|
||||
int cachedPromptTokens = 0;
|
||||
int reasoningTokens = 0;
|
||||
};
|
||||
|
||||
explicit ChatModel(QObject *parent = nullptr);
|
||||
@@ -66,15 +90,23 @@ public:
|
||||
QVector<Message> getChatHistory() const;
|
||||
QJsonArray prepareMessagesForRequest(const QString &systemPrompt) const;
|
||||
|
||||
int tokensThreshold() const;
|
||||
|
||||
QString currentModel() const;
|
||||
QString lastMessageId() const;
|
||||
|
||||
Q_INVOKABLE void resetModelTo(int index);
|
||||
Q_INVOKABLE QVariantList userMessagePreviews(int maxLength = 80) const;
|
||||
|
||||
void addToolExecutionStatus(
|
||||
const QString &requestId, const QString &toolId, const QString &toolName);
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QJsonObject &toolArguments);
|
||||
void dropTrailingAssistantMessage(const QString &requestId);
|
||||
void setToolMessageData(
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QJsonObject &toolArguments,
|
||||
const QString &toolResult);
|
||||
void updateToolResult(
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
@@ -84,6 +116,18 @@ public:
|
||||
const QString &requestId, const QString &thinking, const QString &signature);
|
||||
void addRedactedThinkingBlock(const QString &requestId, const QString &signature);
|
||||
void updateMessageContent(const QString &messageId, const QString &newContent);
|
||||
|
||||
void setMessageUsage(
|
||||
const QString &messageId,
|
||||
int promptTokens,
|
||||
int completionTokens,
|
||||
int cachedPromptTokens,
|
||||
int reasoningTokens);
|
||||
|
||||
int sessionPromptTokens() const;
|
||||
int sessionCompletionTokens() const;
|
||||
int sessionCachedPromptTokens() const;
|
||||
int sessionTotalTokens() const;
|
||||
|
||||
void setLoadingFromHistory(bool loading);
|
||||
bool isLoadingFromHistory() const;
|
||||
@@ -92,8 +136,8 @@ public:
|
||||
QString chatFilePath() const;
|
||||
|
||||
signals:
|
||||
void tokensThresholdChanged();
|
||||
void modelReseted();
|
||||
void sessionUsageChanged();
|
||||
|
||||
private slots:
|
||||
void onFileEditApplied(const QString &editId);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QPointer>
|
||||
#include <QQuickItem>
|
||||
#include <QVariantList>
|
||||
|
||||
@@ -12,9 +13,19 @@
|
||||
#include "pluginllmcore/PromptProviderChat.hpp"
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatCompressor;
|
||||
class AgentRoleController;
|
||||
class ChatConfigurationController;
|
||||
class FileEditController;
|
||||
class InputTokenCounter;
|
||||
class ChatHistoryStore;
|
||||
class SessionFileRegistry;
|
||||
|
||||
class ChatRootView : public QQuickItem
|
||||
{
|
||||
@@ -52,11 +63,14 @@ class ChatRootView : public QQuickItem
|
||||
Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL)
|
||||
Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL)
|
||||
Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged FINAL)
|
||||
Q_PROPERTY(bool isInEditor READ isInEditor NOTIFY isInEditorChanged FINAL)
|
||||
Q_PROPERTY(QString chatTitle READ chatTitle NOTIFY chatTitleChanged FINAL)
|
||||
|
||||
QML_ELEMENT
|
||||
|
||||
public:
|
||||
ChatRootView(QQuickItem *parent = nullptr);
|
||||
~ChatRootView() override;
|
||||
|
||||
ChatModel *chatModel() const;
|
||||
QString currentTemplate() const;
|
||||
@@ -91,6 +105,11 @@ public:
|
||||
|
||||
Q_INVOKABLE void openFileInEditor(const QString &filePath);
|
||||
|
||||
Q_INVOKABLE void relocateToSplit();
|
||||
Q_INVOKABLE void relocateToWindow();
|
||||
|
||||
void consumePendingChatFile();
|
||||
|
||||
Q_INVOKABLE void updateInputTokensCount();
|
||||
int inputTokensCount() const;
|
||||
|
||||
@@ -122,6 +141,8 @@ public:
|
||||
Q_INVOKABLE QString getRuleContent(int index);
|
||||
Q_INVOKABLE void refreshRules();
|
||||
|
||||
Q_INVOKABLE QVariantList searchSkills(const QString &query) const;
|
||||
|
||||
bool useTools() const;
|
||||
void setUseTools(bool enabled);
|
||||
bool useThinking() const;
|
||||
@@ -164,6 +185,13 @@ public:
|
||||
|
||||
bool isCompressing() const;
|
||||
|
||||
bool isInEditor() const;
|
||||
void setInEditor(bool value);
|
||||
|
||||
QString chatTitle() const;
|
||||
|
||||
Q_INVOKABLE void requestNewChat();
|
||||
|
||||
public slots:
|
||||
void sendMessage(const QString &message);
|
||||
void copyToClipboard(const QString &text);
|
||||
@@ -209,15 +237,34 @@ signals:
|
||||
void compressionCompleted(const QString &compressedChatPath);
|
||||
void compressionFailed(const QString &error);
|
||||
|
||||
void isInEditorChanged();
|
||||
void chatTitleChanged();
|
||||
|
||||
void openFilesChanged();
|
||||
|
||||
void closeHostRequested();
|
||||
|
||||
private:
|
||||
void updateFileEditStatus(const QString &editId, const QString &status);
|
||||
QString getChatsHistoryDir() const;
|
||||
QString getSuggestedFileName() const;
|
||||
QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
|
||||
QString computeChatTitle() const;
|
||||
void triggerOpenChatCommand(Utils::Id commandId);
|
||||
void handOffSession();
|
||||
bool deferSendForAutoCompress(
|
||||
const QString &message,
|
||||
const QStringList &attachments,
|
||||
const QStringList &linkedFiles,
|
||||
bool useTools,
|
||||
bool useThinking);
|
||||
void dispatchSend(
|
||||
const QString &message,
|
||||
const QStringList &attachments,
|
||||
const QStringList &linkedFiles,
|
||||
bool useTools,
|
||||
bool useThinking);
|
||||
bool hasImageAttachments(const QStringList &attachments) const;
|
||||
|
||||
SessionFileRegistry *sessionFileRegistry() const;
|
||||
Skills::SkillsManager *skillsManager() const;
|
||||
|
||||
ChatModel *m_chatModel;
|
||||
PluginLLMCore::PromptProviderChat m_promptProvider;
|
||||
ClientInterface *m_clientInterface;
|
||||
@@ -226,28 +273,36 @@ private:
|
||||
QString m_recentFilePath;
|
||||
QStringList m_attachmentFiles;
|
||||
QStringList m_linkedFiles;
|
||||
int m_messageTokensCount{0};
|
||||
int m_inputTokensCount{0};
|
||||
|
||||
struct PendingSend {
|
||||
QString message;
|
||||
QStringList attachments;
|
||||
QStringList linkedFiles;
|
||||
bool useTools = false;
|
||||
bool useThinking = false;
|
||||
bool active = false;
|
||||
};
|
||||
PendingSend m_pendingSend;
|
||||
bool m_isSyncOpenFiles;
|
||||
bool m_isInEditor = false;
|
||||
mutable QString m_cachedChatTitle;
|
||||
QList<Core::IEditor *> m_currentEditors;
|
||||
bool m_isRequestInProgress;
|
||||
QString m_lastErrorMessage;
|
||||
QVariantList m_activeRules;
|
||||
|
||||
QString m_currentMessageRequestId;
|
||||
int m_currentMessageTotalEdits{0};
|
||||
int m_currentMessageAppliedEdits{0};
|
||||
int m_currentMessagePendingEdits{0};
|
||||
int m_currentMessageRejectedEdits{0};
|
||||
QString m_lastInfoMessage;
|
||||
|
||||
QStringList m_availableConfigurations;
|
||||
QString m_currentConfiguration;
|
||||
|
||||
QStringList m_availableAgentRoles;
|
||||
QString m_currentAgentRole;
|
||||
|
||||
ChatCompressor *m_chatCompressor;
|
||||
AgentRoleController *m_agentRoleController;
|
||||
ChatConfigurationController *m_configurationController;
|
||||
FileEditController *m_fileEditController;
|
||||
InputTokenCounter *m_tokenCounter;
|
||||
ChatHistoryStore *m_historyStore;
|
||||
mutable QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
||||
mutable bool m_sessionFileRegistryResolved = false;
|
||||
mutable QPointer<Skills::SkillsManager> m_skillsManager;
|
||||
mutable bool m_skillsManagerResolved = false;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -80,6 +80,15 @@ QJsonObject ChatSerializer::serializeMessage(
|
||||
messageObj["signature"] = message.signature;
|
||||
}
|
||||
|
||||
if (message.role == ChatModel::ChatRole::Tool) {
|
||||
if (!message.toolName.isEmpty())
|
||||
messageObj["toolName"] = message.toolName;
|
||||
if (!message.toolArguments.isEmpty())
|
||||
messageObj["toolArguments"] = message.toolArguments;
|
||||
if (!message.toolResult.isEmpty())
|
||||
messageObj["toolResult"] = message.toolResult;
|
||||
}
|
||||
|
||||
if (!message.attachments.isEmpty()) {
|
||||
QJsonArray attachmentsArray;
|
||||
for (const auto &attachment : message.attachments) {
|
||||
@@ -103,6 +112,17 @@ QJsonObject ChatSerializer::serializeMessage(
|
||||
messageObj["images"] = imagesArray;
|
||||
}
|
||||
|
||||
if (message.promptTokens > 0 || message.completionTokens > 0) {
|
||||
QJsonObject usageObj;
|
||||
usageObj["promptTokens"] = message.promptTokens;
|
||||
usageObj["completionTokens"] = message.completionTokens;
|
||||
if (message.cachedPromptTokens > 0)
|
||||
usageObj["cachedPromptTokens"] = message.cachedPromptTokens;
|
||||
if (message.reasoningTokens > 0)
|
||||
usageObj["reasoningTokens"] = message.reasoningTokens;
|
||||
messageObj["usage"] = usageObj;
|
||||
}
|
||||
|
||||
return messageObj;
|
||||
}
|
||||
|
||||
@@ -115,6 +135,9 @@ ChatModel::Message ChatSerializer::deserializeMessage(
|
||||
message.id = json["id"].toString();
|
||||
message.isRedacted = json["isRedacted"].toBool(false);
|
||||
message.signature = json["signature"].toString();
|
||||
message.toolName = json["toolName"].toString();
|
||||
message.toolArguments = json["toolArguments"].toObject();
|
||||
message.toolResult = json["toolResult"].toString();
|
||||
|
||||
if (json.contains("attachments")) {
|
||||
QJsonArray attachmentsArray = json["attachments"].toArray();
|
||||
@@ -139,6 +162,14 @@ ChatModel::Message ChatSerializer::deserializeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
if (json.contains("usage")) {
|
||||
const QJsonObject usageObj = json["usage"].toObject();
|
||||
message.promptTokens = usageObj["promptTokens"].toInt();
|
||||
message.completionTokens = usageObj["completionTokens"].toInt();
|
||||
message.cachedPromptTokens = usageObj["cachedPromptTokens"].toInt();
|
||||
message.reasoningTokens = usageObj["reasoningTokens"].toInt();
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -180,6 +211,10 @@ bool ChatSerializer::deserializeChat(
|
||||
message.images,
|
||||
message.isRedacted,
|
||||
message.signature);
|
||||
if (message.role == ChatModel::ChatRole::Tool) {
|
||||
model->setToolMessageData(
|
||||
message.id, message.toolName, message.toolArguments, message.toolResult);
|
||||
}
|
||||
LOG_MESSAGE(QString("Loaded message with %1 image(s), isRedacted=%2, signature length=%3")
|
||||
.arg(message.images.size())
|
||||
.arg(message.isRedacted)
|
||||
|
||||
@@ -19,22 +19,34 @@ QString ChatUtils::getSafeMarkdownText(const QString &text) const
|
||||
return text;
|
||||
}
|
||||
|
||||
bool needsSanitization = false;
|
||||
for (const QChar &ch : text) {
|
||||
if (ch.isNull() || (!ch.isPrint() && ch != '\n' && ch != '\t' && ch != '\r' && ch != ' ')) {
|
||||
needsSanitization = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsSanitization) {
|
||||
return text;
|
||||
}
|
||||
|
||||
QString safeText;
|
||||
safeText.reserve(text.size());
|
||||
safeText.reserve(text.size() + 16);
|
||||
|
||||
bool inFenced = false;
|
||||
bool inInline = false;
|
||||
|
||||
for (int i = 0; i < text.size(); ++i) {
|
||||
const QChar ch = text[i];
|
||||
|
||||
if (!inInline && i + 2 < text.size()
|
||||
&& text[i] == '`' && text[i + 1] == '`' && text[i + 2] == '`') {
|
||||
safeText.append(QStringLiteral("```"));
|
||||
inFenced = !inFenced;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inFenced && ch == '`') {
|
||||
safeText.append(ch);
|
||||
inInline = !inInline;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inFenced && !inInline && ch == '<') {
|
||||
safeText.append(QStringLiteral("<"));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (QChar ch : text) {
|
||||
if (ch.isNull()) {
|
||||
safeText.append(' ');
|
||||
} else if (ch == '\n' || ch == '\t' || ch == '\r' || ch == ' ') {
|
||||
|
||||
@@ -3,14 +3,22 @@
|
||||
|
||||
#include "ChatView.hpp"
|
||||
|
||||
#include <QQmlComponent>
|
||||
#include <QQmlContext>
|
||||
#include <QQmlEngine>
|
||||
#include <QQuickItem>
|
||||
#include <QSettings>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <coreplugin/actionmanager/actionmanager.h>
|
||||
#include <coreplugin/actionmanager/command.h>
|
||||
#include <logger/Logger.hpp>
|
||||
|
||||
#include "ChatRootView.hpp"
|
||||
#include "QodeAssistConstants.hpp"
|
||||
#include "SessionFileRegistry.hpp"
|
||||
#include "sources/skills/SkillsManager.hpp"
|
||||
|
||||
namespace {
|
||||
constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint
|
||||
| Qt::WindowMinimizeButtonHint | Qt::WindowMaximizeButtonHint
|
||||
@@ -19,30 +27,65 @@ constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::Win
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatView::ChatView()
|
||||
: m_isPin(false)
|
||||
ChatView::ChatView(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager)
|
||||
: QQuickView{engine, nullptr}
|
||||
, m_isPin(false)
|
||||
{
|
||||
setTitle("QodeAssist Chat");
|
||||
engine()->rootContext()->setContextProperty("_chatview", this);
|
||||
setSource(QUrl("qrc:/qt/qml/ChatView/qml/RootItem.qml"));
|
||||
/// @note setup quick view content
|
||||
{
|
||||
auto context = new QQmlContext{engine, this};
|
||||
context->setContextProperty("_chatview", this);
|
||||
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
|
||||
context->setContextProperty("skillsManager", skillsManager);
|
||||
|
||||
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
||||
auto rootItem = component->create(context);
|
||||
|
||||
setContent(component->url(), component, rootItem);
|
||||
}
|
||||
|
||||
if (auto rootView = qobject_cast<ChatRootView *>(rootObject())) {
|
||||
connect(
|
||||
rootView,
|
||||
&ChatRootView::closeHostRequested,
|
||||
this,
|
||||
&QWindow::close,
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
setResizeMode(QQuickView::SizeRootObjectToView);
|
||||
setMinimumSize({400, 300});
|
||||
setFlags(baseFlags);
|
||||
|
||||
if (auto action = Core::ActionManager::command("QodeAssist.CloseChatView")) {
|
||||
m_closeShortcut = new QShortcut(action->keySequence(), this);
|
||||
connect(m_closeShortcut, &QShortcut::activated, this, &QQuickView::close);
|
||||
|
||||
connect(action, &Core::Command::keySequenceChanged, this, [action, this]() {
|
||||
if (m_closeShortcut) {
|
||||
m_closeShortcut->setKey(action->keySequence());
|
||||
}
|
||||
});
|
||||
}
|
||||
bindCommandShortcut("QodeAssist.CloseChatView", [this] { close(); });
|
||||
bindCommandShortcut(Constants::QODE_ASSIST_CHAT_SEND_MESSAGE, [this] {
|
||||
QMetaObject::invokeMethod(rootObject(), "sendChatMessage");
|
||||
});
|
||||
bindCommandShortcut(Constants::QODE_ASSIST_CHAT_CLEAR_SESSION, [this] {
|
||||
QMetaObject::invokeMethod(rootObject(), "clearChat");
|
||||
});
|
||||
|
||||
restoreSettings();
|
||||
}
|
||||
|
||||
void ChatView::bindCommandShortcut(Utils::Id commandId,
|
||||
const std::function<void()> &onActivated)
|
||||
{
|
||||
auto command = Core::ActionManager::command(commandId);
|
||||
if (!command)
|
||||
return;
|
||||
|
||||
auto shortcut = new QShortcut(command->keySequence(), this);
|
||||
connect(shortcut, &QShortcut::activated, this, onActivated);
|
||||
connect(command, &Core::Command::keySequenceChanged, shortcut, [command, shortcut]() {
|
||||
shortcut->setKey(command->keySequence());
|
||||
});
|
||||
}
|
||||
|
||||
void ChatView::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
saveSettings();
|
||||
|
||||
@@ -3,17 +3,30 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include <utils/id.h>
|
||||
|
||||
#include <QQuickView>
|
||||
#include <QShortcut>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class SessionFileRegistry;
|
||||
|
||||
class ChatView : public QQuickView
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(bool isPin READ isPin WRITE setIsPin NOTIFY isPinChanged FINAL)
|
||||
public:
|
||||
ChatView();
|
||||
ChatView(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager);
|
||||
|
||||
bool isPin() const;
|
||||
void setIsPin(bool newIsPin);
|
||||
@@ -27,9 +40,9 @@ protected:
|
||||
private:
|
||||
void saveSettings();
|
||||
void restoreSettings();
|
||||
void bindCommandShortcut(Utils::Id commandId, const std::function<void()> &onActivated);
|
||||
|
||||
bool m_isPin;
|
||||
QShortcut *m_closeShortcut;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -3,16 +3,65 @@
|
||||
|
||||
#include "ChatWidget.hpp"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QMouseEvent>
|
||||
#include <QQmlContext>
|
||||
#include <QQmlEngine>
|
||||
#include <QQuickItem>
|
||||
|
||||
#include <coreplugin/icontext.h>
|
||||
#include <coreplugin/icore.h>
|
||||
|
||||
#include "QodeAssistConstants.hpp"
|
||||
#include "SessionFileRegistry.hpp"
|
||||
#include "sources/skills/SkillsManager.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatWidget::ChatWidget(QWidget *parent)
|
||||
: QQuickWidget(parent)
|
||||
ChatWidget::ChatWidget(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager,
|
||||
bool registerOwnContext,
|
||||
QWidget *parent)
|
||||
: QQuickWidget{engine, parent}
|
||||
{
|
||||
setSource(QUrl("qrc:/qt/qml/ChatView/qml/RootItem.qml"));
|
||||
/// @note setup quick view content
|
||||
{
|
||||
auto context = new QQmlContext{engine, this};
|
||||
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
|
||||
context->setContextProperty("skillsManager", skillsManager);
|
||||
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
||||
auto rootItem = component->create(context);
|
||||
|
||||
setContent(component->url(), component, rootItem);
|
||||
}
|
||||
setResizeMode(QQuickWidget::SizeRootObjectToView);
|
||||
setFocusPolicy(Qt::StrongFocus);
|
||||
|
||||
setAttribute(Qt::WA_NoMousePropagation, true);
|
||||
|
||||
if (registerOwnContext) {
|
||||
auto ideContext = new Core::IContext{this};
|
||||
ideContext->setWidget(this);
|
||||
ideContext->setContext(Core::Context{Constants::QODE_ASSIST_CHAT_CONTEXT});
|
||||
Core::ICore::addContextObject(ideContext);
|
||||
}
|
||||
}
|
||||
|
||||
void ChatWidget::focusInEvent(QFocusEvent *event)
|
||||
{
|
||||
QQuickWidget::focusInEvent(event);
|
||||
if (rootObject())
|
||||
QMetaObject::invokeMethod(rootObject(), "focusInput");
|
||||
}
|
||||
|
||||
void ChatWidget::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (!hasFocus())
|
||||
setFocus(Qt::MouseFocusReason);
|
||||
|
||||
QQuickWidget::mousePressEvent(event);
|
||||
}
|
||||
|
||||
void ChatWidget::clear()
|
||||
@@ -24,4 +73,35 @@ void ChatWidget::scrollToBottom()
|
||||
{
|
||||
QMetaObject::invokeMethod(rootObject(), "scrollToBottom");
|
||||
}
|
||||
|
||||
void ChatWidget::focusInput()
|
||||
{
|
||||
setFocus(Qt::OtherFocusReason);
|
||||
QMetaObject::invokeMethod(rootObject(), "focusInput");
|
||||
}
|
||||
|
||||
bool ChatWidget::isChatFocused() const
|
||||
{
|
||||
return hasFocus() || (rootObject() && rootObject()->hasActiveFocus());
|
||||
}
|
||||
|
||||
void ChatWidget::sendMessage()
|
||||
{
|
||||
QMetaObject::invokeMethod(rootObject(), "sendChatMessage");
|
||||
}
|
||||
|
||||
void ChatWidget::clearSession()
|
||||
{
|
||||
QMetaObject::invokeMethod(rootObject(), "clearChat");
|
||||
}
|
||||
|
||||
ChatWidget *ChatWidget::focusedInstance()
|
||||
{
|
||||
for (QWidget *widget = QApplication::focusWidget(); widget;
|
||||
widget = widget->parentWidget()) {
|
||||
if (auto chatWidget = qobject_cast<ChatWidget *>(widget))
|
||||
return chatWidget;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -5,21 +5,44 @@
|
||||
|
||||
#include <QtQuickWidgets/QtQuickWidgets>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class SessionFileRegistry;
|
||||
|
||||
class ChatWidget : public QQuickWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatWidget(QWidget *parent = nullptr);
|
||||
explicit ChatWidget(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager,
|
||||
bool registerOwnContext = true,
|
||||
QWidget *parent = nullptr);
|
||||
~ChatWidget() = default;
|
||||
|
||||
Q_INVOKABLE void clear();
|
||||
Q_INVOKABLE void scrollToBottom();
|
||||
Q_INVOKABLE void focusInput();
|
||||
|
||||
void sendMessage();
|
||||
void clearSession();
|
||||
|
||||
bool isChatFocused() const;
|
||||
|
||||
static ChatWidget *focusedInstance();
|
||||
|
||||
signals:
|
||||
void clearPressed();
|
||||
|
||||
protected:
|
||||
void focusInEvent(QFocusEvent *event) override;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QMimeDatabase>
|
||||
#include <QRegularExpression>
|
||||
#include <QUuid>
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
@@ -28,27 +29,36 @@
|
||||
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
|
||||
#include "tools/ReadOriginalHistoryTool.hpp"
|
||||
#include "tools/TodoTool.hpp"
|
||||
|
||||
#include "ChatAssistantSettings.hpp"
|
||||
#include "ChatSerializer.hpp"
|
||||
#include "GeneralSettings.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include "ProjectSettings.hpp"
|
||||
#include "ProvidersManager.hpp"
|
||||
#include "SkillsSettings.hpp"
|
||||
#include "ToolsSettings.hpp"
|
||||
#include <RulesLoader.hpp>
|
||||
#include <context/ChangesManager.h>
|
||||
#include <sources/skills/SkillsManager.hpp>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ClientInterface::ClientInterface(
|
||||
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_chatModel(chatModel)
|
||||
, m_promptProvider(promptProvider)
|
||||
, m_chatModel(chatModel)
|
||||
, m_contextManager(new Context::ContextManager(this))
|
||||
{}
|
||||
|
||||
void ClientInterface::setSkillsManager(Skills::SkillsManager *skillsManager)
|
||||
{
|
||||
m_skillsManager = skillsManager;
|
||||
}
|
||||
|
||||
ClientInterface::~ClientInterface()
|
||||
{
|
||||
cancelRequest();
|
||||
@@ -61,6 +71,11 @@ void ClientInterface::sendMessage(
|
||||
bool useTools,
|
||||
bool useThinking)
|
||||
{
|
||||
if (message.trimmed().isEmpty() && attachments.isEmpty()) {
|
||||
LOG_MESSAGE("Ignoring empty chat message");
|
||||
return;
|
||||
}
|
||||
|
||||
cancelRequest();
|
||||
m_accumulatedResponses.clear();
|
||||
|
||||
@@ -159,14 +174,21 @@ void ClientInterface::sendMessage(
|
||||
auto project = PluginLLMCore::RulesLoader::getActiveProject();
|
||||
|
||||
if (project) {
|
||||
systemPrompt += QString("\n# Active project name: %1").arg(project->displayName());
|
||||
systemPrompt += QString("\n# Active Project path: %1")
|
||||
systemPrompt += QString("\n# Active project: %1").arg(project->displayName());
|
||||
systemPrompt += QString(
|
||||
"\n# Project source root: %1"
|
||||
"\n# All new source files, headers, QML and CMake edits MUST be "
|
||||
"created or modified under this directory. Use absolute paths "
|
||||
"rooted here, or project-relative paths.")
|
||||
.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());
|
||||
systemPrompt
|
||||
+= QString(
|
||||
"\n# Build output directory (compiler artifacts only — do NOT "
|
||||
"create or edit source files here): %1")
|
||||
.arg(buildConfig->buildDirectory().toUrlishString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,15 +202,85 @@ void ClientInterface::sendMessage(
|
||||
systemPrompt += QString("\n# No active project in IDE");
|
||||
}
|
||||
|
||||
if (m_skillsManager && Settings::skillsSettings().enableSkills()) {
|
||||
QStringList projectSkillDirs;
|
||||
if (project) {
|
||||
Settings::ProjectSettings projectSettings(project);
|
||||
projectSkillDirs = Settings::SkillsSettings::splitLines(
|
||||
projectSettings.projectSkillDirs());
|
||||
}
|
||||
m_skillsManager->configure(
|
||||
project ? project->projectDirectory().toFSPathString() : QString(),
|
||||
Settings::SkillsSettings::splitPaths(
|
||||
Settings::skillsSettings().globalSkillRoots()),
|
||||
projectSkillDirs);
|
||||
|
||||
const QString alwaysOnSkills = m_skillsManager->alwaysOnBodies();
|
||||
if (!alwaysOnSkills.isEmpty())
|
||||
systemPrompt += QString("\n\n") + alwaysOnSkills;
|
||||
|
||||
const QString skillsCatalog = m_skillsManager->catalogText();
|
||||
if (!skillsCatalog.isEmpty())
|
||||
systemPrompt += QString("\n\n") + skillsCatalog;
|
||||
|
||||
static const QRegularExpression skillCommand(
|
||||
QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)"));
|
||||
QStringList invokedSkillNames;
|
||||
auto skillMatch = skillCommand.globalMatch(message);
|
||||
while (skillMatch.hasNext()) {
|
||||
const QString skillName = skillMatch.next().captured(1);
|
||||
if (invokedSkillNames.contains(skillName))
|
||||
continue;
|
||||
const auto invokedSkill = m_skillsManager->findByName(skillName);
|
||||
if (invokedSkill && !invokedSkill->body.isEmpty()) {
|
||||
invokedSkillNames << skillName;
|
||||
systemPrompt += QString("\n\n# Invoked Skill: %1\n\n%2")
|
||||
.arg(invokedSkill->name, invokedSkill->body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!linkedFiles.isEmpty()) {
|
||||
systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles);
|
||||
}
|
||||
context.systemPrompt = systemPrompt;
|
||||
}
|
||||
|
||||
const bool toolHistory = promptTemplate->supportsToolHistory();
|
||||
|
||||
QVector<PluginLLMCore::Message> messages;
|
||||
int toolCallMsgIdx = -1;
|
||||
for (const auto &msg : m_chatModel->getChatHistory()) {
|
||||
if (msg.role == ChatModel::ChatRole::Tool || msg.role == ChatModel::ChatRole::FileEdit) {
|
||||
if (msg.role == ChatModel::ChatRole::Tool) {
|
||||
if (!toolHistory || msg.toolName.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toolCallMsgIdx < 0) {
|
||||
PluginLLMCore::Message assistantCall;
|
||||
assistantCall.role = "assistant";
|
||||
messages.append(assistantCall);
|
||||
toolCallMsgIdx = messages.size() - 1;
|
||||
}
|
||||
|
||||
PluginLLMCore::ToolCall call;
|
||||
call.id = msg.id;
|
||||
call.name = msg.toolName;
|
||||
call.arguments = msg.toolArguments;
|
||||
messages[toolCallMsgIdx].toolCalls.append(call);
|
||||
|
||||
PluginLLMCore::Message toolResult;
|
||||
toolResult.role = "tool";
|
||||
toolResult.toolCallId = msg.id;
|
||||
toolResult.toolName = msg.toolName;
|
||||
toolResult.content = msg.toolResult;
|
||||
messages.append(toolResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
toolCallMsgIdx = -1;
|
||||
|
||||
if (msg.role == ChatModel::ChatRole::FileEdit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -257,6 +349,12 @@ void ClientInterface::sendMessage(
|
||||
this,
|
||||
&ClientInterface::handleFullResponse,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider->client(),
|
||||
&::LLMQore::BaseClient::requestFinalized,
|
||||
this,
|
||||
&ClientInterface::handleRequestFinalized,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider->client(),
|
||||
&::LLMQore::BaseClient::requestFailed,
|
||||
@@ -289,7 +387,7 @@ void ClientInterface::sendMessage(
|
||||
= provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint);
|
||||
QJsonObject request{{"id", requestId}};
|
||||
|
||||
m_activeRequests[requestId] = {request, provider};
|
||||
m_activeRequests[requestId] = {request, provider, !toolHistory};
|
||||
|
||||
emit requestStarted(requestId);
|
||||
|
||||
@@ -299,6 +397,10 @@ void ClientInterface::sendMessage(
|
||||
provider->toolsManager()->tool("todo_tool"))) {
|
||||
todoTool->setCurrentSessionId(m_chatFilePath);
|
||||
}
|
||||
if (auto *historyTool = qobject_cast<QodeAssist::Tools::ReadOriginalHistoryTool *>(
|
||||
provider->toolsManager()->tool("read_original_history"))) {
|
||||
historyTool->setCurrentSessionId(m_chatFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +551,29 @@ void ClientInterface::handleFullResponse(const QString &requestId, const QString
|
||||
m_awaitingContinuation.remove(requestId);
|
||||
}
|
||||
|
||||
void ClientInterface::handleRequestFinalized(
|
||||
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
|
||||
{
|
||||
if (!m_activeRequests.contains(requestId))
|
||||
return;
|
||||
if (!info.usage)
|
||||
return;
|
||||
|
||||
const auto &u = *info.usage;
|
||||
m_chatModel->setMessageUsage(
|
||||
requestId, u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens);
|
||||
|
||||
emit messageUsageReceived(
|
||||
u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens);
|
||||
|
||||
LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
|
||||
.arg(requestId)
|
||||
.arg(u.promptTokens)
|
||||
.arg(u.completionTokens)
|
||||
.arg(u.cachedPromptTokens)
|
||||
.arg(u.reasoningTokens));
|
||||
}
|
||||
|
||||
void ClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
|
||||
{
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
@@ -485,14 +610,21 @@ void ClientInterface::handleThinkingBlockReceived(
|
||||
}
|
||||
|
||||
void ClientInterface::handleToolExecutionStarted(
|
||||
const QString &requestId, const QString &toolId, const QString &toolName)
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QJsonObject &arguments)
|
||||
{
|
||||
if (!m_activeRequests.contains(requestId)) {
|
||||
const auto requestIt = m_activeRequests.constFind(requestId);
|
||||
if (requestIt == m_activeRequests.constEnd()) {
|
||||
LOG_MESSAGE(QString("Ignoring tool execution start for non-chat request: %1").arg(requestId));
|
||||
return;
|
||||
}
|
||||
|
||||
m_chatModel->addToolExecutionStatus(requestId, toolId, toolName);
|
||||
if (requestIt->dropPreToolText) {
|
||||
m_chatModel->dropTrailingAssistantMessage(requestId);
|
||||
}
|
||||
m_chatModel->addToolExecutionStatus(requestId, toolId, toolName, arguments);
|
||||
m_awaitingContinuation.insert(requestId);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,13 @@
|
||||
#include "ChatModel.hpp"
|
||||
#include "Provider.hpp"
|
||||
#include "pluginllmcore/IPromptProvider.hpp"
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <context/ContextManager.hpp>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ClientInterface : public QObject
|
||||
@@ -24,6 +29,8 @@ public:
|
||||
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent = nullptr);
|
||||
~ClientInterface();
|
||||
|
||||
void setSkillsManager(Skills::SkillsManager *skillsManager);
|
||||
|
||||
void sendMessage(
|
||||
const QString &message,
|
||||
const QList<QString> &attachments = {},
|
||||
@@ -42,15 +49,21 @@ signals:
|
||||
void errorOccurred(const QString &error);
|
||||
void messageReceivedCompletely();
|
||||
void requestStarted(const QString &requestId);
|
||||
void messageUsageReceived(
|
||||
int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens);
|
||||
|
||||
private slots:
|
||||
void handlePartialResponse(const QString &requestId, const QString &partialText);
|
||||
void handleFullResponse(const QString &requestId, const QString &fullText);
|
||||
void handleRequestFinalized(const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info);
|
||||
void handleRequestFailed(const QString &requestId, const QString &error);
|
||||
void handleThinkingBlockReceived(
|
||||
const QString &requestId, const QString &thinking, const QString &signature);
|
||||
void handleToolExecutionStarted(
|
||||
const QString &requestId, const QString &toolId, const QString &toolName);
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
const QString &toolName,
|
||||
const QJsonObject &arguments);
|
||||
void handleToolExecutionCompleted(
|
||||
const QString &requestId,
|
||||
const QString &toolId,
|
||||
@@ -71,11 +84,13 @@ private:
|
||||
{
|
||||
QJsonObject originalRequest;
|
||||
PluginLLMCore::Provider *provider;
|
||||
bool dropPreToolText = false;
|
||||
};
|
||||
|
||||
PluginLLMCore::IPromptProvider *m_promptProvider = nullptr;
|
||||
ChatModel *m_chatModel;
|
||||
Context::ContextManager *m_contextManager;
|
||||
Skills::SkillsManager *m_skillsManager = nullptr;
|
||||
QString m_chatFilePath;
|
||||
|
||||
QHash<QString, RequestContext> m_activeRequests;
|
||||
|
||||
334
ChatView/FileEditController.cpp
Normal file
334
ChatView/FileEditController.cpp
Normal file
@@ -0,0 +1,334 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "FileEditController.hpp"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <texteditor/texteditor.h>
|
||||
|
||||
#include "ChatModel.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include "context/ChangesManager.h"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
FileEditController::FileEditController(ChatModel *chatModel, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_chatModel(chatModel)
|
||||
{
|
||||
auto &changes = Context::ChangesManager::instance();
|
||||
connect(&changes, &Context::ChangesManager::fileEditAdded, this, [this](const QString &) {
|
||||
updateStats();
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &) {
|
||||
updateStats();
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &) {
|
||||
updateStats();
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditUndone, this, [this](const QString &) {
|
||||
updateStats();
|
||||
});
|
||||
connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &) {
|
||||
updateStats();
|
||||
});
|
||||
}
|
||||
|
||||
void FileEditController::setCurrentRequestId(const QString &requestId)
|
||||
{
|
||||
if (!m_currentRequestId.isEmpty()) {
|
||||
LOG_MESSAGE(QString("Clearing previous message requestId: %1").arg(m_currentRequestId));
|
||||
}
|
||||
|
||||
m_currentRequestId = requestId;
|
||||
LOG_MESSAGE(QString("New message request started: %1").arg(requestId));
|
||||
updateStats();
|
||||
}
|
||||
|
||||
void FileEditController::clearCurrentRequestId()
|
||||
{
|
||||
m_currentRequestId.clear();
|
||||
updateStats();
|
||||
}
|
||||
|
||||
int FileEditController::totalEdits() const
|
||||
{
|
||||
return m_totalEdits;
|
||||
}
|
||||
|
||||
int FileEditController::appliedEdits() const
|
||||
{
|
||||
return m_appliedEdits;
|
||||
}
|
||||
|
||||
int FileEditController::pendingEdits() const
|
||||
{
|
||||
return m_pendingEdits;
|
||||
}
|
||||
|
||||
int FileEditController::rejectedEdits() const
|
||||
{
|
||||
return m_rejectedEdits;
|
||||
}
|
||||
|
||||
void FileEditController::applyFileEdit(const QString &editId)
|
||||
{
|
||||
LOG_MESSAGE(QString("Applying file edit: %1").arg(editId));
|
||||
if (Context::ChangesManager::instance().applyFileEdit(editId)) {
|
||||
emit infoMessage(QString("File edit applied successfully"));
|
||||
updateFileEditStatus(editId, "applied");
|
||||
} else {
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
emit errorOccurred(
|
||||
edit.statusMessage.isEmpty()
|
||||
? QString("Failed to apply file edit")
|
||||
: QString("Failed to apply file edit: %1").arg(edit.statusMessage));
|
||||
}
|
||||
}
|
||||
|
||||
void FileEditController::rejectFileEdit(const QString &editId)
|
||||
{
|
||||
LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId));
|
||||
if (Context::ChangesManager::instance().rejectFileEdit(editId)) {
|
||||
emit infoMessage(QString("File edit rejected"));
|
||||
updateFileEditStatus(editId, "rejected");
|
||||
} else {
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
emit errorOccurred(
|
||||
edit.statusMessage.isEmpty()
|
||||
? QString("Failed to reject file edit")
|
||||
: QString("Failed to reject file edit: %1").arg(edit.statusMessage));
|
||||
}
|
||||
}
|
||||
|
||||
void FileEditController::undoFileEdit(const QString &editId)
|
||||
{
|
||||
LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId));
|
||||
if (Context::ChangesManager::instance().undoFileEdit(editId)) {
|
||||
emit infoMessage(QString("File edit undone successfully"));
|
||||
updateFileEditStatus(editId, "rejected");
|
||||
} else {
|
||||
auto edit = Context::ChangesManager::instance().getFileEdit(editId);
|
||||
emit errorOccurred(
|
||||
edit.statusMessage.isEmpty()
|
||||
? QString("Failed to undo file edit")
|
||||
: QString("Failed to undo file edit: %1").arg(edit.statusMessage));
|
||||
}
|
||||
}
|
||||
|
||||
void FileEditController::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()) {
|
||||
emit errorOccurred(QString("File edit not found: %1").arg(editId));
|
||||
return;
|
||||
}
|
||||
|
||||
Utils::FilePath filePath = Utils::FilePath::fromString(edit.filePath);
|
||||
|
||||
Core::IEditor *editor = Core::EditorManager::openEditor(filePath);
|
||||
if (!editor) {
|
||||
emit errorOccurred(QString("Failed to open file in editor: %1").arg(edit.filePath));
|
||||
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()) {
|
||||
position = currentContent.indexOf(edit.oldContent);
|
||||
}
|
||||
|
||||
if (position >= 0) {
|
||||
QTextCursor cursor(doc);
|
||||
cursor.setPosition(position);
|
||||
textEditor->editorWidget()->setTextCursor(cursor);
|
||||
textEditor->editorWidget()->centerCursor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOG_MESSAGE(QString("Opened file in editor: %1").arg(edit.filePath));
|
||||
}
|
||||
|
||||
void FileEditController::updateFileEditStatus(const QString &editId, const QString &status)
|
||||
{
|
||||
auto messages = m_chatModel->getChatHistory();
|
||||
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));
|
||||
m_chatModel->updateMessageContent(editId, updatedContent);
|
||||
LOG_MESSAGE(QString("Updated file edit status to: %1").arg(status));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateStats();
|
||||
}
|
||||
|
||||
void FileEditController::applyAllForCurrentMessage()
|
||||
{
|
||||
if (m_currentRequestId.isEmpty()) {
|
||||
emit errorOccurred(QString("No active message with file edits"));
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_MESSAGE(QString("Applying all file edits for message: %1").arg(m_currentRequestId));
|
||||
|
||||
QString errorMsg;
|
||||
bool success = Context::ChangesManager::instance()
|
||||
.reapplyAllEditsForRequest(m_currentRequestId, &errorMsg);
|
||||
|
||||
if (success) {
|
||||
emit infoMessage(QString("All file edits applied successfully"));
|
||||
} else {
|
||||
emit errorOccurred(
|
||||
errorMsg.isEmpty()
|
||||
? QString("Failed to apply some file edits")
|
||||
: QString("Failed to apply some file edits:\n%1").arg(errorMsg));
|
||||
}
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
|
||||
for (const auto &edit : edits) {
|
||||
if (edit.status == Context::ChangesManager::Applied) {
|
||||
updateFileEditStatus(edit.editId, "applied");
|
||||
}
|
||||
}
|
||||
|
||||
updateStats();
|
||||
}
|
||||
|
||||
void FileEditController::undoAllForCurrentMessage()
|
||||
{
|
||||
if (m_currentRequestId.isEmpty()) {
|
||||
emit errorOccurred(QString("No active message with file edits"));
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_MESSAGE(QString("Undoing all file edits for message: %1").arg(m_currentRequestId));
|
||||
|
||||
QString errorMsg;
|
||||
bool success = Context::ChangesManager::instance()
|
||||
.undoAllEditsForRequest(m_currentRequestId, &errorMsg);
|
||||
|
||||
if (success) {
|
||||
emit infoMessage(QString("All file edits undone successfully"));
|
||||
} else {
|
||||
emit errorOccurred(
|
||||
errorMsg.isEmpty()
|
||||
? QString("Failed to undo some file edits")
|
||||
: QString("Failed to undo some file edits:\n%1").arg(errorMsg));
|
||||
}
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
|
||||
for (const auto &edit : edits) {
|
||||
if (edit.status == Context::ChangesManager::Rejected) {
|
||||
updateFileEditStatus(edit.editId, "rejected");
|
||||
}
|
||||
}
|
||||
|
||||
updateStats();
|
||||
}
|
||||
|
||||
void FileEditController::updateStats()
|
||||
{
|
||||
if (m_currentRequestId.isEmpty()) {
|
||||
if (m_totalEdits != 0 || m_appliedEdits != 0 || m_pendingEdits != 0
|
||||
|| m_rejectedEdits != 0) {
|
||||
m_totalEdits = 0;
|
||||
m_appliedEdits = 0;
|
||||
m_pendingEdits = 0;
|
||||
m_rejectedEdits = 0;
|
||||
emit statsChanged();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
|
||||
|
||||
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:
|
||||
applied++;
|
||||
break;
|
||||
case Context::ChangesManager::Pending:
|
||||
pending++;
|
||||
break;
|
||||
case Context::ChangesManager::Rejected:
|
||||
rejected++;
|
||||
break;
|
||||
case Context::ChangesManager::Archived:
|
||||
total--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
if (m_totalEdits != total) {
|
||||
m_totalEdits = total;
|
||||
changed = true;
|
||||
}
|
||||
if (m_appliedEdits != applied) {
|
||||
m_appliedEdits = applied;
|
||||
changed = true;
|
||||
}
|
||||
if (m_pendingEdits != pending) {
|
||||
m_pendingEdits = pending;
|
||||
changed = true;
|
||||
}
|
||||
if (m_rejectedEdits != rejected) {
|
||||
m_rejectedEdits = 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));
|
||||
emit statsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
53
ChatView/FileEditController.hpp
Normal file
53
ChatView/FileEditController.hpp
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatModel;
|
||||
|
||||
class FileEditController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit FileEditController(ChatModel *chatModel, QObject *parent = nullptr);
|
||||
|
||||
void setCurrentRequestId(const QString &requestId);
|
||||
void clearCurrentRequestId();
|
||||
|
||||
int totalEdits() const;
|
||||
int appliedEdits() const;
|
||||
int pendingEdits() const;
|
||||
int rejectedEdits() const;
|
||||
|
||||
void applyFileEdit(const QString &editId);
|
||||
void rejectFileEdit(const QString &editId);
|
||||
void undoFileEdit(const QString &editId);
|
||||
void openFileEditInEditor(const QString &editId);
|
||||
|
||||
void applyAllForCurrentMessage();
|
||||
void undoAllForCurrentMessage();
|
||||
void updateStats();
|
||||
|
||||
signals:
|
||||
void statsChanged();
|
||||
void infoMessage(const QString &message);
|
||||
void errorOccurred(const QString &error);
|
||||
|
||||
private:
|
||||
void updateFileEditStatus(const QString &editId, const QString &status);
|
||||
|
||||
ChatModel *m_chatModel;
|
||||
QString m_currentRequestId;
|
||||
int m_totalEdits{0};
|
||||
int m_appliedEdits{0};
|
||||
int m_pendingEdits{0};
|
||||
int m_rejectedEdits{0};
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
183
ChatView/InputTokenCounter.cpp
Normal file
183
ChatView/InputTokenCounter.cpp
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "InputTokenCounter.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
|
||||
#include <utils/aspects.h>
|
||||
|
||||
#include "ChatAssistantSettings.hpp"
|
||||
#include "ChatModel.hpp"
|
||||
#include "GeneralSettings.hpp"
|
||||
#include "Logger.hpp"
|
||||
#include "ProvidersManager.hpp"
|
||||
#include "context/ContextManager.hpp"
|
||||
#include "context/TokenUtils.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
InputTokenCounter::InputTokenCounter(
|
||||
ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_chatModel(chatModel)
|
||||
, m_contextManager(contextManager)
|
||||
{
|
||||
auto &settings = Settings::chatAssistantSettings();
|
||||
connect(
|
||||
&settings.useSystemPrompt,
|
||||
&Utils::BaseAspect::changed,
|
||||
this,
|
||||
&InputTokenCounter::recompute);
|
||||
connect(
|
||||
&settings.systemPrompt, &Utils::BaseAspect::changed, this, &InputTokenCounter::recompute);
|
||||
connect(
|
||||
&settings.enableChatTools,
|
||||
&Utils::BaseAspect::changed,
|
||||
this,
|
||||
&InputTokenCounter::recompute);
|
||||
|
||||
connect(&Settings::generalSettings().caProvider, &Utils::BaseAspect::changed, this, [this]() {
|
||||
rewireToolsChangedConnection();
|
||||
recompute();
|
||||
});
|
||||
|
||||
rewireToolsChangedConnection();
|
||||
recompute();
|
||||
}
|
||||
|
||||
int InputTokenCounter::inputTokens() const
|
||||
{
|
||||
return m_inputTokens;
|
||||
}
|
||||
|
||||
void InputTokenCounter::setMessage(const QString &message)
|
||||
{
|
||||
m_messageTokens = Context::TokenUtils::estimateTokens(message);
|
||||
recompute();
|
||||
}
|
||||
|
||||
void InputTokenCounter::setAttachments(const QStringList &attachments)
|
||||
{
|
||||
m_attachments = attachments;
|
||||
recompute();
|
||||
}
|
||||
|
||||
void InputTokenCounter::setLinkedFiles(const QStringList &linkedFiles)
|
||||
{
|
||||
m_linkedFiles = linkedFiles;
|
||||
recompute();
|
||||
}
|
||||
|
||||
void InputTokenCounter::rewireToolsChangedConnection()
|
||||
{
|
||||
if (m_toolsChangedConn)
|
||||
QObject::disconnect(m_toolsChangedConn);
|
||||
m_toolsChangedConn = {};
|
||||
|
||||
const auto providerName = Settings::generalSettings().caProvider();
|
||||
auto *provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName);
|
||||
if (!provider)
|
||||
return;
|
||||
auto *tm = provider->toolsManager();
|
||||
if (!tm)
|
||||
return;
|
||||
|
||||
m_toolsChangedConn = connect(
|
||||
tm, &::LLMQore::ToolRegistry::toolsChanged, this, &InputTokenCounter::recompute);
|
||||
}
|
||||
|
||||
void InputTokenCounter::recompute()
|
||||
{
|
||||
int inputTokens = m_messageTokens;
|
||||
auto &settings = Settings::chatAssistantSettings();
|
||||
|
||||
if (settings.useSystemPrompt()) {
|
||||
inputTokens += Context::TokenUtils::estimateTokens(settings.systemPrompt());
|
||||
}
|
||||
|
||||
const auto splitImageEstimate = [](const QStringList &paths, QStringList &textPaths) {
|
||||
int imageTokens = 0;
|
||||
for (const QString &p : paths) {
|
||||
if (Context::TokenUtils::isImageFilePath(p))
|
||||
imageTokens += Context::TokenUtils::estimateImageAttachmentTokens(p);
|
||||
else
|
||||
textPaths.append(p);
|
||||
}
|
||||
return imageTokens;
|
||||
};
|
||||
|
||||
if (!m_attachments.isEmpty()) {
|
||||
QStringList textPaths;
|
||||
inputTokens += splitImageEstimate(m_attachments, textPaths);
|
||||
if (!textPaths.isEmpty()) {
|
||||
auto attachFiles = m_contextManager->getContentFiles(textPaths);
|
||||
inputTokens += Context::TokenUtils::estimateFilesTokens(attachFiles);
|
||||
}
|
||||
}
|
||||
|
||||
if (!m_linkedFiles.isEmpty()) {
|
||||
QStringList textPaths;
|
||||
inputTokens += splitImageEstimate(m_linkedFiles, textPaths);
|
||||
if (!textPaths.isEmpty()) {
|
||||
auto linkFiles = m_contextManager->getContentFiles(textPaths);
|
||||
inputTokens += Context::TokenUtils::estimateFilesTokens(linkFiles);
|
||||
}
|
||||
}
|
||||
|
||||
const auto &history = m_chatModel->getChatHistory();
|
||||
for (const auto &message : history) {
|
||||
inputTokens += Context::TokenUtils::estimateTokens(message.content);
|
||||
inputTokens += 4; // + role
|
||||
}
|
||||
|
||||
if (settings.enableChatTools()) {
|
||||
const auto providerName = Settings::generalSettings().caProvider();
|
||||
if (auto *provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(
|
||||
providerName)) {
|
||||
if (auto *tm = provider->toolsManager()) {
|
||||
const QJsonArray toolDefs = tm->getToolsDefinitions();
|
||||
if (!toolDefs.isEmpty()) {
|
||||
const QByteArray serialized
|
||||
= QJsonDocument(toolDefs).toJson(QJsonDocument::Compact);
|
||||
inputTokens += static_cast<int>(serialized.size() / 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m_inputTokens = static_cast<int>(inputTokens * m_calibrationFactor);
|
||||
emit inputTokensChanged();
|
||||
}
|
||||
|
||||
void InputTokenCounter::recordSent()
|
||||
{
|
||||
m_lastSentEstimate = m_calibrationFactor > 0.0
|
||||
? static_cast<int>(m_inputTokens / m_calibrationFactor)
|
||||
: m_inputTokens;
|
||||
}
|
||||
|
||||
void InputTokenCounter::recordServerUsage(int promptTokens)
|
||||
{
|
||||
if (promptTokens <= 0 || m_lastSentEstimate <= 0)
|
||||
return;
|
||||
|
||||
const double rawFactor
|
||||
= static_cast<double>(promptTokens) / static_cast<double>(m_lastSentEstimate);
|
||||
const double clamped = std::clamp(rawFactor, 0.5, 3.0);
|
||||
m_calibrationFactor = 0.5 * m_calibrationFactor + 0.5 * clamped;
|
||||
|
||||
LOG_MESSAGE(QString("Token calibration: server=%1 estimated=%2 ratio=%3 ema=%4")
|
||||
.arg(promptTokens)
|
||||
.arg(m_lastSentEstimate)
|
||||
.arg(rawFactor, 0, 'f', 3)
|
||||
.arg(m_calibrationFactor, 0, 'f', 3));
|
||||
|
||||
recompute();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
53
ChatView/InputTokenCounter.hpp
Normal file
53
ChatView/InputTokenCounter.hpp
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
namespace QodeAssist::Context {
|
||||
class ContextManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatModel;
|
||||
|
||||
class InputTokenCounter : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
InputTokenCounter(
|
||||
ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent = nullptr);
|
||||
|
||||
int inputTokens() const;
|
||||
|
||||
void setMessage(const QString &message);
|
||||
void setAttachments(const QStringList &attachments);
|
||||
void setLinkedFiles(const QStringList &linkedFiles);
|
||||
void recompute();
|
||||
|
||||
void recordSent();
|
||||
void recordServerUsage(int promptTokens);
|
||||
|
||||
signals:
|
||||
void inputTokensChanged();
|
||||
|
||||
private:
|
||||
void rewireToolsChangedConnection();
|
||||
|
||||
ChatModel *m_chatModel;
|
||||
Context::ContextManager *m_contextManager;
|
||||
QMetaObject::Connection m_toolsChangedConn;
|
||||
|
||||
QStringList m_attachments;
|
||||
QStringList m_linkedFiles;
|
||||
int m_messageTokens{0};
|
||||
int m_inputTokens{0};
|
||||
int m_lastSentEstimate{0};
|
||||
double m_calibrationFactor{1.0};
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
67
ChatView/SessionFileRegistry.cpp
Normal file
67
ChatView/SessionFileRegistry.cpp
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "SessionFileRegistry.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
SessionFileRegistry::SessionFileRegistry(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
bool SessionFileRegistry::isLocked(const QString &path) const
|
||||
{
|
||||
return !path.isEmpty() && m_lockedPaths.contains(path);
|
||||
}
|
||||
|
||||
bool SessionFileRegistry::lock(const QString &path)
|
||||
{
|
||||
if (path.isEmpty() || m_lockedPaths.contains(path)) {
|
||||
return false;
|
||||
}
|
||||
m_lockedPaths.insert(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
void SessionFileRegistry::release(const QString &path)
|
||||
{
|
||||
m_lockedPaths.remove(path);
|
||||
}
|
||||
|
||||
void SessionFileRegistry::setPendingChatFile(const QString &path)
|
||||
{
|
||||
m_pendingChatFile = path;
|
||||
}
|
||||
|
||||
QString SessionFileRegistry::takePendingChatFile()
|
||||
{
|
||||
return std::exchange(m_pendingChatFile, QString{});
|
||||
}
|
||||
|
||||
QString SessionFileRegistry::uniqueFreePath(const QString &desiredPath) const
|
||||
{
|
||||
if (desiredPath.isEmpty() || !m_lockedPaths.contains(desiredPath)) {
|
||||
return desiredPath;
|
||||
}
|
||||
|
||||
const QFileInfo info(desiredPath);
|
||||
const QString dir = info.path();
|
||||
const QString base = info.completeBaseName();
|
||||
const QString suffix = info.suffix();
|
||||
|
||||
for (int counter = 2;; ++counter) {
|
||||
QString candidate = dir + '/' + base + '_' + QString::number(counter);
|
||||
if (!suffix.isEmpty()) {
|
||||
candidate += '.' + suffix;
|
||||
}
|
||||
if (!m_lockedPaths.contains(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
38
ChatView/SessionFileRegistry.hpp
Normal file
38
ChatView/SessionFileRegistry.hpp
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
// Shared registry of chat session (autosave) file paths that are currently held by a live
|
||||
// chat instance. Lets every chat view — bottom pane, navigation panel, editor split — claim
|
||||
// a unique history file so two sessions never autosave into the same path.
|
||||
class SessionFileRegistry : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SessionFileRegistry(QObject *parent = nullptr);
|
||||
|
||||
bool isLocked(const QString &path) const;
|
||||
bool lock(const QString &path);
|
||||
void release(const QString &path);
|
||||
|
||||
QString uniqueFreePath(const QString &desiredPath) const;
|
||||
|
||||
// Handoff slot for relocating a live chat between hosts (split <-> window): the source
|
||||
// chat stores its history file here, the freshly created host picks it up exactly once.
|
||||
void setPendingChatFile(const QString &path);
|
||||
QString takePendingChatFile();
|
||||
|
||||
private:
|
||||
QSet<QString> m_lockedPaths;
|
||||
QString m_pendingChatFile;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
5
ChatView/icons/new-chat-icon.svg
Normal file
5
ChatView/icons/new-chat-icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.6 0H2.4C1.08 0 0 1.08 0 2.4V16.8C0 18.12 1.08 19.2 2.4 19.2H7.2V22.8C7.2 23.46 7.74 24 8.4 24H9C9.3 24 9.6 23.88 9.84 23.652L14.28 19.2H21.6C22.92 19.2 24 18.12 24 16.8V2.4C24 1.08 22.92 0 21.6 0ZM21.6 16.8H13.44L8.76 21.48L8.4 21.6V16.8H2.4V2.4H21.6V16.8Z" fill="black"/>
|
||||
<rect x="11" y="5" width="2" height="9" rx="0.5" fill="black"/>
|
||||
<rect x="7.5" y="8.5" width="9" height="2" rx="0.5" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
17
ChatView/icons/open-in-code.svg
Normal file
17
ChatView/icons/open-in-code.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_74_52)">
|
||||
<mask id="mask0_74_52" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="44">
|
||||
<path d="M44 0H0V44H44V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_74_52)">
|
||||
<path d="M18 31C25.1797 31 31 25.1797 31 18C31 10.8203 25.1797 5 18 5C10.8203 5 5 10.8203 5 18C5 25.1797 10.8203 31 18 31Z" stroke="black" stroke-width="3.5"/>
|
||||
<path d="M27 27L38 38" stroke="black" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<path d="M16.375 23L18.2841 11.3636H20.1023L18.1932 23H16.375ZM11.1648 20.1136L11.4659 18.2955H20.5568L20.2557 20.1136H11.1648ZM12.2841 23L14.1932 11.3636H16.0114L14.1023 23H12.2841ZM11.8295 16.0682L12.1364 14.25H21.2273L20.9205 16.0682H11.8295Z" fill="black"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_74_52">
|
||||
<rect width="44" height="44" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 943 B |
@@ -1,17 +1,6 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_74_52)">
|
||||
<mask id="mask0_74_52" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="44">
|
||||
<path d="M44 0H0V44H44V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_74_52)">
|
||||
<path d="M18 31C25.1797 31 31 25.1797 31 18C31 10.8203 25.1797 5 18 5C10.8203 5 5 10.8203 5 18C5 25.1797 10.8203 31 18 31Z" stroke="black" stroke-width="3.5"/>
|
||||
<path d="M27 27L38 38" stroke="black" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<path d="M16.375 23L18.2841 11.3636H20.1023L18.1932 23H16.375ZM11.1648 20.1136L11.4659 18.2955H20.5568L20.2557 20.1136H11.1648ZM12.2841 23L14.1932 11.3636H16.0114L14.1023 23H12.2841ZM11.8295 16.0682L12.1364 14.25H21.2273L20.9205 16.0682H11.8295Z" fill="black"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_74_52">
|
||||
<rect width="44" height="44" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(10 8) skewX(-15)" stroke="black" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="10" y="0" width="22" height="15" rx="3" ry="3" fill="black"/>
|
||||
<rect x="0" y="12" width="22" height="15" rx="3" ry="3" fill="none"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 348 B |
6
ChatView/icons/open-in-window.svg
Normal file
6
ChatView/icons/open-in-window.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(10 8) skewX(-15)" stroke="black" stroke-width="2" stroke-linejoin="round">
|
||||
<rect x="10" y="0" width="22" height="15" rx="3" ry="3" fill="none"/>
|
||||
<rect x="0" y="12" width="22" height="15" rx="3" ry="3" fill="black"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -87,11 +87,28 @@ ChatRootView {
|
||||
Layout.preferredWidth: parent.width
|
||||
Layout.preferredHeight: childrenRect.height + 10
|
||||
|
||||
isInEditor: root.isInEditor
|
||||
saveButton.onClicked: root.showSaveDialog()
|
||||
loadButton.onClicked: root.showLoadDialog()
|
||||
clearButton.onClicked: root.clearChat()
|
||||
newChatButton.onClicked: root.requestNewChat()
|
||||
tokensBadge {
|
||||
text: qsTr("%1/%2").arg(root.inputTokensCount).arg(root.chatModel.tokensThreshold)
|
||||
readonly property int sessionPrompt: root.chatModel.sessionPromptTokens || 0
|
||||
readonly property int sessionCompletion: root.chatModel.sessionCompletionTokens || 0
|
||||
readonly property int sessionCached: root.chatModel.sessionCachedPromptTokens || 0
|
||||
text: sessionCached > 0
|
||||
? qsTr("next ~%1 · session ↑%2 ↓%3 ↻%4")
|
||||
.arg(root.inputTokensCount)
|
||||
.arg(sessionPrompt)
|
||||
.arg(sessionCompletion)
|
||||
.arg(sessionCached)
|
||||
: qsTr("next ~%1 · session ↑%2 ↓%3")
|
||||
.arg(root.inputTokensCount)
|
||||
.arg(sessionPrompt)
|
||||
.arg(sessionCompletion)
|
||||
ToolTip.text: sessionCached > 0
|
||||
? qsTr("next request (estimate) · session prompt ↑ / completion ↓ / cached ↻ (provider cache hits)")
|
||||
: qsTr("next request (estimate) · session prompt ↑ / completion ↓")
|
||||
}
|
||||
recentPath {
|
||||
text: qsTr("Сhat name: %1").arg(root.chatFileName.length > 0 ? root.chatFileName : "Unsaved")
|
||||
@@ -103,6 +120,20 @@ ChatRootView {
|
||||
checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false
|
||||
onCheckedChanged: _chatview.isPin = topBar.pinButton.checked
|
||||
}
|
||||
relocateButton {
|
||||
icon.source: (typeof _chatview !== 'undefined')
|
||||
? "qrc:/qt/qml/ChatView/icons/open-in-editor.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/open-in-window.svg"
|
||||
onClicked: {
|
||||
if (typeof _chatview !== 'undefined')
|
||||
root.relocateToSplit()
|
||||
else
|
||||
root.relocateToWindow()
|
||||
}
|
||||
}
|
||||
relocateTooltip.text: (typeof _chatview !== 'undefined')
|
||||
? qsTr("Move this chat to an editor tab")
|
||||
: qsTr("Move this chat to a separate window")
|
||||
toolsButton {
|
||||
checked: root.useTools
|
||||
onCheckedChanged: {
|
||||
@@ -125,39 +156,67 @@ ChatRootView {
|
||||
root.applyConfiguration(root.availableConfigurations[index])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
popup.onAboutToShow: {
|
||||
root.loadAvailableConfigurations()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
roleSelector {
|
||||
model: root.availableAgentRoles
|
||||
displayText: root.currentAgentRole
|
||||
onActivated: function(index) {
|
||||
root.applyAgentRole(root.availableAgentRoles[index])
|
||||
}
|
||||
|
||||
|
||||
popup.onAboutToShow: {
|
||||
root.loadAvailableAgentRoles()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 2
|
||||
|
||||
MessageNavigator {
|
||||
id: messageNavigator
|
||||
|
||||
Layout.preferredWidth: 16
|
||||
Layout.fillHeight: true
|
||||
Layout.topMargin: 4
|
||||
Layout.bottomMargin: 4
|
||||
|
||||
chatModel: root.chatModel
|
||||
|
||||
onMessageClicked: function(messageIndex) {
|
||||
chatListView.userScrolledUp = true
|
||||
chatListView.positionViewAtIndex(messageIndex, ListView.Beginning)
|
||||
}
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: chatListView
|
||||
|
||||
property bool userScrolledUp: false
|
||||
|
||||
function syncNavigatorCurrent() {
|
||||
const top = indexAt(10, contentY + 4)
|
||||
messageNavigator.updateCurrentFromModelIndex(top)
|
||||
}
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
leftMargin: 5
|
||||
leftMargin: 3
|
||||
model: root.chatModel
|
||||
clip: true
|
||||
spacing: 0
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
cacheBuffer: 2000
|
||||
|
||||
onContentYChanged: Qt.callLater(syncNavigatorCurrent)
|
||||
|
||||
onMovingChanged: {
|
||||
if (moving) {
|
||||
userScrolledUp = !atYEnd
|
||||
@@ -244,6 +303,7 @@ ChatRootView {
|
||||
if (!userScrolledUp) {
|
||||
root.scrollToBottom()
|
||||
}
|
||||
Qt.callLater(syncNavigatorCurrent)
|
||||
}
|
||||
|
||||
onContentHeightChanged: {
|
||||
@@ -259,6 +319,7 @@ ChatRootView {
|
||||
id: chatItemInstance
|
||||
|
||||
width: parent.width
|
||||
chatViewport: chatListView
|
||||
msgModel: root.chatModel.processMessageContent(model.content)
|
||||
messageAttachments: model.attachments
|
||||
messageImages: model.images
|
||||
@@ -270,6 +331,10 @@ ChatRootView {
|
||||
codeFontSize: root.codeFontSize
|
||||
textFontSize: root.textFontSize
|
||||
textFormat: root.textFormat
|
||||
promptTokens: model.promptTokens || 0
|
||||
completionTokens: model.completionTokens || 0
|
||||
cachedPromptTokens: model.cachedPromptTokens || 0
|
||||
reasoningTokens: model.reasoningTokens || 0
|
||||
|
||||
onResetChatToMessage: function(idx) {
|
||||
messageInput.text = model.content
|
||||
@@ -334,6 +399,7 @@ ChatRootView {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
id: view
|
||||
@@ -372,15 +438,31 @@ ChatRootView {
|
||||
root.calculateMessageTokensCount(messageInput.text)
|
||||
var cursorPos = messageInput.cursorPosition
|
||||
var textBefore = messageInput.text.substring(0, cursorPos)
|
||||
|
||||
var atIndex = textBefore.lastIndexOf('@')
|
||||
if (atIndex >= 0) {
|
||||
var query = textBefore.substring(atIndex + 1)
|
||||
if (query.indexOf(' ') === -1 && query.indexOf('\n') === -1) {
|
||||
fileMentionPopup.updateSearch(query)
|
||||
skillCommandPopup.dismiss()
|
||||
return
|
||||
}
|
||||
}
|
||||
fileMentionPopup.dismiss()
|
||||
|
||||
const slashIndex = textBefore.lastIndexOf('/')
|
||||
if (slashIndex >= 0) {
|
||||
const beforeSlash = slashIndex === 0
|
||||
? ' '
|
||||
: textBefore.charAt(slashIndex - 1)
|
||||
const skillQuery = textBefore.substring(slashIndex + 1)
|
||||
if ((beforeSlash === ' ' || beforeSlash === '\n')
|
||||
&& /^[a-z0-9-]*$/.test(skillQuery)) {
|
||||
skillCommandPopup.updateSearch(skillQuery)
|
||||
return
|
||||
}
|
||||
}
|
||||
skillCommandPopup.dismiss()
|
||||
}
|
||||
|
||||
Keys.onPressed: function(event) {
|
||||
@@ -398,6 +480,20 @@ ChatRootView {
|
||||
fileMentionPopup.dismiss()
|
||||
event.accepted = true
|
||||
}
|
||||
} else if (skillCommandPopup.visible) {
|
||||
if (event.key === Qt.Key_Down) {
|
||||
skillCommandPopup.moveDown()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Up) {
|
||||
skillCommandPopup.moveUp()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
root.applySkillSelection()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
skillCommandPopup.dismiss()
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,8 +591,8 @@ ChatRootView {
|
||||
sendButton.icon.source: !root.isRequestInProgress ? "qrc:/qt/qml/ChatView/icons/chat-icon.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/chat-pause-icon.svg"
|
||||
sendButton.text: !root.isRequestInProgress ? qsTr("Send") : qsTr("Stop")
|
||||
sendButton.ToolTip.text: !root.isRequestInProgress ? qsTr("Send message to LLM %1").arg(Qt.platform.os === "osx" ? "Cmd+Return" : "Ctrl+Return")
|
||||
: qsTr("Stop")
|
||||
sendButtonTooltip.text: !root.isRequestInProgress ? qsTr("Send message to LLM %1").arg(Qt.platform.os === "osx" ? "Cmd+Return" : "Ctrl+Return")
|
||||
: qsTr("Stop")
|
||||
compressButton.onClicked: compressConfirmDialog.open()
|
||||
cancelCompressButton.onClicked: root.cancelCompression()
|
||||
syncOpenFiles {
|
||||
@@ -509,15 +605,6 @@ ChatRootView {
|
||||
}
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
id: sendMessageShortcut
|
||||
|
||||
sequences: ["Ctrl+Return", "Ctrl+Enter"]
|
||||
context: Qt.WindowShortcut
|
||||
enabled: messageInput.activeFocus && !Qt.inputMethod.visible && !fileMentionPopup.visible
|
||||
onActivated: root.sendChatMessage()
|
||||
}
|
||||
|
||||
function clearChat() {
|
||||
root.clearMessages()
|
||||
root.clearAttachmentFiles()
|
||||
@@ -528,6 +615,31 @@ ChatRootView {
|
||||
Qt.callLater(chatListView.positionViewAtEnd)
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
messageInput.forceActiveFocus()
|
||||
}
|
||||
|
||||
property Item focusGuard: Window.activeFocusItem
|
||||
onFocusGuardChanged: Qt.callLater(returnFocusToInputIfNeeded)
|
||||
|
||||
function returnFocusToInputIfNeeded() {
|
||||
var item = Window.activeFocusItem
|
||||
if (!item || item === messageInput)
|
||||
return
|
||||
if (item.cursorVisible !== undefined || item.selectByMouse !== undefined)
|
||||
return
|
||||
if (item.popup !== undefined)
|
||||
return
|
||||
var p = item
|
||||
while (p) {
|
||||
if (p === root) {
|
||||
messageInput.forceActiveFocus()
|
||||
return
|
||||
}
|
||||
p = p.parent
|
||||
}
|
||||
}
|
||||
|
||||
function applyMentionSelection() {
|
||||
var result = fileMentionPopup.applyCurrentSelection(
|
||||
messageInput.text, messageInput.cursorPosition, root.useTools)
|
||||
@@ -537,6 +649,23 @@ ChatRootView {
|
||||
}
|
||||
}
|
||||
|
||||
function applySkillSelection() {
|
||||
const name = skillCommandPopup.currentName()
|
||||
if (name === "")
|
||||
return
|
||||
const cursorPos = messageInput.cursorPosition
|
||||
const textBefore = messageInput.text.substring(0, cursorPos)
|
||||
const slashIndex = textBefore.lastIndexOf('/')
|
||||
if (slashIndex < 0)
|
||||
return
|
||||
const before = messageInput.text.substring(0, slashIndex)
|
||||
const after = messageInput.text.substring(cursorPos)
|
||||
const token = '/' + name + ' '
|
||||
messageInput.text = before + token + after
|
||||
messageInput.cursorPosition = before.length + token.length
|
||||
skillCommandPopup.dismiss()
|
||||
}
|
||||
|
||||
function sendChatMessage() {
|
||||
root.sendMessage(fileMentionPopup.expandMentions(messageInput.text))
|
||||
messageInput.text = ""
|
||||
@@ -636,7 +765,21 @@ ChatRootView {
|
||||
}
|
||||
}
|
||||
|
||||
SkillCommandPopup {
|
||||
id: skillCommandPopup
|
||||
|
||||
z: 999
|
||||
width: Math.min(480, root.width - 20)
|
||||
|
||||
x: Math.max(5, Math.min(view.x + 5, root.width - width - 5))
|
||||
y: view.y - height - 4
|
||||
|
||||
skillProvider: root
|
||||
|
||||
onSelectionRequested: root.applySkillSelection()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
messageInput.forceActiveFocus()
|
||||
focusInput()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,16 @@ Rectangle {
|
||||
property int textFontSize: Qt.application.font.pointSize
|
||||
property int codeFontSize: Qt.application.font.pointSize
|
||||
property int textFormat: 0
|
||||
property Flickable chatViewport: null
|
||||
|
||||
property bool isUserMessage: false
|
||||
property int messageIndex: -1
|
||||
|
||||
property int promptTokens: 0
|
||||
property int completionTokens: 0
|
||||
property int cachedPromptTokens: 0
|
||||
property int reasoningTokens: 0
|
||||
|
||||
signal resetChatToMessage(int index)
|
||||
signal openFileRequested(string filePath)
|
||||
|
||||
@@ -135,6 +141,39 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: usageBadge
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 10
|
||||
Layout.rightMargin: 10
|
||||
spacing: 8
|
||||
visible: !root.isUserMessage
|
||||
&& (root.promptTokens > 0 || root.completionTokens > 0)
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Text {
|
||||
text: root.cachedPromptTokens > 0
|
||||
? qsTr("↑ %1 (cached %2)").arg(root.promptTokens).arg(root.cachedPromptTokens)
|
||||
: qsTr("↑ %1").arg(root.promptTokens)
|
||||
color: palette.placeholderText
|
||||
font.pointSize: Math.max(root.textFontSize - 2, 7)
|
||||
}
|
||||
Text {
|
||||
text: root.reasoningTokens > 0
|
||||
? qsTr("↓ %1 (reasoning %2)").arg(root.completionTokens).arg(root.reasoningTokens)
|
||||
: qsTr("↓ %1").arg(root.completionTokens)
|
||||
color: palette.placeholderText
|
||||
font.pointSize: Math.max(root.textFontSize - 2, 7)
|
||||
}
|
||||
Text {
|
||||
text: qsTr("Σ %1").arg(root.promptTokens + root.completionTokens)
|
||||
color: palette.placeholderText
|
||||
font.pointSize: Math.max(root.textFontSize - 2, 7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -221,6 +260,7 @@ Rectangle {
|
||||
language: itemData.language
|
||||
codeFontFamily: root.codeFontFamily
|
||||
codeFontSize: root.codeFontSize
|
||||
viewport: root.chatViewport
|
||||
}
|
||||
|
||||
component AttachmentComponent : Rectangle {
|
||||
|
||||
@@ -13,6 +13,7 @@ Rectangle {
|
||||
property string code: ""
|
||||
property string language: ""
|
||||
property bool expanded: false
|
||||
property Flickable viewport: null
|
||||
|
||||
property alias codeFontFamily: codeText.font.family
|
||||
property alias codeFontSize: codeText.font.pointSize
|
||||
@@ -122,7 +123,16 @@ Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 5
|
||||
|
||||
y: 5
|
||||
y: {
|
||||
if (!root.expanded || !root.viewport)
|
||||
return 5
|
||||
const flick = root.viewport
|
||||
const topInContent = root.mapToItem(flick.contentItem, 0, 0).y
|
||||
const topInView = topInContent - flick.contentY
|
||||
const desired = topInView < 0 ? (-topInView + 5) : 5
|
||||
const maxY = Math.max(5, root.height - copyButton.height - 5)
|
||||
return Math.max(5, Math.min(desired, maxY))
|
||||
}
|
||||
text: qsTr("Copy")
|
||||
|
||||
onClicked: {
|
||||
|
||||
@@ -193,9 +193,24 @@ Rectangle {
|
||||
color: root.statusColor
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/open-in-code.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
hoverEnabled: true
|
||||
onClicked: root.openInEditor(editData.edit_id)
|
||||
|
||||
QoAToolTip {
|
||||
visible: parent.hovered
|
||||
delay: 500
|
||||
text: qsTr("Open file in editor and navigate to changes")
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: headerText
|
||||
Layout.fillWidth: true
|
||||
text: {
|
||||
var modeText = root.oldContent.length > 0 ? qsTr("Replace") : qsTr("Append")
|
||||
if (root.oldContent.length > 0) {
|
||||
@@ -223,6 +238,19 @@ Rectangle {
|
||||
color: palette.mid
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: actionButtons
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
rightMargin: 5
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
Rectangle {
|
||||
visible: !root.isPending
|
||||
Layout.preferredWidth: badgeText.width + 12
|
||||
@@ -239,31 +267,6 @@ Rectangle {
|
||||
color: root.isArchived ? Qt.rgba(0.6, 0.6, 0.6, 1.0) : palette.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: actionButtons
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
rightMargin: 5
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
QoAButton {
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/open-in-editor.svg"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
hoverEnabled: true
|
||||
onClicked: root.openInEditor(editData.edit_id)
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Open file in editor and navigate to changes")
|
||||
ToolTip.delay: 500
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
icon {
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Qt.labs.platform as Platform
|
||||
import ChatView
|
||||
import UIControls
|
||||
|
||||
Flow {
|
||||
id: root
|
||||
|
||||
|
||||
property alias attachedFilesModel: attachRepeater.model
|
||||
property color accentColor: palette.mid
|
||||
property string iconPath
|
||||
@@ -21,10 +22,10 @@ Flow {
|
||||
rightPadding: 5
|
||||
topPadding: attachRepeater.model.length > 0 ? 2 : 0
|
||||
bottomPadding: attachRepeater.model.length > 0 ? 2 : 0
|
||||
|
||||
|
||||
Repeater {
|
||||
id: attachRepeater
|
||||
|
||||
|
||||
delegate: FileItem {
|
||||
id: fileItem
|
||||
|
||||
@@ -32,7 +33,7 @@ Flow {
|
||||
required property string modelData
|
||||
|
||||
filePath: modelData
|
||||
|
||||
|
||||
height: 30
|
||||
width: contentRow.width + 10
|
||||
|
||||
@@ -52,7 +53,7 @@ Flow {
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
contextMenu.popup()
|
||||
contextMenu.open()
|
||||
} else if (mouse.button === Qt.MiddleButton ||
|
||||
(mouse.button === Qt.LeftButton && (mouse.modifiers & Qt.ControlModifier))) {
|
||||
root.removeFileFromListByIndex(fileItem.index)
|
||||
@@ -70,27 +71,27 @@ Flow {
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
Platform.Menu {
|
||||
id: contextMenu
|
||||
|
||||
MenuItem {
|
||||
text: "Open in Qt Creator"
|
||||
Platform.MenuItem {
|
||||
text: qsTr("Open in Qt Creator")
|
||||
onTriggered: fileItem.openFileInEditor()
|
||||
}
|
||||
|
||||
MenuItem {
|
||||
text: "Open in External Editor"
|
||||
Platform.MenuItem {
|
||||
text: qsTr("Open in External Editor")
|
||||
onTriggered: fileItem.openFileInExternalEditor()
|
||||
}
|
||||
|
||||
MenuSeparator {}
|
||||
Platform.MenuSeparator {}
|
||||
|
||||
MenuItem {
|
||||
text: "Remove"
|
||||
Platform.MenuItem {
|
||||
text: qsTr("Remove")
|
||||
onTriggered: root.removeFileFromListByIndex(fileItem.index)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Row {
|
||||
id: contentRow
|
||||
|
||||
@@ -107,31 +108,31 @@ Flow {
|
||||
sourceSize.width: 8
|
||||
sourceSize.height: 15
|
||||
}
|
||||
|
||||
|
||||
Text {
|
||||
id: fileNameText
|
||||
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: palette.buttonText
|
||||
|
||||
|
||||
text: {
|
||||
const parts = modelData.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
MouseArea {
|
||||
id: closeButton
|
||||
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: closeIcon.width + 5
|
||||
height: closeButton.width + 5
|
||||
|
||||
|
||||
onClicked: root.removeFileFromListByIndex(index)
|
||||
|
||||
|
||||
Image {
|
||||
id: closeIcon
|
||||
|
||||
|
||||
anchors.centerIn: parent
|
||||
source: palette.window.hslLightness > 0.5 ? "qrc:/qt/qml/ChatView/icons/close-dark.svg"
|
||||
: "qrc:/qt/qml/ChatView/icons/close-light.svg"
|
||||
|
||||
@@ -19,6 +19,7 @@ Rectangle {
|
||||
property alias cancelCompressButton: cancelCompressButtonId
|
||||
|
||||
property bool isCompressing: false
|
||||
property alias sendButtonTooltip: sendButtonTooltipId
|
||||
|
||||
color: palette.window.hslLightness > 0.5 ?
|
||||
Qt.darker(palette.window, 1.1) :
|
||||
@@ -45,9 +46,12 @@ Rectangle {
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Attach file to message")
|
||||
|
||||
QoAToolTip {
|
||||
visible: attachFilesId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Attach file to message")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
@@ -58,9 +62,12 @@ Rectangle {
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Attach image to message")
|
||||
|
||||
QoAToolTip {
|
||||
visible: attachImagesId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Attach image to message")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
@@ -71,9 +78,12 @@ Rectangle {
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Link file to context")
|
||||
|
||||
QoAToolTip {
|
||||
visible: linkFilesId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Link file to context")
|
||||
}
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
@@ -81,8 +91,10 @@ Rectangle {
|
||||
|
||||
text: qsTr("Sync open files")
|
||||
|
||||
ToolTip.visible: syncOpenFilesId.hovered
|
||||
ToolTip.text: qsTr("Automatically synchronize currently opened files with the model context")
|
||||
QoAToolTip {
|
||||
visible: syncOpenFilesId.hovered
|
||||
text: qsTr("Automatically synchronize currently opened files with the model context")
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -117,9 +129,11 @@ Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: qsTr("Cancel")
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Cancel compression")
|
||||
QoAToolTip {
|
||||
visible: cancelCompressButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Cancel compression")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,9 +148,12 @@ Rectangle {
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Compress chat (create summarized copy using LLM)")
|
||||
|
||||
QoAToolTip {
|
||||
visible: compressButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Compress chat (create summarized copy using LLM)")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
@@ -146,8 +163,13 @@ Rectangle {
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
|
||||
QoAToolTip {
|
||||
id: sendButtonTooltipId
|
||||
|
||||
visible: sendButtonId.hovered
|
||||
delay: 250
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,12 +117,14 @@ Rectangle {
|
||||
text: root.hasPendingEdits
|
||||
? qsTr("Apply All (%1)").arg(root.pendingEdits + root.rejectedEdits)
|
||||
: qsTr("Reapply All (%1)").arg(root.rejectedEdits)
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: root.hasPendingEdits
|
||||
? qsTr("Apply all pending and rejected edits in this message")
|
||||
: qsTr("Reapply all rejected edits in this message")
|
||||
|
||||
QoAToolTip {
|
||||
visible: applyAllButton.hovered
|
||||
delay: 250
|
||||
text: root.hasPendingEdits
|
||||
? qsTr("Apply all pending and rejected edits in this message")
|
||||
: qsTr("Reapply all rejected edits in this message")
|
||||
}
|
||||
|
||||
onClicked: root.applyAllClicked()
|
||||
}
|
||||
@@ -133,10 +135,12 @@ Rectangle {
|
||||
visible: root.hasAppliedEdits
|
||||
enabled: root.hasAppliedEdits
|
||||
text: qsTr("Undo All (%1)").arg(root.appliedEdits)
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Undo all applied edits in this message")
|
||||
|
||||
QoAToolTip {
|
||||
visible: undoAllButton.hovered
|
||||
delay: 250
|
||||
text: qsTr("Undo all applied edits in this message")
|
||||
}
|
||||
|
||||
onClicked: root.undoAllClicked()
|
||||
}
|
||||
|
||||
187
ChatView/qml/controls/MessageNavigator.qml
Normal file
187
ChatView/qml/controls/MessageNavigator.qml
Normal file
@@ -0,0 +1,187 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import ChatView
|
||||
import UIControls
|
||||
|
||||
Item {
|
||||
id: nav
|
||||
|
||||
property var chatModel
|
||||
property var entries: []
|
||||
property color dotColor: "#92BD6C"
|
||||
property int currentMessageIndex: -1
|
||||
|
||||
readonly property int dotCount: entries.length
|
||||
readonly property int verticalPadding: 8
|
||||
readonly property int minDotSpacing: 18
|
||||
readonly property real availableHeight: Math.max(0, height - 2 * verticalPadding)
|
||||
readonly property real naturalHeight: dotCount > 1 ? (dotCount - 1) * minDotSpacing : 0
|
||||
readonly property bool needsScrolling: naturalHeight > availableHeight
|
||||
readonly property real contentHeight: needsScrolling
|
||||
? naturalHeight + 2 * verticalPadding
|
||||
: Math.max(height, 2 * verticalPadding)
|
||||
|
||||
signal messageClicked(int messageIndex)
|
||||
|
||||
implicitWidth: 16
|
||||
|
||||
function rebuild() {
|
||||
entries = chatModel ? chatModel.userMessagePreviews(80) : []
|
||||
Qt.callLater(scrollCurrentIntoView)
|
||||
}
|
||||
|
||||
function updateCurrentFromModelIndex(modelIdx) {
|
||||
if (modelIdx < 0) {
|
||||
currentMessageIndex = -1
|
||||
return
|
||||
}
|
||||
let best = -1
|
||||
for (let i = 0; i < entries.length; ++i) {
|
||||
const e = entries[i]
|
||||
if (!e)
|
||||
continue
|
||||
const mi = e.messageIndex
|
||||
if (mi <= modelIdx)
|
||||
best = mi
|
||||
else
|
||||
break
|
||||
}
|
||||
currentMessageIndex = best
|
||||
}
|
||||
|
||||
function uiIndexOf(messageIndex) {
|
||||
for (let i = 0; i < entries.length; ++i) {
|
||||
const e = entries[i]
|
||||
if (e && e.messageIndex === messageIndex)
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function dotCenterY(uiIndex) {
|
||||
const count = dotCount
|
||||
if (count <= 1)
|
||||
return contentHeight / 2
|
||||
const spacing = needsScrolling
|
||||
? minDotSpacing
|
||||
: availableHeight / (count - 1)
|
||||
return verticalPadding + spacing * uiIndex
|
||||
}
|
||||
|
||||
function scrollCurrentIntoView() {
|
||||
if (!needsScrolling || currentMessageIndex < 0)
|
||||
return
|
||||
const ui = uiIndexOf(currentMessageIndex)
|
||||
if (ui < 0)
|
||||
return
|
||||
const y = dotCenterY(ui)
|
||||
const margin = 24
|
||||
if (y < flick.contentY + margin)
|
||||
flick.contentY = Math.max(0, y - margin)
|
||||
else if (y > flick.contentY + flick.height - margin)
|
||||
flick.contentY = Math.min(
|
||||
Math.max(0, flick.contentHeight - flick.height),
|
||||
y - flick.height + margin)
|
||||
}
|
||||
|
||||
onChatModelChanged: rebuild()
|
||||
onCurrentMessageIndexChanged: scrollCurrentIntoView()
|
||||
Component.onCompleted: rebuild()
|
||||
|
||||
Connections {
|
||||
target: nav.chatModel
|
||||
ignoreUnknownSignals: true
|
||||
function onRowsInserted() { nav.rebuild() }
|
||||
function onRowsRemoved() { nav.rebuild() }
|
||||
function onModelReset() { nav.rebuild() }
|
||||
function onModelReseted() { nav.rebuild() }
|
||||
function onDataChanged() { nav.rebuild() }
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: flick
|
||||
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: nav.contentHeight
|
||||
interactive: nav.needsScrolling
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Rectangle {
|
||||
id: spine
|
||||
|
||||
visible: nav.dotCount > 1
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: nav.verticalPadding
|
||||
width: 1
|
||||
height: Math.max(0, flick.contentHeight - 2 * nav.verticalPadding)
|
||||
color: palette.mid
|
||||
opacity: 0.4
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: nav.entries
|
||||
|
||||
delegate: Item {
|
||||
id: dotItem
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property int msgIndex: modelData && modelData.messageIndex !== undefined
|
||||
? modelData.messageIndex : -1
|
||||
readonly property string preview: modelData && modelData.preview !== undefined
|
||||
? modelData.preview : ""
|
||||
readonly property bool isCurrent: nav.currentMessageIndex === msgIndex
|
||||
|
||||
width: 16
|
||||
height: 14
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: nav.dotCenterY(index) - height / 2
|
||||
|
||||
Rectangle {
|
||||
id: dot
|
||||
|
||||
anchors.centerIn: parent
|
||||
width: dotItem.isCurrent ? 11 : (dotArea.containsMouse ? 10 : 7)
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: dotArea.containsMouse
|
||||
? Qt.lighter(nav.dotColor, 1.2)
|
||||
: nav.dotColor
|
||||
border.color: dotItem.isCurrent
|
||||
? Qt.darker(nav.dotColor, 1.7)
|
||||
: Qt.darker(nav.dotColor, 1.4)
|
||||
border.width: dotItem.isCurrent ? 2 : 1
|
||||
opacity: dotItem.isCurrent || dotArea.containsMouse ? 1.0 : 0.55
|
||||
|
||||
Behavior on width { NumberAnimation { duration: 120 } }
|
||||
Behavior on opacity { NumberAnimation { duration: 120 } }
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: dotArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: nav.messageClicked(dotItem.msgIndex)
|
||||
|
||||
QoAToolTip {
|
||||
visible: dotArea.containsMouse
|
||||
delay: 350
|
||||
text: dotItem.preview.length > 0
|
||||
? qsTr("#%1 · %2").arg(dotItem.index + 1).arg(dotItem.preview)
|
||||
: qsTr("Jump to message #%1").arg(dotItem.index + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
125
ChatView/qml/controls/SkillCommandPopup.qml
Normal file
125
ChatView/qml/controls/SkillCommandPopup.qml
Normal file
@@ -0,0 +1,125 @@
|
||||
// Copyright (C) 2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// Object exposing Q_INVOKABLE QVariantList searchSkills(query).
|
||||
property var skillProvider: null
|
||||
property var searchResults: []
|
||||
property int currentIndex: 0
|
||||
|
||||
signal selectionRequested()
|
||||
|
||||
visible: searchResults.length > 0
|
||||
height: Math.min(searchResults.length * 40, 40 * 6) + 2
|
||||
|
||||
color: palette.window
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 4
|
||||
|
||||
function updateSearch(query) {
|
||||
searchResults = skillProvider ? skillProvider.searchSkills(query) : []
|
||||
currentIndex = 0
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
searchResults = []
|
||||
currentIndex = 0
|
||||
}
|
||||
|
||||
function moveUp() {
|
||||
if (currentIndex > 0)
|
||||
currentIndex--
|
||||
}
|
||||
|
||||
function moveDown() {
|
||||
if (currentIndex < searchResults.length - 1)
|
||||
currentIndex++
|
||||
}
|
||||
|
||||
function currentName() {
|
||||
if (currentIndex >= 0 && currentIndex < searchResults.length)
|
||||
return searchResults[currentIndex].name
|
||||
return ""
|
||||
}
|
||||
|
||||
onCurrentIndexChanged: listView.positionViewAtIndex(currentIndex, ListView.Contain)
|
||||
|
||||
ListView {
|
||||
id: listView
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 1
|
||||
model: root.searchResults
|
||||
currentIndex: root.currentIndex
|
||||
clip: true
|
||||
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
policy: ScrollBar.AsNeeded
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: delegateItem
|
||||
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
width: listView.width
|
||||
height: 40
|
||||
color: index === root.currentIndex
|
||||
? palette.highlight
|
||||
: (hoverArea.containsMouse
|
||||
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.25)
|
||||
: "transparent")
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
anchors.topMargin: 4
|
||||
anchors.bottomMargin: 4
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "/" + delegateItem.modelData.name
|
||||
color: delegateItem.index === root.currentIndex
|
||||
? palette.highlightedText
|
||||
: palette.text
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: delegateItem.modelData.description
|
||||
color: delegateItem.index === root.currentIndex
|
||||
? Qt.rgba(palette.highlightedText.r,
|
||||
palette.highlightedText.g,
|
||||
palette.highlightedText.b, 0.7)
|
||||
: palette.mid
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: hoverArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
root.currentIndex = delegateItem.index
|
||||
root.selectionRequested()
|
||||
}
|
||||
onEntered: root.currentIndex = delegateItem.index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,24 @@ import UIControls
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property bool isInEditor: false
|
||||
|
||||
property alias saveButton: saveButtonId
|
||||
property alias loadButton: loadButtonId
|
||||
property alias clearButton: clearButtonId
|
||||
property alias newChatButton: newChatButtonId
|
||||
property alias tokensBadge: tokensBadgeId
|
||||
property alias recentPath: recentPathId
|
||||
property alias openChatHistory: openChatHistoryId
|
||||
property alias pinButton: pinButtonId
|
||||
property alias relocateButton: relocateButtonId
|
||||
property alias contextButton: contextButtonId
|
||||
property alias toolsButton: toolsButtonId
|
||||
property alias thinkingMode: thinkingModeId
|
||||
property alias settingsButton: settingsButtonId
|
||||
property alias configSelector: configSelectorId
|
||||
property alias roleSelector: roleSelector
|
||||
property alias relocateTooltip: relocateTooltipId
|
||||
|
||||
color: palette.window.hslLightness > 0.5 ?
|
||||
Qt.darker(palette.window, 1.1) :
|
||||
@@ -55,10 +60,76 @@ Rectangle {
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: checked ? qsTr("Unpin chat window")
|
||||
: qsTr("Pin chat window to the top")
|
||||
|
||||
QoAToolTip {
|
||||
visible: pinButtonId.hovered
|
||||
delay: 250
|
||||
text: pinButtonId.checked ? qsTr("Unpin chat window")
|
||||
: qsTr("Pin chat window to the top")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: relocateButtonId
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/open-in-editor.svg"
|
||||
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
id: relocateTooltipId
|
||||
|
||||
visible: relocateButtonId.hovered
|
||||
delay: 250
|
||||
}
|
||||
}
|
||||
|
||||
QoASeparator {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: clearButtonId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/clean-icon-dark.svg"
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: clearButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Clean chat")
|
||||
}
|
||||
}
|
||||
|
||||
QoASeparator {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
id: newChatButtonId
|
||||
|
||||
visible: root.isInEditor
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/new-chat-icon.svg"
|
||||
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
|
||||
QoAToolTip {
|
||||
visible: newChatButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Open new chat in a new tab")
|
||||
}
|
||||
}
|
||||
|
||||
QoAComboBox {
|
||||
@@ -69,9 +140,11 @@ Rectangle {
|
||||
model: []
|
||||
currentIndex: 0
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Switch saved AI configuration")
|
||||
QoAToolTip {
|
||||
visible: configSelectorId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Switch saved AI configuration")
|
||||
}
|
||||
}
|
||||
|
||||
QoAComboBox {
|
||||
@@ -82,9 +155,11 @@ Rectangle {
|
||||
model: []
|
||||
currentIndex: 0
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Switch agent role (different system prompts)")
|
||||
QoAToolTip {
|
||||
visible: roleSelector.hovered
|
||||
delay: 250
|
||||
text: qsTr("Switch agent role (different system prompts)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,15 +182,17 @@ Rectangle {
|
||||
width: 15
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: {
|
||||
if (!toolsButtonId.enabled) {
|
||||
return qsTr("Tools are disabled in General Settings")
|
||||
QoAToolTip {
|
||||
visible: toolsButtonId.hovered
|
||||
delay: 250
|
||||
text: {
|
||||
if (!toolsButtonId.enabled) {
|
||||
return qsTr("Tools are disabled in General Settings")
|
||||
}
|
||||
return toolsButtonId.checked
|
||||
? qsTr("Tools enabled: AI can use tools to read files, search project, and build code")
|
||||
: qsTr("Tools disabled: Simple conversation without tool access")
|
||||
}
|
||||
return checked
|
||||
? qsTr("Tools enabled: AI can use tools to read files, search project, and build code")
|
||||
: qsTr("Tools disabled: Simple conversation without tool access")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,11 +212,14 @@ Rectangle {
|
||||
width: 15
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: enabled ? (checked ? qsTr("Thinking Mode enabled (Check model list support it)")
|
||||
: qsTr("Thinking Mode disabled"))
|
||||
: qsTr("Thinking Mode is not available for this provider")
|
||||
QoAToolTip {
|
||||
visible: thinkingModeId.hovered
|
||||
delay: 250
|
||||
text: thinkingModeId.enabled
|
||||
? (thinkingModeId.checked ? qsTr("Thinking Mode enabled (Check model list support it)")
|
||||
: qsTr("Thinking Mode disabled"))
|
||||
: qsTr("Thinking Mode is not available for this provider")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
@@ -154,9 +234,11 @@ Rectangle {
|
||||
width: 15
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Open Chat Assistant Settings")
|
||||
QoAToolTip {
|
||||
visible: settingsButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Open Chat Assistant Settings")
|
||||
}
|
||||
}
|
||||
|
||||
QoASeparator {
|
||||
@@ -182,9 +264,11 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
|
||||
ToolTip.visible: containsMouse
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: recentPathId.text
|
||||
QoAToolTip {
|
||||
visible: parent.containsMouse && recentPathId.text.length > 0
|
||||
text: recentPathId.text
|
||||
delay: 500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,9 +289,12 @@ Rectangle {
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Save chat to *.json file")
|
||||
|
||||
QoAToolTip {
|
||||
visible: saveButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Save chat to *.json file")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
@@ -218,9 +305,12 @@ Rectangle {
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Load chat from *.json file")
|
||||
|
||||
QoAToolTip {
|
||||
visible: loadButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Load chat from *.json file")
|
||||
}
|
||||
}
|
||||
|
||||
QoAButton {
|
||||
@@ -231,9 +321,12 @@ Rectangle {
|
||||
height: 15
|
||||
width: 15
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Show in system")
|
||||
|
||||
QoAToolTip {
|
||||
visible: openChatHistoryId.hovered
|
||||
delay: 250
|
||||
text: qsTr("Show in system")
|
||||
}
|
||||
}
|
||||
|
||||
QoASeparator {}
|
||||
@@ -248,9 +341,11 @@ Rectangle {
|
||||
width: 15
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("View chat context (system prompt, role, rules)")
|
||||
QoAToolTip {
|
||||
visible: contextButtonId.hovered
|
||||
delay: 250
|
||||
text: qsTr("View chat context (system prompt, role, rules)")
|
||||
}
|
||||
}
|
||||
|
||||
Badge {
|
||||
@@ -260,21 +355,6 @@ Rectangle {
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Current amount tokens in chat and LLM limit threshold")
|
||||
}
|
||||
|
||||
QoASeparator {}
|
||||
|
||||
QoAButton {
|
||||
id: clearButtonId
|
||||
|
||||
icon {
|
||||
source: "qrc:/qt/qml/ChatView/icons/clean-icon-dark.svg"
|
||||
height: 15
|
||||
width: 8
|
||||
}
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 250
|
||||
ToolTip.text: qsTr("Clean chat")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,21 @@ void LLMClientInterface::handleFullResponse(const QString &requestId, const QStr
|
||||
m_performanceLogger.endTimeMeasurement(requestId);
|
||||
}
|
||||
|
||||
void LLMClientInterface::handleRequestFinalized(
|
||||
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
|
||||
{
|
||||
if (!m_activeRequests.contains(requestId) || !info.usage)
|
||||
return;
|
||||
|
||||
const auto &u = *info.usage;
|
||||
LOG_MESSAGE(QString("Completion usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
|
||||
.arg(requestId)
|
||||
.arg(u.promptTokens)
|
||||
.arg(u.completionTokens)
|
||||
.arg(u.cachedPromptTokens)
|
||||
.arg(u.reasoningTokens));
|
||||
}
|
||||
|
||||
void LLMClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
|
||||
{
|
||||
auto it = m_activeRequests.find(requestId);
|
||||
@@ -325,6 +340,12 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
|
||||
this,
|
||||
&LLMClientInterface::handleFullResponse,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider->client(),
|
||||
&::LLMQore::BaseClient::requestFinalized,
|
||||
this,
|
||||
&LLMClientInterface::handleRequestFinalized,
|
||||
Qt::UniqueConnection);
|
||||
connect(
|
||||
provider->client(),
|
||||
&::LLMQore::BaseClient::requestFailed,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <languageclient/languageclientinterface.h>
|
||||
#include <texteditor/texteditor.h>
|
||||
|
||||
@@ -52,6 +53,8 @@ protected:
|
||||
|
||||
private slots:
|
||||
void handleFullResponse(const QString &requestId, const QString &fullText);
|
||||
void handleRequestFinalized(
|
||||
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info);
|
||||
void handleRequestFailed(const QString &requestId, const QString &error);
|
||||
|
||||
private:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Id" : "qodeassist",
|
||||
"Name" : "QodeAssist",
|
||||
"Version" : "0.9.13",
|
||||
"Version" : "0.9.18",
|
||||
"CompatVersion" : "${IDE_VERSION}",
|
||||
"Vendor" : "Petr Mironychev",
|
||||
"VendorId" : "petrmironychev",
|
||||
|
||||
@@ -10,4 +10,16 @@ const char MENU_ID[] = "QodeAssist.Menu";
|
||||
|
||||
const char QODE_ASSIST_REQUEST_SUGGESTION[] = "QodeAssist.RequestSuggestion";
|
||||
|
||||
const char QODE_ASSIST_CHAT_CONTEXT[] = "QodeAssist.ChatContext";
|
||||
const char QODE_ASSIST_CHAT_NAV_ID[] = "QodeAssistChat";
|
||||
const char QODE_ASSIST_CHAT_EDITOR_ID[] = "QodeAssist.ChatEditor";
|
||||
|
||||
const char QODE_ASSIST_SHOW_CHAT_ACTION[] = "QodeAssist.ShowChatView";
|
||||
const char QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION[] = "QodeAssist.OpenChatWindow";
|
||||
const char QODE_ASSIST_NEW_CHAT_ACTION[] = "QodeAssist.NewChat";
|
||||
|
||||
const char QODE_ASSIST_CHAT_SEND_MESSAGE[] = "QodeAssist.Chat.SendMessage";
|
||||
const char QODE_ASSIST_CHAT_CLEAR_SESSION[] = "QodeAssist.Chat.ClearSession";
|
||||
const char QODE_ASSIST_CHAT_SHOW_IN_RIGHT[] = "QodeAssist.Chat.ShowInRightSidebar";
|
||||
|
||||
} // namespace QodeAssist::Constants
|
||||
|
||||
@@ -152,6 +152,13 @@ void QuickRefactorHandler::prepareAndSendRequest(
|
||||
&QuickRefactorHandler::handleFullResponse,
|
||||
Qt::UniqueConnection);
|
||||
|
||||
connect(
|
||||
provider->client(),
|
||||
&::LLMQore::BaseClient::requestFinalized,
|
||||
this,
|
||||
&QuickRefactorHandler::handleRequestFinalized,
|
||||
Qt::UniqueConnection);
|
||||
|
||||
connect(
|
||||
provider->client(),
|
||||
&::LLMQore::BaseClient::requestFailed,
|
||||
@@ -378,25 +385,25 @@ void QuickRefactorHandler::handleLLMResponse(
|
||||
|
||||
void QuickRefactorHandler::cancelRequest()
|
||||
{
|
||||
if (m_isRefactoringInProgress) {
|
||||
auto id = m_lastRequestId;
|
||||
if (!m_isRefactoringInProgress)
|
||||
return;
|
||||
|
||||
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
|
||||
if (it.key() == id) {
|
||||
const RequestContext &ctx = it.value();
|
||||
ctx.provider->cancelRequest(id);
|
||||
m_activeRequests.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const auto id = m_lastRequestId;
|
||||
m_isRefactoringInProgress = false;
|
||||
m_lastRequestId.clear();
|
||||
|
||||
m_isRefactoringInProgress = false;
|
||||
|
||||
RefactorResult result;
|
||||
result.success = false;
|
||||
result.errorMessage = "Refactoring request was cancelled";
|
||||
emit refactoringCompleted(result);
|
||||
auto it = m_activeRequests.find(id);
|
||||
if (it != m_activeRequests.end()) {
|
||||
auto provider = it.value().provider;
|
||||
m_activeRequests.erase(it);
|
||||
if (provider)
|
||||
provider->cancelRequest(id);
|
||||
}
|
||||
|
||||
RefactorResult result;
|
||||
result.success = false;
|
||||
result.errorMessage = "Refactoring request was cancelled";
|
||||
emit refactoringCompleted(result);
|
||||
}
|
||||
|
||||
void QuickRefactorHandler::handleFullResponse(const QString &requestId, const QString &fullText)
|
||||
@@ -408,6 +415,22 @@ void QuickRefactorHandler::handleFullResponse(const QString &requestId, const QS
|
||||
}
|
||||
}
|
||||
|
||||
void QuickRefactorHandler::handleRequestFinalized(
|
||||
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
|
||||
{
|
||||
if (requestId != m_lastRequestId || !info.usage)
|
||||
return;
|
||||
|
||||
const auto &u = *info.usage;
|
||||
LOG_MESSAGE(
|
||||
QString("Quick refactor usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5")
|
||||
.arg(requestId)
|
||||
.arg(u.promptTokens)
|
||||
.arg(u.completionTokens)
|
||||
.arg(u.cachedPromptTokens)
|
||||
.arg(u.reasoningTokens));
|
||||
}
|
||||
|
||||
void QuickRefactorHandler::handleRequestFailed(const QString &requestId, const QString &error)
|
||||
{
|
||||
if (requestId == m_lastRequestId) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <texteditor/texteditor.h>
|
||||
#include <utils/textutils.h>
|
||||
|
||||
@@ -43,6 +44,8 @@ signals:
|
||||
|
||||
private slots:
|
||||
void handleFullResponse(const QString &requestId, const QString &fullText);
|
||||
void handleRequestFinalized(
|
||||
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info);
|
||||
void handleRequestFailed(const QString &requestId, const QString &error);
|
||||
|
||||
private:
|
||||
|
||||
78
README.md
78
README.md
@@ -6,7 +6,7 @@
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://discord.gg/BGMkUsXUgf)
|
||||
|
||||
 **QodeAssist** brings a full AI coding workflow to Qt Creator for C++ and QML — smart code completion, multi-panel chat, inline quick refactoring, and project-aware tool calling. It works with local runtimes (Ollama, llama.cpp, LM Studio) and cloud providers (Claude, OpenAI, Google AI, Mistral), can run as an **MCP server** so other clients reuse its project context, and can also act as an **MCP client** to consume tools from external MCP servers (authenticated MCP servers are not supported yet).
|
||||
 **QodeAssist** brings a full AI coding workflow to Qt Creator for C++ and QML — smart code completion, multi-panel chat, inline quick refactoring, and project-aware tool calling. It works with local runtimes (Ollama, llama.cpp, LM Studio) and cloud providers (Claude, OpenAI, Google AI, Mistral, Qwen, DeepSeek), can run as an **MCP server** so other clients reuse its project context, and can also act as an **MCP client** to consume tools from external MCP servers (authenticated MCP servers are not supported yet).
|
||||
|
||||
⚠️ **Important Notice About Paid Providers**
|
||||
> When using paid providers like Claude, OpenRouter or OpenAI-compatible services:
|
||||
@@ -35,10 +35,12 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance:
|
||||
- **Chat Assistant** — side panel, bottom panel, or detached window; history with auto-save, token monitoring, extended thinking
|
||||
- **Quick Refactoring** — inline AI-assisted edits directly in the editor with a searchable custom-instructions library
|
||||
- **Agent Tools** — read, search, create and edit files; build the project; run terminal commands; access linter/compiler issues; manage TODOs
|
||||
- **Agent Skills** — reusable folders of specialized instructions loaded on demand; discovered from `.qodeassist/skills/` and `.claude/skills/`, invoked automatically, with `/skill`, or always-on
|
||||
- **MCP Server** — expose QodeAssist's project-aware tools to external MCP clients (Claude Code, VS Code, Claude Desktop via bridge)
|
||||
- **MCP Client Hub** — connect QodeAssist to external MCP servers and use their tools in Chat and Quick Refactor (authenticated MCP servers are not supported yet)
|
||||
- **File Context** — attach, link, or auto-sync open editor files for richer prompts
|
||||
- **Many Providers** — Ollama, llama.cpp, LM Studio (Chat + Responses), Claude, OpenAI (Chat + Responses), Google AI, Mistral, Codestral, OpenRouter, any OpenAI-compatible endpoint
|
||||
- **Many Providers** — Ollama, llama.cpp, LM Studio (Chat + Responses), Claude, OpenAI (Chat + Responses), Google AI, Mistral, Codestral, OpenRouter, Qwen (OpenAI + Responses), DeepSeek, any OpenAI-compatible endpoint
|
||||
- **Reasoning / Thinking** — streamed chain-of-thought is shown for reasoning models across Claude, Google, OpenAI Responses, and any OpenAI-compatible endpoint that returns `reasoning_content` (DeepSeek, Qwen QwQ/Qwen3-Thinking, LM Studio, OpenRouter, …)
|
||||
- **Customizable** — per-project rules (`.qodeassist/rules/`), agent roles, reusable refactor templates, full prompt-template control
|
||||
|
||||
**Join our [Discord Community](https://discord.gg/BGMkUsXUgf)** to get support and connect with other users!
|
||||
@@ -53,6 +55,11 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance:
|
||||
<img src="https://github.com/user-attachments/assets/4a9092e0-429f-41eb-8723-cbb202fd0a8c" width="600" alt="QodeAssistPreview">
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Chat View Mode: (click to expand)</summary>
|
||||
<img src="https://github.com/user-attachments/assets/5914dd78-c8a4-4d35-889a-10ec493d4c4b" width="600" alt="QodeAssistChat2">
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Multiline Code completion: (click to expand)</summary>
|
||||
<img src="https://github.com/user-attachments/assets/c18dfbd2-8c54-4a7b-90d1-66e3bb51adb0" width="600" alt="QodeAssistPreview">
|
||||
@@ -85,7 +92,27 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance:
|
||||
|
||||
## Install plugin to QtCreator
|
||||
|
||||
### Method 1: Using QodeAssistUpdater (Beta)
|
||||
### Method 1: Using the Extension Registry (Recommended)
|
||||
|
||||
You can install and update QodeAssist directly from within Qt Creator by adding the QodeAssist registry as an external extension repository.
|
||||
|
||||
1. Open the Extensions page (`Qt Creator → Extensions`) and switch to the **Browser** tab
|
||||
2. Enable **Use External Repository**
|
||||
3. Next to **Repository URLs**, click **Add** and paste the registry archive URL matching your Qt Creator version:
|
||||
- **Latest (QtC 19)**: `https://github.com/Palm1r/extension-registry/archive/refs/heads/qodeassist.tar.gz`
|
||||
- **QtC 19**: `https://github.com/Palm1r/extension-registry/archive/refs/heads/qodeassist-qtc19.tar.gz`
|
||||
- **QtC 18**: `https://github.com/Palm1r/extension-registry/archive/refs/heads/qodeassist-qtc18.tar.gz`
|
||||
<details>
|
||||
<summary>Example of extension registry: (click to expand)</summary>
|
||||
<img width="600" alt="RegistryExample" src="https://github.com/user-attachments/assets/8ab8cf10-72e7-4961-8c5a-21d530378a05">
|
||||
</details>
|
||||
|
||||
4. Click **Apply** — QodeAssist will appear in the extensions list, where you can **Install** it
|
||||
5. Updates can be installed from the same screen when a new version is published
|
||||
|
||||
> **Note:** This is an external repository not maintained by The Qt Company. By adding it you accept responsibility for managing the associated risks, as stated in the Extensions page.
|
||||
|
||||
### Method 2: Using QodeAssistUpdater (Beta)
|
||||
|
||||
QodeAssistUpdater is a command-line utility that automates plugin installation and updates with automatic Qt Creator version detection and checksum verification.
|
||||
|
||||
@@ -113,7 +140,7 @@ Download pre-built binary from [QodeAssistUpdater releases](https://github.com/P
|
||||
|
||||
For more information, visit the [QodeAssistUpdater repository](https://github.com/Palm1r/QodeAssistUpdater).
|
||||
|
||||
### Method 2: Manual Installation
|
||||
### Method 3: Manual Installation
|
||||
|
||||
1. Install Latest Qt Creator
|
||||
2. Download the QodeAssist plugin for your Qt Creator
|
||||
@@ -143,6 +170,8 @@ The Quick Setup feature provides one-click configuration for popular cloud AI mo
|
||||
- **OpenAI** (gpt-5.2-codex)
|
||||
- **Mistral AI** (Codestral 2501)
|
||||
- **Google AI** (Gemini 2.5 Flash)
|
||||
- **Qwen** (Qwen3.6 Plus, Qwen3.7 Max)
|
||||
- **DeepSeek** (DeepSeek V4 Flash, DeepSeek V4 Pro)
|
||||
3. **Configure API Key** - Click "Configure API Key" button and enter your API key in Provider Settings
|
||||
|
||||
All settings (provider, model, template, URL) are configured automatically. Just add your API key and you're ready to go!
|
||||
@@ -163,6 +192,8 @@ For advanced users or local models, choose your preferred provider and follow th
|
||||
- **[OpenAI](docs/openai-configuration.md)** — Chat Completions and Responses API
|
||||
- **[Mistral AI](docs/mistral-configuration.md)** / **Codestral**
|
||||
- **[Google AI](docs/google-ai-configuration.md)** — Gemini
|
||||
- **Qwen (Alibaba)** — DashScope OpenAI-compatible Chat and Responses endpoints
|
||||
- **DeepSeek** — `deepseek-chat` and `deepseek-reasoner` (reasoning shown as thinking)
|
||||
- **OpenAI-compatible** — OpenRouter and any custom endpoint
|
||||
|
||||
### Recommended Models for Best Experience
|
||||
@@ -227,7 +258,7 @@ Configure in: `Tools → Options → QodeAssist → Code Completion → General
|
||||
- **[Chat Summarization](docs/chat-summarization.md)** - Compress long conversations into AI-generated summaries
|
||||
- **[File Context](docs/file-context.md)** - Attach or link files for better context
|
||||
- Automatic syncing with open editor files (optional)
|
||||
- Extended thinking mode (Claude, other providers in plan) - Enable deeper reasoning for complex tasks
|
||||
- Extended thinking / reasoning mode - shows streamed chain-of-thought for reasoning models (Claude, Google, OpenAI Responses, and OpenAI-compatible endpoints returning `reasoning_content` such as DeepSeek, Qwen, LM Studio, OpenRouter)
|
||||
|
||||
### Quick Refactoring
|
||||
- Inline code refactoring directly in the editor with AI assistance
|
||||
@@ -253,6 +284,41 @@ Chat and Quick Refactor can call tools to inspect and modify your project. Each
|
||||
| `execute_terminal_command` | Run a shell command (with confirmation) |
|
||||
| `todo_tool` | Track multi-step task progress during a conversation |
|
||||
|
||||
### Skills
|
||||
|
||||
**Agent Skills** package specialized instructions and workflows into reusable folders the AI loads on demand. QodeAssist implements the open [Agent Skills](https://agentskills.io) format, so skills authored for Claude Code, Cursor, or other agents work as-is.
|
||||
|
||||
A skill is a folder containing a `SKILL.md` file — YAML frontmatter (`name`, `description`) plus Markdown instructions:
|
||||
|
||||
```
|
||||
my-skill/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: What the skill does and when to use it.
|
||||
---
|
||||
|
||||
# My Skill
|
||||
|
||||
Step-by-step instructions for the task...
|
||||
```
|
||||
|
||||
**Where skills are discovered:**
|
||||
- **Project skills** — project-relative subdirectories (default `.qodeassist/skills/` and `.claude/skills/`), configured in `Projects → QodeAssist → Skills`. Project skills win over global ones on a name collision.
|
||||
- **Global skills** — absolute directories shared across all projects (default includes `~/.claude/skills/`), configured in `Tools → Options → QodeAssist → Skills`.
|
||||
|
||||
Both settings pages show the list of currently discovered skills.
|
||||
|
||||
**How skills are used in Chat:**
|
||||
- **Automatically** — each skill's name and description is added to the system prompt; when a request matches, the model loads the full instructions via the `load_skill` tool (requires a tool-calling model).
|
||||
- **Explicitly** — type `/` in the chat input and pick a skill from the popup; its instructions are injected into that one message. Works with any model.
|
||||
- **Always-on** — a skill whose frontmatter has `metadata: always-on: "true"` is injected into every chat request automatically.
|
||||
|
||||
Enable or disable the whole feature in `Tools → Options → QodeAssist → Skills`.
|
||||
|
||||
### MCP Server
|
||||
|
||||
QodeAssist can run an **MCP (Model Context Protocol) server** on `localhost`, exposing the tools above to external clients — so you can use QodeAssist's project awareness from Claude Code CLI, VS Code, Cursor, Claude Desktop, or any other MCP-capable client.
|
||||
@@ -454,6 +520,7 @@ For additional support, join our [Discord Community](https://discord.gg/BGMkUsXU
|
||||
- [x] Quick refactoring with custom-instructions library
|
||||
- [x] Diff sharing with models
|
||||
- [x] Tools / function calling (file I/O, build, terminal, diagnostics)
|
||||
- [x] Agent Skills (project + global directories, `/skill` commands, always-on, `load_skill` tool)
|
||||
- [x] Project-specific rules (`.qodeassist/rules/`)
|
||||
- [x] MCP (Model Context Protocol) — QodeAssist as a server
|
||||
- [x] MCP — QodeAssist as a client (consume external MCP tools; authenticated MCP servers not yet supported)
|
||||
@@ -470,6 +537,7 @@ If you find QodeAssist helpful, there are several ways you can support the proje
|
||||
3. **Spread the Word**: Star our GitHub repository and share QodeAssist with your fellow developers.
|
||||
|
||||
4. **Financial Support**: If you'd like to support the development financially, you can make a donation using one of the following:
|
||||
- Paypal: [my paypalme page](https://www.paypal.com/paypalme/palm1r)
|
||||
- Bitcoin (BTC): `bc1qndq7f0mpnlya48vk7kugvyqj5w89xrg4wzg68t`
|
||||
- Ethereum (ETH): `0xA5e8c37c94b24e25F9f1f292a01AF55F03099D8D`
|
||||
- Litecoin (LTC): `ltc1qlrxnk30s2pcjchzx4qrxvdjt5gzuervy5mv0vy`
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
@@ -9,6 +10,12 @@ Rectangle {
|
||||
property alias text: badgeText.text
|
||||
property alias hovered: mouse.hovered
|
||||
|
||||
QoAToolTip {
|
||||
visible: root.hovered && root.ToolTip.text.length > 0
|
||||
text: root.ToolTip.text
|
||||
delay: root.ToolTip.delay
|
||||
}
|
||||
|
||||
implicitWidth: badgeText.implicitWidth + root.radius
|
||||
implicitHeight: badgeText.implicitHeight + 6
|
||||
color: palette.button
|
||||
|
||||
@@ -7,6 +7,7 @@ import QtQuick.Controls.Basic
|
||||
Button {
|
||||
id: control
|
||||
|
||||
focusPolicy: Qt.NoFocus
|
||||
padding: 4
|
||||
|
||||
icon.width: 16
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
#include "UpdateStatusWidget.hpp"
|
||||
|
||||
#include <QMenu>
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
UpdateStatusWidget::UpdateStatusWidget(QWidget *parent)
|
||||
@@ -57,6 +59,16 @@ void UpdateStatusWidget::setChatButtonAction(QAction *action)
|
||||
m_chatButton->setDefaultAction(action);
|
||||
}
|
||||
|
||||
void UpdateStatusWidget::setChatButtonMenu(QMenu *menu)
|
||||
{
|
||||
m_chatButton->setMenu(menu);
|
||||
m_chatButton->setPopupMode(QToolButton::DelayedPopup);
|
||||
m_chatButton->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
connect(m_chatButton, &QWidget::customContextMenuRequested, m_chatButton, [this, menu](const QPoint &pos) {
|
||||
menu->exec(m_chatButton->mapToGlobal(pos));
|
||||
});
|
||||
}
|
||||
|
||||
QPushButton *UpdateStatusWidget::updateButton() const
|
||||
{
|
||||
return m_updateButton;
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
#include <QPushButton>
|
||||
#include <QToolButton>
|
||||
|
||||
QT_BEGIN_NAMESPACE
|
||||
class QMenu;
|
||||
QT_END_NAMESPACE
|
||||
|
||||
namespace QodeAssist {
|
||||
|
||||
class UpdateStatusWidget : public QFrame
|
||||
@@ -21,6 +25,7 @@ public:
|
||||
void showUpdateAvailable(const QString &version);
|
||||
void hideUpdateInfo();
|
||||
void setChatButtonAction(QAction *action);
|
||||
void setChatButtonMenu(QMenu *menu);
|
||||
|
||||
QPushButton *updateButton() const;
|
||||
|
||||
|
||||
47
chat/ChatDocument.cpp
Normal file
47
chat/ChatDocument.cpp
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "ChatDocument.hpp"
|
||||
|
||||
#include <utils/result.h>
|
||||
|
||||
#include "QodeAssistConstants.hpp"
|
||||
#include "QodeAssisttr.h"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatDocument::ChatDocument(QObject *parent)
|
||||
: Core::IDocument(parent)
|
||||
{
|
||||
setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID);
|
||||
setMimeType("text/plain");
|
||||
setTemporary(true);
|
||||
setPreferredDisplayName(Tr::tr("QodeAssist Chat"));
|
||||
}
|
||||
|
||||
QByteArray ChatDocument::contents() const
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
Utils::Result<> ChatDocument::setContents(const QByteArray &)
|
||||
{
|
||||
return Utils::ResultOk;
|
||||
}
|
||||
|
||||
bool ChatDocument::isModified() const
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChatDocument::isSaveAsAllowed() const
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChatDocument::shouldAutoSave() const
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
27
chat/ChatDocument.hpp
Normal file
27
chat/ChatDocument.hpp
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <coreplugin/idocument.h>
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
// Backing document for a chat editor view. The chat persists itself through its own
|
||||
// autosave history file, so this document is purely a placeholder for the editor area
|
||||
// and never participates in Qt Creator's save infrastructure.
|
||||
class ChatDocument : public Core::IDocument
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatDocument(QObject *parent = nullptr);
|
||||
|
||||
QByteArray contents() const override;
|
||||
Utils::Result<> setContents(const QByteArray &contents) override;
|
||||
bool isModified() const override;
|
||||
bool isSaveAsAllowed() const override;
|
||||
bool shouldAutoSave() const override;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
75
chat/ChatEditor.cpp
Normal file
75
chat/ChatEditor.cpp
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "ChatEditor.hpp"
|
||||
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
|
||||
#include "ChatDocument.hpp"
|
||||
#include "ChatView/ChatRootView.hpp"
|
||||
#include "ChatView/ChatWidget.hpp"
|
||||
#include "QodeAssistConstants.hpp"
|
||||
#include "QodeAssisttr.h"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatEditor::ChatEditor(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager)
|
||||
: m_engine(engine)
|
||||
, m_sessionFileRegistry(sessionFileRegistry)
|
||||
, m_skillsManager(skillsManager)
|
||||
, m_document(new ChatDocument(this))
|
||||
, m_chatWidget(new ChatWidget(engine, sessionFileRegistry, skillsManager, false))
|
||||
{
|
||||
setWidget(m_chatWidget);
|
||||
setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
|
||||
setDuplicateSupported(false);
|
||||
|
||||
if (auto rootView = qobject_cast<ChatRootView *>(m_chatWidget->rootObject())) {
|
||||
rootView->setInEditor(true);
|
||||
connect(
|
||||
rootView,
|
||||
&ChatRootView::closeHostRequested,
|
||||
this,
|
||||
[this] { Core::EditorManager::closeEditors({this}); },
|
||||
Qt::QueuedConnection);
|
||||
|
||||
auto syncTitle = [this, rootView] {
|
||||
const QString title = rootView->chatTitle();
|
||||
m_document->setPreferredDisplayName(
|
||||
title.isEmpty() ? Tr::tr("QodeAssist Chat") : QStringLiteral("QodeAssist - ") + title);
|
||||
};
|
||||
connect(rootView, &ChatRootView::chatTitleChanged, this, syncTitle);
|
||||
syncTitle();
|
||||
}
|
||||
}
|
||||
|
||||
void ChatEditor::consumePendingChatFile()
|
||||
{
|
||||
if (auto rootView = qobject_cast<ChatRootView *>(m_chatWidget->rootObject()))
|
||||
rootView->consumePendingChatFile();
|
||||
}
|
||||
|
||||
ChatEditor::~ChatEditor()
|
||||
{
|
||||
delete m_chatWidget;
|
||||
}
|
||||
|
||||
Core::IDocument *ChatEditor::document() const
|
||||
{
|
||||
return m_document;
|
||||
}
|
||||
|
||||
QWidget *ChatEditor::toolBar()
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Core::IEditor *ChatEditor::duplicate()
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
45
chat/ChatEditor.hpp
Normal file
45
chat/ChatEditor.hpp
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <coreplugin/editormanager/ieditor.h>
|
||||
|
||||
class QQmlEngine;
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class ChatDocument;
|
||||
class ChatWidget;
|
||||
class SessionFileRegistry;
|
||||
|
||||
class ChatEditor : public Core::IEditor
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ChatEditor(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager);
|
||||
~ChatEditor() override;
|
||||
|
||||
Core::IDocument *document() const override;
|
||||
QWidget *toolBar() override;
|
||||
Core::IEditor *duplicate() override;
|
||||
|
||||
void consumePendingChatFile();
|
||||
|
||||
private:
|
||||
QQmlEngine *m_engine;
|
||||
SessionFileRegistry *m_sessionFileRegistry;
|
||||
Skills::SkillsManager *m_skillsManager;
|
||||
ChatDocument *m_document;
|
||||
ChatWidget *m_chatWidget;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
24
chat/ChatEditorFactory.cpp
Normal file
24
chat/ChatEditorFactory.cpp
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "ChatEditorFactory.hpp"
|
||||
|
||||
#include "ChatEditor.hpp"
|
||||
#include "QodeAssistConstants.hpp"
|
||||
#include "QodeAssisttr.h"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatEditorFactory::ChatEditorFactory(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager)
|
||||
{
|
||||
setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID);
|
||||
setDisplayName(Tr::tr("QodeAssist Chat"));
|
||||
setEditorCreator([engine, sessionFileRegistry, skillsManager] {
|
||||
return new ChatEditor(engine, sessionFileRegistry, skillsManager);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
27
chat/ChatEditorFactory.hpp
Normal file
27
chat/ChatEditorFactory.hpp
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <coreplugin/editormanager/ieditorfactory.h>
|
||||
|
||||
class QQmlEngine;
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class SessionFileRegistry;
|
||||
|
||||
class ChatEditorFactory : public Core::IEditorFactory
|
||||
{
|
||||
public:
|
||||
ChatEditorFactory(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager);
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
@@ -7,9 +7,13 @@
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
ChatOutputPane::ChatOutputPane(QObject *parent)
|
||||
ChatOutputPane::ChatOutputPane(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager,
|
||||
QObject *parent)
|
||||
: Core::IOutputPane(parent)
|
||||
, m_chatWidget(new ChatWidget)
|
||||
, m_chatWidget{new ChatWidget{engine, sessionFileRegistry, skillsManager}}
|
||||
{
|
||||
setId("QodeAssistChat");
|
||||
setDisplayName(Tr::tr("QodeAssist Chat"));
|
||||
@@ -38,18 +42,20 @@ void ChatOutputPane::clearContents()
|
||||
|
||||
void ChatOutputPane::visibilityChanged(bool visible)
|
||||
{
|
||||
if (visible)
|
||||
if (visible) {
|
||||
m_chatWidget->scrollToBottom();
|
||||
m_chatWidget->focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
void ChatOutputPane::setFocus()
|
||||
{
|
||||
m_chatWidget->setFocus();
|
||||
m_chatWidget->focusInput();
|
||||
}
|
||||
|
||||
bool ChatOutputPane::hasFocus() const
|
||||
{
|
||||
return m_chatWidget->hasFocus();
|
||||
return m_chatWidget->isChatFocused();
|
||||
}
|
||||
|
||||
bool ChatOutputPane::canFocus() const
|
||||
|
||||
@@ -6,14 +6,24 @@
|
||||
#include "ChatView/ChatWidget.hpp"
|
||||
#include <coreplugin/ioutputpane.h>
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class SessionFileRegistry;
|
||||
|
||||
class ChatOutputPane : public Core::IOutputPane
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatOutputPane(QObject *parent = nullptr);
|
||||
explicit ChatOutputPane(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager,
|
||||
QObject *parent = nullptr);
|
||||
~ChatOutputPane() override;
|
||||
|
||||
QWidget *outputWidget(QWidget *parent) override;
|
||||
|
||||
@@ -4,14 +4,23 @@
|
||||
#include "NavigationPanel.hpp"
|
||||
|
||||
#include "ChatView/ChatWidget.hpp"
|
||||
#include "ChatView/SessionFileRegistry.hpp"
|
||||
#include "QodeAssistConstants.hpp"
|
||||
#include "sources/skills/SkillsManager.hpp"
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
NavigationPanel::NavigationPanel()
|
||||
NavigationPanel::NavigationPanel(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager)
|
||||
: m_engine{engine}
|
||||
, m_sessionFileRegistry{sessionFileRegistry}
|
||||
, m_skillsManager{skillsManager}
|
||||
{
|
||||
setDisplayName(tr("QodeAssist Chat"));
|
||||
setPriority(500);
|
||||
setId("QodeAssistChat");
|
||||
setId(Constants::QODE_ASSIST_CHAT_NAV_ID);
|
||||
setActivationSequence(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_C));
|
||||
}
|
||||
|
||||
@@ -19,10 +28,7 @@ NavigationPanel::~NavigationPanel() {}
|
||||
|
||||
Core::NavigationView NavigationPanel::createWidget()
|
||||
{
|
||||
Core::NavigationView view;
|
||||
view.widget = new ChatWidget;
|
||||
|
||||
return view;
|
||||
return {.widget = new ChatWidget{m_engine, m_sessionFileRegistry, m_skillsManager}};
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -5,17 +5,34 @@
|
||||
|
||||
#include <coreplugin/inavigationwidgetfactory.h>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
|
||||
class QQmlEngine;
|
||||
|
||||
namespace QodeAssist::Skills {
|
||||
class SkillsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Chat {
|
||||
|
||||
class SessionFileRegistry;
|
||||
|
||||
class NavigationPanel : public Core::INavigationWidgetFactory
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit NavigationPanel();
|
||||
explicit NavigationPanel(
|
||||
QQmlEngine *engine,
|
||||
SessionFileRegistry *sessionFileRegistry,
|
||||
Skills::SkillsManager *skillsManager);
|
||||
~NavigationPanel();
|
||||
|
||||
Core::NavigationView createWidget() override;
|
||||
|
||||
private:
|
||||
QPointer<QQmlEngine> m_engine;
|
||||
QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
||||
QPointer<Skills::SkillsManager> m_skillsManager;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Chat
|
||||
|
||||
@@ -15,6 +15,7 @@ add_library(Context STATIC
|
||||
target_link_libraries(Context
|
||||
PUBLIC
|
||||
Qt::Core
|
||||
Qt::Gui
|
||||
QtCreator::Core
|
||||
QtCreator::TextEditor
|
||||
QtCreator::Utils
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
|
||||
#include "TokenUtils.hpp"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QImageReader>
|
||||
#include <QSet>
|
||||
#include <QSize>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace QodeAssist::Context {
|
||||
|
||||
int TokenUtils::estimateTokens(const QString &text)
|
||||
@@ -15,8 +23,48 @@ int TokenUtils::estimateTokens(const QString &text)
|
||||
return text.length() / 4;
|
||||
}
|
||||
|
||||
bool TokenUtils::isImageFilePath(const QString &filePath)
|
||||
{
|
||||
static const QSet<QString> imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp"};
|
||||
return imageExtensions.contains(QFileInfo(filePath).suffix().toLower());
|
||||
}
|
||||
|
||||
int TokenUtils::estimateImageAttachmentTokens(const QString &filePath)
|
||||
{
|
||||
QImageReader reader(filePath);
|
||||
QSize size = reader.size();
|
||||
if (!size.isValid() || size.isEmpty())
|
||||
return 1500;
|
||||
|
||||
double w = size.width();
|
||||
double h = size.height();
|
||||
|
||||
const double longSide = std::max(w, h);
|
||||
if (longSide > 2048.0) {
|
||||
const double s = 2048.0 / longSide;
|
||||
w *= s;
|
||||
h *= s;
|
||||
}
|
||||
|
||||
const double shortSide = std::min(w, h);
|
||||
if (shortSide > 768.0) {
|
||||
const double s = 768.0 / shortSide;
|
||||
w *= s;
|
||||
h *= s;
|
||||
}
|
||||
|
||||
const int tilesW = static_cast<int>(std::ceil(w / 512.0));
|
||||
const int tilesH = static_cast<int>(std::ceil(h / 512.0));
|
||||
const int tiles = std::max(1, tilesW * tilesH);
|
||||
|
||||
return 85 + tiles * 170;
|
||||
}
|
||||
|
||||
int TokenUtils::estimateFileTokens(const Context::ContentFile &file)
|
||||
{
|
||||
if (isImageFilePath(file.filename))
|
||||
return estimateImageAttachmentTokens(QString());
|
||||
|
||||
int total = 0;
|
||||
|
||||
total += estimateTokens(file.filename);
|
||||
|
||||
@@ -15,6 +15,8 @@ public:
|
||||
static int estimateTokens(const QString &text);
|
||||
static int estimateFileTokens(const Context::ContentFile &file);
|
||||
static int estimateFilesTokens(const QList<Context::ContentFile> &files);
|
||||
static bool isImageFilePath(const QString &filePath);
|
||||
static int estimateImageAttachmentTokens(const QString &filePath);
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Context
|
||||
|
||||
@@ -189,19 +189,42 @@ QList<PluginLLMCore::Provider *> McpClientsManager::toolsCapableProviders() cons
|
||||
return out;
|
||||
}
|
||||
|
||||
QJsonObject McpClientsManager::builtinServers()
|
||||
{
|
||||
static const QByteArray pseudoConfig(
|
||||
"{\n"
|
||||
" \"mcpServers\": {\n"
|
||||
" \"qt-docs\": {\n"
|
||||
" \"type\": \"sse\",\n"
|
||||
" \"url\": \"https://qt-docs-mcp.qt.io/mcp\",\n"
|
||||
" \"enable\": false\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n");
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(pseudoConfig);
|
||||
return doc.object().value(QLatin1String(kServersKey)).toObject();
|
||||
}
|
||||
|
||||
QJsonObject McpClientsManager::readRoot() const
|
||||
{
|
||||
QJsonObject root{{QLatin1String(kServersKey), QJsonObject{}}};
|
||||
|
||||
QFile f(configFilePath());
|
||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
return QJsonObject{{QLatin1String(kServersKey), QJsonObject{}}};
|
||||
QJsonParseError err;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &err);
|
||||
f.close();
|
||||
if (err.error != QJsonParseError::NoError || !doc.isObject())
|
||||
return QJsonObject{{QLatin1String(kServersKey), QJsonObject{}}};
|
||||
QJsonObject root = doc.object();
|
||||
if (!root.contains(QLatin1String(kServersKey)))
|
||||
root.insert(QLatin1String(kServersKey), QJsonObject{});
|
||||
if (f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
QJsonParseError err;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &err);
|
||||
f.close();
|
||||
if (err.error == QJsonParseError::NoError && doc.isObject())
|
||||
root = doc.object();
|
||||
}
|
||||
|
||||
QJsonObject servers = root.value(QLatin1String(kServersKey)).toObject();
|
||||
const QJsonObject builtin = builtinServers();
|
||||
for (auto it = builtin.begin(); it != builtin.end(); ++it) {
|
||||
if (!servers.contains(it.key()))
|
||||
servers.insert(it.key(), it.value());
|
||||
}
|
||||
root.insert(QLatin1String(kServersKey), servers);
|
||||
return root;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ private:
|
||||
void updateWatchedPaths();
|
||||
|
||||
QList<PluginLLMCore::Provider *> toolsCapableProviders() const;
|
||||
static QJsonObject builtinServers();
|
||||
QJsonObject readRoot() const;
|
||||
bool writeRoot(const QJsonObject &root);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
@@ -17,6 +18,15 @@ struct ImageAttachment
|
||||
bool operator==(const ImageAttachment &) const = default;
|
||||
};
|
||||
|
||||
struct ToolCall
|
||||
{
|
||||
QString id;
|
||||
QString name;
|
||||
QJsonObject arguments;
|
||||
|
||||
bool operator==(const ToolCall &) const = default;
|
||||
};
|
||||
|
||||
struct Message
|
||||
{
|
||||
QString role;
|
||||
@@ -26,6 +36,10 @@ struct Message
|
||||
bool isRedacted = false;
|
||||
std::optional<QVector<ImageAttachment>> images;
|
||||
|
||||
QVector<ToolCall> toolCalls;
|
||||
QString toolCallId;
|
||||
QString toolName;
|
||||
|
||||
// clang-format off
|
||||
bool operator==(const Message&) const = default;
|
||||
// clang-format on
|
||||
|
||||
@@ -25,12 +25,8 @@ public:
|
||||
virtual QString description() const = 0;
|
||||
virtual bool isSupportProvider(ProviderID id) const = 0;
|
||||
|
||||
// Endpoint path this template expects to be sent to. Empty string
|
||||
// (default) means "let the provider's client use its standard chat
|
||||
// path" (/chat/completions, /api/chat, /v1/messages, ...). Templates
|
||||
// producing non-chat payload shapes (e.g. {prompt, suffix} for
|
||||
// Mistral FIM, {input_prefix, input_suffix} for llama.cpp infill)
|
||||
// must override this to the path their payload is valid for.
|
||||
virtual QString endpoint() const { return {}; }
|
||||
|
||||
virtual bool supportsToolHistory() const { return false; }
|
||||
};
|
||||
} // namespace QodeAssist::PluginLLMCore
|
||||
|
||||
@@ -16,7 +16,7 @@ Provider::Provider(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
RequestID Provider::sendRequest(
|
||||
LLMQore::RequestID Provider::sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||
{
|
||||
auto *c = client();
|
||||
@@ -35,7 +35,7 @@ RequestID Provider::sendRequest(
|
||||
return requestId;
|
||||
}
|
||||
|
||||
void Provider::cancelRequest(const RequestID &requestId)
|
||||
void Provider::cancelRequest(const LLMQore::RequestID &requestId)
|
||||
{
|
||||
LOG_MESSAGE(QString("%1: Cancelling request %2").arg(name(), requestId));
|
||||
client()->cancelRequest(requestId);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
#include "ContextData.hpp"
|
||||
#include "PromptTemplate.hpp"
|
||||
#include "LLMQore/BaseClient.hpp"
|
||||
#include "RequestType.hpp"
|
||||
|
||||
namespace LLMQore {
|
||||
@@ -56,9 +57,9 @@ public:
|
||||
virtual ::LLMQore::BaseClient *client() const = 0;
|
||||
virtual QString apiKey() const = 0;
|
||||
|
||||
virtual RequestID sendRequest(
|
||||
virtual LLMQore::RequestID sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint);
|
||||
void cancelRequest(const RequestID &requestId);
|
||||
void cancelRequest(const LLMQore::RequestID &requestId);
|
||||
::LLMQore::ToolsManager *toolsManager() const;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ enum class ProviderID {
|
||||
MistralAI,
|
||||
OpenRouter,
|
||||
GoogleAI,
|
||||
LlamaCpp
|
||||
LlamaCpp,
|
||||
Qwen,
|
||||
DeepSeek
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,5 +9,4 @@ namespace QodeAssist::PluginLLMCore {
|
||||
|
||||
enum RequestType { CodeCompletion, Chat, Embedding, QuickRefactoring };
|
||||
|
||||
using RequestID = QString;
|
||||
}
|
||||
|
||||
89
providers/ClaudeCacheControl.hpp
Normal file
89
providers/ClaudeCacheControl.hpp
Normal file
@@ -0,0 +1,89 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::Providers::ClaudeCacheControl {
|
||||
|
||||
inline QJsonObject buildBreakpoint(bool extendedTtl)
|
||||
{
|
||||
QJsonObject cacheControl{{"type", "ephemeral"}};
|
||||
if (extendedTtl)
|
||||
cacheControl["ttl"] = "1h";
|
||||
return cacheControl;
|
||||
}
|
||||
|
||||
inline void markLastBlock(QJsonArray &blocks, const QJsonObject &cacheControl)
|
||||
{
|
||||
if (blocks.isEmpty())
|
||||
return;
|
||||
QJsonObject last = blocks.last().toObject();
|
||||
last["cache_control"] = cacheControl;
|
||||
blocks.replace(blocks.size() - 1, last);
|
||||
}
|
||||
|
||||
inline void applyToSystem(QJsonObject &request, const QJsonObject &cacheControl)
|
||||
{
|
||||
if (!request.contains("system"))
|
||||
return;
|
||||
|
||||
const QJsonValue sys = request.value("system");
|
||||
if (sys.isString()) {
|
||||
const QString text = sys.toString();
|
||||
if (!text.isEmpty()) {
|
||||
request["system"] = QJsonArray{QJsonObject{
|
||||
{"type", "text"}, {"text", text}, {"cache_control", cacheControl}}};
|
||||
}
|
||||
} else if (sys.isArray()) {
|
||||
QJsonArray blocks = sys.toArray();
|
||||
markLastBlock(blocks, cacheControl);
|
||||
request["system"] = blocks;
|
||||
}
|
||||
}
|
||||
|
||||
inline void applyToTools(QJsonObject &request, const QJsonObject &cacheControl)
|
||||
{
|
||||
if (!request.contains("tools"))
|
||||
return;
|
||||
QJsonArray tools = request.value("tools").toArray();
|
||||
markLastBlock(tools, cacheControl);
|
||||
request["tools"] = tools;
|
||||
}
|
||||
|
||||
inline void applyToHistory(QJsonObject &request, const QJsonObject &cacheControl)
|
||||
{
|
||||
if (!request.contains("messages"))
|
||||
return;
|
||||
QJsonArray messages = request.value("messages").toArray();
|
||||
if (messages.size() < 2)
|
||||
return;
|
||||
|
||||
const int idx = messages.size() - 2;
|
||||
QJsonObject msg = messages[idx].toObject();
|
||||
const QJsonValue content = msg.value("content");
|
||||
if (content.isString()) {
|
||||
msg["content"] = QJsonArray{QJsonObject{
|
||||
{"type", "text"}, {"text", content.toString()}, {"cache_control", cacheControl}}};
|
||||
} else if (content.isArray()) {
|
||||
QJsonArray blocks = content.toArray();
|
||||
markLastBlock(blocks, cacheControl);
|
||||
msg["content"] = blocks;
|
||||
}
|
||||
messages.replace(idx, msg);
|
||||
request["messages"] = messages;
|
||||
}
|
||||
|
||||
inline void apply(QJsonObject &request, bool extendedTtl)
|
||||
{
|
||||
const QJsonObject cacheControl = buildBreakpoint(extendedTtl);
|
||||
applyToSystem(request, cacheControl);
|
||||
applyToTools(request, cacheControl);
|
||||
applyToHistory(request, cacheControl);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Providers::ClaudeCacheControl
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
|
||||
#include "ClaudeCacheControl.hpp"
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
@@ -104,6 +105,14 @@ void ClaudeProvider::prepareRequest(
|
||||
LOG_MESSAGE(QString("Added %1 tools to Claude request").arg(toolsDefinitions.size()));
|
||||
}
|
||||
}
|
||||
|
||||
const auto &ps = Settings::providerSettings();
|
||||
const bool cachingOn = ps.claudeEnablePromptCaching()
|
||||
&& type != PluginLLMCore::RequestType::CodeCompletion;
|
||||
m_client->setUseExtendedCacheTTL(cachingOn && ps.claudeUseExtendedCacheTTL());
|
||||
if (cachingOn) {
|
||||
ClaudeCacheControl::apply(request, ps.claudeUseExtendedCacheTTL());
|
||||
}
|
||||
}
|
||||
|
||||
QFuture<QList<QString>> ClaudeProvider::getInstalledModels(const QString &baseUrl)
|
||||
|
||||
110
providers/DeepSeekProvider.cpp
Normal file
110
providers/DeepSeekProvider.cpp
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "DeepSeekProvider.hpp"
|
||||
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProviderSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "tools/ToolsRegistration.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
DeepSeekProvider::DeepSeekProvider(QObject *parent)
|
||||
: PluginLLMCore::Provider(parent)
|
||||
, m_client(new ::LLMQore::OpenAIClient(QString(), QString(), QString(), this))
|
||||
{
|
||||
Tools::registerQodeAssistTools(m_client->tools());
|
||||
}
|
||||
|
||||
QString DeepSeekProvider::name() const
|
||||
{
|
||||
return "DeepSeek";
|
||||
}
|
||||
|
||||
QString DeepSeekProvider::apiKey() const
|
||||
{
|
||||
return Settings::providerSettings().deepSeekApiKey();
|
||||
}
|
||||
|
||||
QString DeepSeekProvider::url() const
|
||||
{
|
||||
return "https://api.deepseek.com";
|
||||
}
|
||||
|
||||
QFuture<QList<QString>> DeepSeekProvider::getInstalledModels(const QString &url)
|
||||
{
|
||||
m_client->setUrl(url);
|
||||
m_client->setApiKey(apiKey());
|
||||
return m_client->listModels();
|
||||
}
|
||||
|
||||
PluginLLMCore::ProviderID DeepSeekProvider::providerID() const
|
||||
{
|
||||
return PluginLLMCore::ProviderID::DeepSeek;
|
||||
}
|
||||
|
||||
PluginLLMCore::ProviderCapabilities DeepSeekProvider::capabilities() const
|
||||
{
|
||||
return PluginLLMCore::ProviderCapability::Tools
|
||||
| PluginLLMCore::ProviderCapability::Thinking
|
||||
| PluginLLMCore::ProviderCapability::ModelListing;
|
||||
}
|
||||
|
||||
void DeepSeekProvider::prepareRequest(
|
||||
QJsonObject &request,
|
||||
PluginLLMCore::PromptTemplate *prompt,
|
||||
PluginLLMCore::ContextData context,
|
||||
PluginLLMCore::RequestType type,
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name()));
|
||||
}
|
||||
|
||||
prompt->prepareRequest(request, context);
|
||||
|
||||
auto applyModelParams = [&request](const auto &settings) {
|
||||
request["max_tokens"] = settings.maxTokens();
|
||||
request["temperature"] = settings.temperature();
|
||||
|
||||
if (settings.useTopP())
|
||||
request["top_p"] = settings.topP();
|
||||
if (settings.useFrequencyPenalty())
|
||||
request["frequency_penalty"] = settings.frequencyPenalty();
|
||||
if (settings.usePresencePenalty())
|
||||
request["presence_penalty"] = settings.presencePenalty();
|
||||
};
|
||||
|
||||
if (type == PluginLLMCore::RequestType::CodeCompletion) {
|
||||
applyModelParams(Settings::codeCompletionSettings());
|
||||
} else if (type == PluginLLMCore::RequestType::QuickRefactoring) {
|
||||
applyModelParams(Settings::quickRefactorSettings());
|
||||
} else {
|
||||
applyModelParams(Settings::chatAssistantSettings());
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
auto toolsDefinitions = m_client->tools()->getToolsDefinitions();
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
LOG_MESSAGE(QString("Added %1 tools to DeepSeek request").arg(toolsDefinitions.size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::LLMQore::BaseClient *DeepSeekProvider::client() const
|
||||
{
|
||||
return m_client;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
37
providers/DeepSeekProvider.hpp
Normal file
37
providers/DeepSeekProvider.hpp
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <LLMQore/OpenAIClient.hpp>
|
||||
#include <pluginllmcore/Provider.hpp>
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
class DeepSeekProvider : public PluginLLMCore::Provider
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit DeepSeekProvider(QObject *parent = nullptr);
|
||||
|
||||
QString name() const override;
|
||||
QString url() const override;
|
||||
void prepareRequest(
|
||||
QJsonObject &request,
|
||||
PluginLLMCore::PromptTemplate *prompt,
|
||||
PluginLLMCore::ContextData context,
|
||||
PluginLLMCore::RequestType type,
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled) override;
|
||||
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
|
||||
PluginLLMCore::ProviderID providerID() const override;
|
||||
PluginLLMCore::ProviderCapabilities capabilities() const override;
|
||||
|
||||
::LLMQore::BaseClient *client() const override;
|
||||
QString apiKey() const override;
|
||||
|
||||
private:
|
||||
::LLMQore::OpenAIClient *m_client;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
@@ -139,14 +139,9 @@ PluginLLMCore::ProviderCapabilities GoogleAIProvider::capabilities() const
|
||||
| PluginLLMCore::ProviderCapability::ModelListing;
|
||||
}
|
||||
|
||||
PluginLLMCore::RequestID GoogleAIProvider::sendRequest(
|
||||
LLMQore::RequestID GoogleAIProvider::sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||
{
|
||||
// Gemini takes the model from the URL path and streaming from the
|
||||
// action suffix (:streamGenerateContent vs :generateContent), and
|
||||
// rejects unknown top-level body fields. The shared call-site seeds
|
||||
// payload with {model, stream}; consume them here into client state
|
||||
// before they hit the wire.
|
||||
QJsonObject cleaned = payload;
|
||||
if (cleaned.contains("model")) {
|
||||
m_client->setModel(cleaned["model"].toString());
|
||||
|
||||
@@ -28,7 +28,7 @@ public:
|
||||
PluginLLMCore::ProviderID providerID() const override;
|
||||
PluginLLMCore::ProviderCapabilities capabilities() const override;
|
||||
|
||||
PluginLLMCore::RequestID sendRequest(
|
||||
LLMQore::RequestID sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
||||
|
||||
::LLMQore::BaseClient *client() const override;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
|
||||
#include "providers/ProviderUrlUtils.hpp"
|
||||
#include "tools/ToolsRegistration.hpp"
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
@@ -38,12 +39,12 @@ QString LMStudioProvider::apiKey() const
|
||||
|
||||
QString LMStudioProvider::url() const
|
||||
{
|
||||
return "http://localhost:1234/v1";
|
||||
return "http://localhost:1234";
|
||||
}
|
||||
|
||||
QFuture<QList<QString>> LMStudioProvider::getInstalledModels(const QString &url)
|
||||
{
|
||||
m_client->setUrl(url);
|
||||
m_client->setUrl(ensureOpenAIV1Base(url));
|
||||
m_client->setApiKey(apiKey());
|
||||
return m_client->listModels();
|
||||
}
|
||||
@@ -56,7 +57,8 @@ PluginLLMCore::ProviderID LMStudioProvider::providerID() const
|
||||
PluginLLMCore::ProviderCapabilities LMStudioProvider::capabilities() const
|
||||
{
|
||||
return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image
|
||||
| PluginLLMCore::ProviderCapability::ModelListing;
|
||||
| PluginLLMCore::ProviderCapability::ModelListing
|
||||
| PluginLLMCore::ProviderCapability::Thinking;
|
||||
}
|
||||
|
||||
void LMStudioProvider::prepareRequest(
|
||||
@@ -104,6 +106,13 @@ void LMStudioProvider::prepareRequest(
|
||||
}
|
||||
}
|
||||
|
||||
LLMQore::RequestID LMStudioProvider::sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||
{
|
||||
return PluginLLMCore::Provider::sendRequest(
|
||||
QUrl(ensureOpenAIV1Base(url.toString())), payload, endpoint);
|
||||
}
|
||||
|
||||
::LLMQore::BaseClient *LMStudioProvider::client() const
|
||||
{
|
||||
return m_client;
|
||||
|
||||
@@ -30,6 +30,9 @@ public:
|
||||
::LLMQore::BaseClient *client() const override;
|
||||
QString apiKey() const override;
|
||||
|
||||
LLMQore::RequestID sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
||||
|
||||
private:
|
||||
::LLMQore::OpenAIClient *m_client;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
|
||||
#include "logger/Logger.hpp"
|
||||
#include "providers/ProviderUrlUtils.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
@@ -112,20 +113,16 @@ void LMStudioResponsesProvider::prepareRequest(
|
||||
|
||||
QFuture<QList<QString>> LMStudioResponsesProvider::getInstalledModels(const QString &baseUrl)
|
||||
{
|
||||
QString url = baseUrl;
|
||||
if (!url.endsWith(QStringLiteral("/v1")))
|
||||
url += QStringLiteral("/v1");
|
||||
m_client->setUrl(url);
|
||||
m_client->setUrl(ensureOpenAIV1Base(baseUrl));
|
||||
m_client->setApiKey(apiKey());
|
||||
return m_client->listModels();
|
||||
}
|
||||
|
||||
PluginLLMCore::RequestID LMStudioResponsesProvider::sendRequest(
|
||||
LLMQore::RequestID LMStudioResponsesProvider::sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||
{
|
||||
const QString effectiveEndpoint
|
||||
= endpoint.isEmpty() ? QStringLiteral("/v1/responses") : endpoint;
|
||||
return PluginLLMCore::Provider::sendRequest(url, payload, effectiveEndpoint);
|
||||
return PluginLLMCore::Provider::sendRequest(
|
||||
QUrl(ensureOpenAIV1Base(url.toString())), payload, endpoint);
|
||||
}
|
||||
|
||||
PluginLLMCore::ProviderID LMStudioResponsesProvider::providerID() const
|
||||
|
||||
@@ -30,7 +30,7 @@ public:
|
||||
::LLMQore::BaseClient *client() const override;
|
||||
QString apiKey() const override;
|
||||
|
||||
PluginLLMCore::RequestID sendRequest(
|
||||
LLMQore::RequestID sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
||||
|
||||
private:
|
||||
|
||||
@@ -55,7 +55,8 @@ PluginLLMCore::ProviderID MistralAIProvider::providerID() const
|
||||
PluginLLMCore::ProviderCapabilities MistralAIProvider::capabilities() const
|
||||
{
|
||||
return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image
|
||||
| PluginLLMCore::ProviderCapability::ModelListing;
|
||||
| PluginLLMCore::ProviderCapability::ModelListing
|
||||
| PluginLLMCore::ProviderCapability::Thinking;
|
||||
}
|
||||
|
||||
void MistralAIProvider::prepareRequest(
|
||||
|
||||
@@ -97,7 +97,7 @@ QFuture<QList<QString>> OllamaCompatProvider::getInstalledModels(const QString &
|
||||
return m_client->listModels();
|
||||
}
|
||||
|
||||
PluginLLMCore::RequestID OllamaCompatProvider::sendRequest(
|
||||
LLMQore::RequestID OllamaCompatProvider::sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||
{
|
||||
const QString effectiveEndpoint
|
||||
@@ -113,7 +113,8 @@ PluginLLMCore::ProviderID OllamaCompatProvider::providerID() const
|
||||
PluginLLMCore::ProviderCapabilities OllamaCompatProvider::capabilities() const
|
||||
{
|
||||
return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image
|
||||
| PluginLLMCore::ProviderCapability::ModelListing;
|
||||
| PluginLLMCore::ProviderCapability::ModelListing
|
||||
| PluginLLMCore::ProviderCapability::Thinking;
|
||||
}
|
||||
|
||||
::LLMQore::BaseClient *OllamaCompatProvider::client() const
|
||||
|
||||
@@ -30,7 +30,7 @@ public:
|
||||
::LLMQore::BaseClient *client() const override;
|
||||
QString apiKey() const override;
|
||||
|
||||
PluginLLMCore::RequestID sendRequest(
|
||||
LLMQore::RequestID sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
||||
|
||||
private:
|
||||
|
||||
@@ -98,7 +98,8 @@ PluginLLMCore::ProviderID OpenAICompatProvider::providerID() const
|
||||
|
||||
PluginLLMCore::ProviderCapabilities OpenAICompatProvider::capabilities() const
|
||||
{
|
||||
return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image;
|
||||
return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image
|
||||
| PluginLLMCore::ProviderCapability::Thinking;
|
||||
}
|
||||
|
||||
::LLMQore::BaseClient *OpenAICompatProvider::client() const
|
||||
|
||||
@@ -129,7 +129,8 @@ PluginLLMCore::ProviderID OpenAIProvider::providerID() const
|
||||
PluginLLMCore::ProviderCapabilities OpenAIProvider::capabilities() const
|
||||
{
|
||||
return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image
|
||||
| PluginLLMCore::ProviderCapability::ModelListing;
|
||||
| PluginLLMCore::ProviderCapability::ModelListing
|
||||
| PluginLLMCore::ProviderCapability::Thinking;
|
||||
}
|
||||
|
||||
::LLMQore::BaseClient *OpenAIProvider::client() const
|
||||
|
||||
23
providers/ProviderUrlUtils.hpp
Normal file
23
providers/ProviderUrlUtils.hpp
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
// LM Studio presents its OpenAI-compatible API as <host>/v1/..., while the
|
||||
// OpenAI-style clients expect the base URL to already include /v1. Accept the
|
||||
// configured URL either with or without the /v1 suffix and return it normalized.
|
||||
inline QString ensureOpenAIV1Base(const QString &url)
|
||||
{
|
||||
QString base = url.trimmed();
|
||||
while (base.endsWith(QLatin1Char('/')))
|
||||
base.chop(1);
|
||||
if (!base.endsWith(QStringLiteral("/v1")))
|
||||
base += QStringLiteral("/v1");
|
||||
return base;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include "pluginllmcore/ProvidersManager.hpp"
|
||||
#include "providers/ClaudeProvider.hpp"
|
||||
#include "providers/DeepSeekProvider.hpp"
|
||||
#include "providers/CodestralProvider.hpp"
|
||||
#include "providers/GoogleAIProvider.hpp"
|
||||
#include "providers/LMStudioProvider.hpp"
|
||||
@@ -17,6 +18,8 @@
|
||||
#include "providers/OpenAIProvider.hpp"
|
||||
#include "providers/OpenAIResponsesProvider.hpp"
|
||||
#include "providers/OpenRouterAIProvider.hpp"
|
||||
#include "providers/QwenProvider.hpp"
|
||||
#include "providers/QwenResponsesProvider.hpp"
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
@@ -36,6 +39,9 @@ inline void registerProviders()
|
||||
providerManager.registerProvider<GoogleAIProvider>();
|
||||
providerManager.registerProvider<LlamaCppProvider>();
|
||||
providerManager.registerProvider<CodestralProvider>();
|
||||
providerManager.registerProvider<QwenProvider>();
|
||||
providerManager.registerProvider<QwenResponsesProvider>();
|
||||
providerManager.registerProvider<DeepSeekProvider>();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
|
||||
112
providers/QwenProvider.cpp
Normal file
112
providers/QwenProvider.cpp
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "QwenProvider.hpp"
|
||||
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProviderSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "tools/ToolsRegistration.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
QwenProvider::QwenProvider(QObject *parent)
|
||||
: PluginLLMCore::Provider(parent)
|
||||
, m_client(new ::LLMQore::OpenAIClient(QString(), QString(), QString(), this))
|
||||
{
|
||||
Tools::registerQodeAssistTools(m_client->tools());
|
||||
}
|
||||
|
||||
QString QwenProvider::name() const
|
||||
{
|
||||
return "Qwen (OpenAI)";
|
||||
}
|
||||
|
||||
QString QwenProvider::apiKey() const
|
||||
{
|
||||
return Settings::providerSettings().qwenApiKey();
|
||||
}
|
||||
|
||||
QString QwenProvider::url() const
|
||||
{
|
||||
return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
|
||||
}
|
||||
|
||||
QFuture<QList<QString>> QwenProvider::getInstalledModels(const QString &url)
|
||||
{
|
||||
m_client->setUrl(url);
|
||||
m_client->setApiKey(apiKey());
|
||||
return m_client->listModels();
|
||||
}
|
||||
|
||||
PluginLLMCore::ProviderID QwenProvider::providerID() const
|
||||
{
|
||||
return PluginLLMCore::ProviderID::Qwen;
|
||||
}
|
||||
|
||||
PluginLLMCore::ProviderCapabilities QwenProvider::capabilities() const
|
||||
{
|
||||
return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image
|
||||
| PluginLLMCore::ProviderCapability::Thinking
|
||||
| PluginLLMCore::ProviderCapability::ModelListing;
|
||||
}
|
||||
|
||||
void QwenProvider::prepareRequest(
|
||||
QJsonObject &request,
|
||||
PluginLLMCore::PromptTemplate *prompt,
|
||||
PluginLLMCore::ContextData context,
|
||||
PluginLLMCore::RequestType type,
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name()));
|
||||
}
|
||||
|
||||
prompt->prepareRequest(request, context);
|
||||
|
||||
auto applyModelParams = [&request](const auto &settings) {
|
||||
request["max_tokens"] = settings.maxTokens();
|
||||
request["temperature"] = settings.temperature();
|
||||
|
||||
if (settings.useTopP())
|
||||
request["top_p"] = settings.topP();
|
||||
if (settings.useTopK())
|
||||
request["top_k"] = settings.topK();
|
||||
if (settings.useFrequencyPenalty())
|
||||
request["frequency_penalty"] = settings.frequencyPenalty();
|
||||
if (settings.usePresencePenalty())
|
||||
request["presence_penalty"] = settings.presencePenalty();
|
||||
};
|
||||
|
||||
if (type == PluginLLMCore::RequestType::CodeCompletion) {
|
||||
applyModelParams(Settings::codeCompletionSettings());
|
||||
} else if (type == PluginLLMCore::RequestType::QuickRefactoring) {
|
||||
applyModelParams(Settings::quickRefactorSettings());
|
||||
} else {
|
||||
applyModelParams(Settings::chatAssistantSettings());
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
auto toolsDefinitions = m_client->tools()->getToolsDefinitions();
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
LOG_MESSAGE(QString("Added %1 tools to Qwen request").arg(toolsDefinitions.size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::LLMQore::BaseClient *QwenProvider::client() const
|
||||
{
|
||||
return m_client;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
37
providers/QwenProvider.hpp
Normal file
37
providers/QwenProvider.hpp
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <LLMQore/OpenAIClient.hpp>
|
||||
#include <pluginllmcore/Provider.hpp>
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
class QwenProvider : public PluginLLMCore::Provider
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit QwenProvider(QObject *parent = nullptr);
|
||||
|
||||
QString name() const override;
|
||||
QString url() const override;
|
||||
void prepareRequest(
|
||||
QJsonObject &request,
|
||||
PluginLLMCore::PromptTemplate *prompt,
|
||||
PluginLLMCore::ContextData context,
|
||||
PluginLLMCore::RequestType type,
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled) override;
|
||||
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
|
||||
PluginLLMCore::ProviderID providerID() const override;
|
||||
PluginLLMCore::ProviderCapabilities capabilities() const override;
|
||||
|
||||
::LLMQore::BaseClient *client() const override;
|
||||
QString apiKey() const override;
|
||||
|
||||
private:
|
||||
::LLMQore::OpenAIClient *m_client;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
135
providers/QwenResponsesProvider.cpp
Normal file
135
providers/QwenResponsesProvider.cpp
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "QwenResponsesProvider.hpp"
|
||||
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
#include "logger/Logger.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/CodeCompletionSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProviderSettings.hpp"
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "tools/ToolsRegistration.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
QwenResponsesProvider::QwenResponsesProvider(QObject *parent)
|
||||
: PluginLLMCore::Provider(parent)
|
||||
, m_client(new ::LLMQore::OpenAIResponsesClient(QString(), QString(), QString(), this))
|
||||
{
|
||||
Tools::registerQodeAssistTools(m_client->tools());
|
||||
}
|
||||
|
||||
QString QwenResponsesProvider::name() const
|
||||
{
|
||||
return "Qwen (OpenAI Response)";
|
||||
}
|
||||
|
||||
QString QwenResponsesProvider::apiKey() const
|
||||
{
|
||||
return Settings::providerSettings().qwenApiKey();
|
||||
}
|
||||
|
||||
QString QwenResponsesProvider::url() const
|
||||
{
|
||||
return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
|
||||
}
|
||||
|
||||
void QwenResponsesProvider::prepareRequest(
|
||||
QJsonObject &request,
|
||||
PluginLLMCore::PromptTemplate *prompt,
|
||||
PluginLLMCore::ContextData context,
|
||||
PluginLLMCore::RequestType type,
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name()));
|
||||
}
|
||||
|
||||
prompt->prepareRequest(request, context);
|
||||
|
||||
auto applyModelParams = [&request](const auto &settings) {
|
||||
request["max_output_tokens"] = settings.maxTokens();
|
||||
|
||||
if (settings.useTopP()) {
|
||||
request["top_p"] = settings.topP();
|
||||
}
|
||||
};
|
||||
|
||||
auto applyThinkingMode = [&request](const auto &settings) {
|
||||
QString effortStr = settings.openAIResponsesReasoningEffort.stringValue().toLower();
|
||||
if (effortStr.isEmpty()) {
|
||||
effortStr = "medium";
|
||||
}
|
||||
|
||||
QJsonObject reasoning;
|
||||
reasoning["effort"] = effortStr;
|
||||
request["reasoning"] = reasoning;
|
||||
request["max_output_tokens"] = settings.thinkingMaxTokens();
|
||||
request["store"] = true;
|
||||
|
||||
QJsonArray include;
|
||||
include.append("reasoning.encrypted_content");
|
||||
request["include"] = include;
|
||||
};
|
||||
|
||||
if (type == PluginLLMCore::RequestType::CodeCompletion) {
|
||||
applyModelParams(Settings::codeCompletionSettings());
|
||||
} else if (type == PluginLLMCore::RequestType::QuickRefactoring) {
|
||||
const auto &qrSettings = Settings::quickRefactorSettings();
|
||||
applyModelParams(qrSettings);
|
||||
|
||||
if (isThinkingEnabled) {
|
||||
applyThinkingMode(qrSettings);
|
||||
}
|
||||
} else {
|
||||
const auto &chatSettings = Settings::chatAssistantSettings();
|
||||
applyModelParams(chatSettings);
|
||||
|
||||
if (isThinkingEnabled) {
|
||||
applyThinkingMode(chatSettings);
|
||||
}
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
const auto toolsDefinitions = m_client->tools()->getToolsDefinitions();
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
LOG_MESSAGE(
|
||||
QString("Added %1 tools to Qwen Responses request").arg(toolsDefinitions.size()));
|
||||
}
|
||||
}
|
||||
|
||||
request["stream"] = true;
|
||||
}
|
||||
|
||||
QFuture<QList<QString>> QwenResponsesProvider::getInstalledModels(const QString &url)
|
||||
{
|
||||
m_client->setUrl(url);
|
||||
m_client->setApiKey(apiKey());
|
||||
return m_client->listModels();
|
||||
}
|
||||
|
||||
PluginLLMCore::ProviderID QwenResponsesProvider::providerID() const
|
||||
{
|
||||
return PluginLLMCore::ProviderID::OpenAIResponses;
|
||||
}
|
||||
|
||||
PluginLLMCore::ProviderCapabilities QwenResponsesProvider::capabilities() const
|
||||
{
|
||||
return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Thinking
|
||||
| PluginLLMCore::ProviderCapability::Image | PluginLLMCore::ProviderCapability::ModelListing;
|
||||
}
|
||||
|
||||
::LLMQore::BaseClient *QwenResponsesProvider::client() const
|
||||
{
|
||||
return m_client;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
37
providers/QwenResponsesProvider.hpp
Normal file
37
providers/QwenResponsesProvider.hpp
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <LLMQore/OpenAIResponsesClient.hpp>
|
||||
#include <pluginllmcore/Provider.hpp>
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
class QwenResponsesProvider : public PluginLLMCore::Provider
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit QwenResponsesProvider(QObject *parent = nullptr);
|
||||
|
||||
QString name() const override;
|
||||
QString url() const override;
|
||||
void prepareRequest(
|
||||
QJsonObject &request,
|
||||
PluginLLMCore::PromptTemplate *prompt,
|
||||
PluginLLMCore::ContextData context,
|
||||
PluginLLMCore::RequestType type,
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled) override;
|
||||
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
|
||||
PluginLLMCore::ProviderID providerID() const override;
|
||||
PluginLLMCore::ProviderCapabilities capabilities() const override;
|
||||
|
||||
::LLMQore::BaseClient *client() const override;
|
||||
QString apiKey() const override;
|
||||
|
||||
private:
|
||||
::LLMQore::OpenAIResponsesClient *m_client;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
267
qodeassist.cpp
267
qodeassist.cpp
@@ -1,6 +1,8 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "QodeAssistConstants.hpp"
|
||||
#include "QodeAssisttr.h"
|
||||
#include "settings/PluginUpdater.hpp"
|
||||
@@ -10,10 +12,14 @@
|
||||
#include <coreplugin/actionmanager/actionmanager.h>
|
||||
#include <coreplugin/actionmanager/command.h>
|
||||
#include <coreplugin/coreconstants.h>
|
||||
#include <coreplugin/dialogs/ioptionspage.h>
|
||||
#include <coreplugin/editormanager/documentmodel.h>
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <coreplugin/icontext.h>
|
||||
#include <coreplugin/icore.h>
|
||||
#include <coreplugin/messagemanager.h>
|
||||
#include <coreplugin/modemanager.h>
|
||||
#include <coreplugin/navigationwidget.h>
|
||||
#include <coreplugin/statusbarmanager.h>
|
||||
#include <extensionsystem/iplugin.h>
|
||||
#include <languageclient/languageclientmanager.h>
|
||||
@@ -31,6 +37,8 @@
|
||||
#include "QodeAssistClient.hpp"
|
||||
#include "UpdateStatusWidget.hpp"
|
||||
#include "Version.hpp"
|
||||
#include "chat/ChatEditor.hpp"
|
||||
#include "chat/ChatEditorFactory.hpp"
|
||||
#include "chat/ChatOutputPane.h"
|
||||
#include "chat/NavigationPanel.hpp"
|
||||
#include "context/DocumentReaderQtCreator.hpp"
|
||||
@@ -39,17 +47,37 @@
|
||||
#include "logger/RequestPerformanceLogger.hpp"
|
||||
#include "mcp/McpClientsManager.hpp"
|
||||
#include "mcp/McpServerManager.hpp"
|
||||
#include "sources/skills/SkillsManager.hpp"
|
||||
#include "tools/ToolsRegistration.hpp"
|
||||
#include "providers/Providers.hpp"
|
||||
#include "settings/ChatAssistantSettings.hpp"
|
||||
#include "settings/GeneralSettings.hpp"
|
||||
#include "settings/ProjectSettingsPanel.hpp"
|
||||
#ifdef QODEASSIST_EXPERIMENTAL
|
||||
#include "settings/AgentsSettingsPage.hpp"
|
||||
#include "settings/ProvidersSettingsPage.hpp"
|
||||
#include "sources/settings/AgentPipelinesPage.hpp"
|
||||
#endif
|
||||
#include "settings/QuickRefactorSettings.hpp"
|
||||
#include "settings/SettingsConstants.hpp"
|
||||
|
||||
#ifdef QODEASSIST_EXPERIMENTAL
|
||||
#include "ProviderInstanceFactory.hpp"
|
||||
#include "ProviderLauncher.hpp"
|
||||
#include "ProviderSecretsStore.hpp"
|
||||
|
||||
#include <AgentFactory.hpp>
|
||||
#endif
|
||||
#include "templates/Templates.hpp"
|
||||
#include "widgets/CustomInstructionsManager.hpp"
|
||||
#include "widgets/QuickRefactorDialog.hpp"
|
||||
#include <ChatView/ChatView.hpp>
|
||||
#include <ChatView/ChatFileManager.hpp>
|
||||
#include <ChatView/ChatRootView.hpp>
|
||||
#include <ChatView/ChatWidget.hpp>
|
||||
#include <ChatView/SessionFileRegistry.hpp>
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
#include <QUuid>
|
||||
#include <coreplugin/actionmanager/actioncontainer.h>
|
||||
#include <coreplugin/actionmanager/actionmanager.h>
|
||||
#include <texteditor/textdocument.h>
|
||||
@@ -84,6 +112,7 @@ public:
|
||||
if (m_navigationPanel) {
|
||||
delete m_navigationPanel;
|
||||
}
|
||||
delete m_chatEditorFactory;
|
||||
}
|
||||
|
||||
void loadTranslations()
|
||||
@@ -151,16 +180,55 @@ public:
|
||||
UpdateDialog::checkForUpdatesAndShow(Core::ICore::mainWindow());
|
||||
});
|
||||
|
||||
m_engine = new QQmlEngine{this};
|
||||
m_sessionFileRegistry = new Chat::SessionFileRegistry{this};
|
||||
m_skillsManager = new Skills::SkillsManager{this};
|
||||
|
||||
{
|
||||
auto &providers = PluginLLMCore::ProvidersManager::instance();
|
||||
for (const QString &providerName : providers.providersNames()) {
|
||||
if (auto *provider = providers.getProviderByName(providerName)) {
|
||||
if (auto *toolsManager = provider->toolsManager())
|
||||
Tools::registerSkillTool(toolsManager, m_skillsManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Settings::chatAssistantSettings().enableChatInBottomToolBar()) {
|
||||
m_chatOutputPane = new Chat::ChatOutputPane(this);
|
||||
m_chatOutputPane = new Chat::ChatOutputPane{
|
||||
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||
}
|
||||
if (Settings::chatAssistantSettings().enableChatInNavigationPanel()) {
|
||||
m_navigationPanel = new Chat::NavigationPanel();
|
||||
m_navigationPanel = new Chat::NavigationPanel{
|
||||
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||
}
|
||||
m_chatEditorFactory = new Chat::ChatEditorFactory{
|
||||
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||
|
||||
Settings::setupProjectPanel();
|
||||
ConfigurationManager::instance().init();
|
||||
|
||||
#ifdef QODEASSIST_EXPERIMENTAL
|
||||
m_providerInstanceFactory = new Providers::ProviderInstanceFactory(this);
|
||||
m_providerSecretsStore = new Providers::ProviderSecretsStore(this);
|
||||
m_providerLauncher = new Providers::ProviderLauncher(this);
|
||||
m_providersPageNavigator = new Settings::ProvidersPageNavigator(this);
|
||||
m_providersOptionsPage = Settings::createProvidersSettingsPage(
|
||||
m_providerInstanceFactory,
|
||||
m_providerSecretsStore,
|
||||
m_providerLauncher,
|
||||
m_providersPageNavigator);
|
||||
|
||||
m_agentFactory = new AgentFactory(m_providerInstanceFactory, m_providerSecretsStore, this);
|
||||
m_agentsPageNavigator = new Settings::AgentsPageNavigator(this);
|
||||
m_agentsOptionsPage = Settings::createAgentsSettingsPage(
|
||||
m_agentFactory, m_agentsPageNavigator);
|
||||
|
||||
m_agentPipelinesPageNavigator = new Settings::AgentPipelinesPageNavigator(this);
|
||||
m_agentPipelinesOptionsPage = Settings::createAgentPipelinesSettingsPage(
|
||||
m_agentFactory, m_agentPipelinesPageNavigator, m_agentsPageNavigator);
|
||||
#endif
|
||||
|
||||
m_mcpServerManager = new Mcp::McpServerManager(this);
|
||||
m_mcpServerManager->init();
|
||||
|
||||
@@ -196,26 +264,23 @@ public:
|
||||
}
|
||||
});
|
||||
|
||||
ActionBuilder showChatViewAction(this, "QodeAssist.ShowChatView");
|
||||
ActionBuilder showChatViewAction(this, Constants::QODE_ASSIST_SHOW_CHAT_ACTION);
|
||||
const QKeySequence showChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_W);
|
||||
showChatViewAction.setDefaultKeySequence(showChatViewShortcut);
|
||||
showChatViewAction.setToolTip(Tr::tr("Show QodeAssist Chat"));
|
||||
showChatViewAction.setToolTip(Tr::tr("Open QodeAssist Chat as an editor tab"));
|
||||
showChatViewAction.setText(Tr::tr("Show QodeAssist Chat"));
|
||||
showChatViewAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
|
||||
showChatViewAction.addOnTriggered(this, [this] {
|
||||
if (!m_chatView) {
|
||||
m_chatView.reset(new Chat::ChatView());
|
||||
}
|
||||
|
||||
if (!m_chatView->isVisible()) {
|
||||
m_chatView->show();
|
||||
}
|
||||
|
||||
m_chatView->raise();
|
||||
m_chatView->requestActivate();
|
||||
});
|
||||
showChatViewAction.addOnTriggered(this, [this] { openChatInEditor(); });
|
||||
m_statusWidget->setChatButtonAction(showChatViewAction.contextAction());
|
||||
|
||||
m_chatButtonMenu = new QMenu(m_statusWidget);
|
||||
connect(
|
||||
m_chatButtonMenu,
|
||||
&QMenu::aboutToShow,
|
||||
this,
|
||||
&QodeAssistPlugin::rebuildChatButtonMenu);
|
||||
m_statusWidget->setChatButtonMenu(m_chatButtonMenu);
|
||||
|
||||
ActionBuilder closeChatViewAction(this, "QodeAssist.CloseChatView");
|
||||
const QKeySequence closeChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_S);
|
||||
closeChatViewAction.setDefaultKeySequence(closeChatViewShortcut);
|
||||
@@ -228,6 +293,38 @@ public:
|
||||
}
|
||||
});
|
||||
|
||||
ActionBuilder openChatWindowAction(this, Constants::QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION);
|
||||
openChatWindowAction.setText(Tr::tr("Open QodeAssist Chat in Separate Window"));
|
||||
openChatWindowAction.setToolTip(Tr::tr("Open the QodeAssist chat in a separate window"));
|
||||
openChatWindowAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
|
||||
openChatWindowAction.addOnTriggered(this, [this] { openChatInWindow(); });
|
||||
|
||||
ActionBuilder newChatAction(this, Constants::QODE_ASSIST_NEW_CHAT_ACTION);
|
||||
newChatAction.setText(Tr::tr("New QodeAssist Chat"));
|
||||
newChatAction.setToolTip(Tr::tr("Open a fresh chat in a new editor tab"));
|
||||
newChatAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
|
||||
newChatAction.addOnTriggered(this, [this] { openNewChatInEditor(); });
|
||||
|
||||
ActionBuilder sendMessageAction(this, Constants::QODE_ASSIST_CHAT_SEND_MESSAGE);
|
||||
sendMessageAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
|
||||
sendMessageAction.setText(Tr::tr("Send QodeAssist Chat Message"));
|
||||
sendMessageAction.setToolTip(Tr::tr("Send the current message to the LLM"));
|
||||
sendMessageAction.setDefaultKeySequence(QKeySequence(Qt::CTRL | Qt::Key_Return));
|
||||
sendMessageAction.addOnTriggered(this, [] {
|
||||
if (auto chatWidget = Chat::ChatWidget::focusedInstance())
|
||||
chatWidget->sendMessage();
|
||||
});
|
||||
|
||||
ActionBuilder clearSessionAction(this, Constants::QODE_ASSIST_CHAT_CLEAR_SESSION);
|
||||
clearSessionAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
|
||||
clearSessionAction.setText(Tr::tr("Clear QodeAssist Chat Session"));
|
||||
clearSessionAction.setToolTip(Tr::tr("Clear the current chat session"));
|
||||
clearSessionAction.setDefaultKeySequence(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_L));
|
||||
clearSessionAction.addOnTriggered(this, [] {
|
||||
if (auto chatWidget = Chat::ChatWidget::focusedInstance())
|
||||
chatWidget->clearSession();
|
||||
});
|
||||
|
||||
Core::ActionContainer *editorContextMenu = Core::ActionManager::actionContainer(
|
||||
TextEditor::Constants::M_STANDARDCONTEXTMENU);
|
||||
if (editorContextMenu) {
|
||||
@@ -271,6 +368,125 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
void openChatInEditor()
|
||||
{
|
||||
if (auto existing = findExistingChatEditor()) {
|
||||
Core::EditorManager::activateEditor(existing);
|
||||
existing->consumePendingChatFile();
|
||||
return;
|
||||
}
|
||||
|
||||
QString title = Tr::tr("QodeAssist Chat");
|
||||
Core::IEditor *editor = Core::EditorManager::openEditorWithContents(
|
||||
Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString());
|
||||
if (auto chatEditor = qobject_cast<Chat::ChatEditor *>(editor))
|
||||
chatEditor->consumePendingChatFile();
|
||||
}
|
||||
|
||||
void openNewChatInEditor()
|
||||
{
|
||||
QString title = Tr::tr("QodeAssist Chat");
|
||||
Core::IEditor *editor = Core::EditorManager::openEditorWithContents(
|
||||
Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString());
|
||||
// For the "New Chat" button pending is empty (no-op). For relocate-to-editor it
|
||||
// carries the handed-off chat file and gets loaded into the freshly opened tab.
|
||||
if (auto chatEditor = qobject_cast<Chat::ChatEditor *>(editor))
|
||||
chatEditor->consumePendingChatFile();
|
||||
}
|
||||
|
||||
Chat::ChatEditor *findExistingChatEditor() const
|
||||
{
|
||||
const auto entries = Core::DocumentModel::entries();
|
||||
for (auto *entry : entries) {
|
||||
if (!entry || !entry->document)
|
||||
continue;
|
||||
if (entry->document->id() != Constants::QODE_ASSIST_CHAT_EDITOR_ID)
|
||||
continue;
|
||||
const auto editors = Core::DocumentModel::editorsForDocument(entry->document);
|
||||
for (auto *editor : editors) {
|
||||
if (auto chatEditor = qobject_cast<Chat::ChatEditor *>(editor))
|
||||
return chatEditor;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void openChatInWindow()
|
||||
{
|
||||
if (!m_chatView)
|
||||
m_chatView.reset(new Chat::ChatView{m_engine, m_sessionFileRegistry, m_skillsManager});
|
||||
|
||||
if (!m_chatView->isVisible())
|
||||
m_chatView->show();
|
||||
|
||||
m_chatView->raise();
|
||||
m_chatView->requestActivate();
|
||||
|
||||
if (auto rootView = qobject_cast<Chat::ChatRootView *>(m_chatView->rootObject()))
|
||||
rootView->consumePendingChatFile();
|
||||
}
|
||||
|
||||
void setChatInBottomPaneEnabled(bool enabled)
|
||||
{
|
||||
if (enabled && !m_chatOutputPane)
|
||||
m_chatOutputPane = new Chat::ChatOutputPane{
|
||||
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||
else if (!enabled && m_chatOutputPane)
|
||||
delete m_chatOutputPane;
|
||||
|
||||
Settings::chatAssistantSettings().enableChatInBottomToolBar.setValue(enabled);
|
||||
Settings::chatAssistantSettings().writeSettings();
|
||||
}
|
||||
|
||||
void setChatInSidebarEnabled(bool enabled)
|
||||
{
|
||||
if (enabled && !m_navigationPanel)
|
||||
m_navigationPanel = new Chat::NavigationPanel{
|
||||
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||
else if (!enabled && m_navigationPanel)
|
||||
delete m_navigationPanel;
|
||||
|
||||
Settings::chatAssistantSettings().enableChatInNavigationPanel.setValue(enabled);
|
||||
Settings::chatAssistantSettings().writeSettings();
|
||||
}
|
||||
|
||||
void rebuildChatButtonMenu()
|
||||
{
|
||||
if (!m_chatButtonMenu)
|
||||
return;
|
||||
|
||||
m_chatButtonMenu->clear();
|
||||
|
||||
QAction *paneAction = m_chatButtonMenu->addAction(Tr::tr("Chat in Bottom Panel"));
|
||||
paneAction->setCheckable(true);
|
||||
paneAction->setChecked(m_chatOutputPane != nullptr);
|
||||
connect(paneAction, &QAction::toggled, this, [this](bool on) {
|
||||
setChatInBottomPaneEnabled(on);
|
||||
});
|
||||
|
||||
QAction *sidebarAction = m_chatButtonMenu->addAction(Tr::tr("Chat in Sidebar"));
|
||||
sidebarAction->setCheckable(true);
|
||||
sidebarAction->setChecked(m_navigationPanel != nullptr);
|
||||
connect(sidebarAction, &QAction::toggled, this, [this](bool on) {
|
||||
setChatInSidebarEnabled(on);
|
||||
});
|
||||
|
||||
m_chatButtonMenu->addSeparator();
|
||||
|
||||
if (m_chatView && m_chatView->isVisible()) {
|
||||
QAction *editorAction = m_chatButtonMenu->addAction(Tr::tr("Open Chat in Editor"));
|
||||
connect(editorAction, &QAction::triggered, this, [this] {
|
||||
if (m_chatView)
|
||||
m_chatView->close();
|
||||
openChatInEditor();
|
||||
});
|
||||
} else {
|
||||
QAction *windowAction
|
||||
= m_chatButtonMenu->addAction(Tr::tr("Open Chat in Separate Window"));
|
||||
connect(windowAction, &QAction::triggered, this, [this] { openChatInWindow(); });
|
||||
}
|
||||
}
|
||||
|
||||
void checkForUpdates()
|
||||
{
|
||||
connect(
|
||||
@@ -297,11 +513,28 @@ private:
|
||||
RequestPerformanceLogger m_performanceLogger;
|
||||
QPointer<Chat::ChatOutputPane> m_chatOutputPane;
|
||||
QPointer<Chat::NavigationPanel> m_navigationPanel;
|
||||
QPointer<Chat::SessionFileRegistry> m_sessionFileRegistry;
|
||||
Chat::ChatEditorFactory *m_chatEditorFactory{nullptr};
|
||||
QPointer<QMenu> m_chatButtonMenu;
|
||||
QPointer<PluginUpdater> m_updater;
|
||||
UpdateStatusWidget *m_statusWidget{nullptr};
|
||||
QString m_lastRefactorInstructions;
|
||||
QScopedPointer<Chat::ChatView> m_chatView;
|
||||
std::unique_ptr<Chat::ChatView> m_chatView;
|
||||
QPointer<Mcp::McpServerManager> m_mcpServerManager;
|
||||
QPointer<QQmlEngine> m_engine;
|
||||
QPointer<Skills::SkillsManager> m_skillsManager;
|
||||
#ifdef QODEASSIST_EXPERIMENTAL
|
||||
QPointer<Providers::ProviderInstanceFactory> m_providerInstanceFactory;
|
||||
QPointer<Providers::ProviderSecretsStore> m_providerSecretsStore;
|
||||
QPointer<Providers::ProviderLauncher> m_providerLauncher;
|
||||
QPointer<Settings::ProvidersPageNavigator> m_providersPageNavigator;
|
||||
std::unique_ptr<Core::IOptionsPage> m_providersOptionsPage;
|
||||
QPointer<AgentFactory> m_agentFactory;
|
||||
QPointer<Settings::AgentsPageNavigator> m_agentsPageNavigator;
|
||||
std::unique_ptr<Core::IOptionsPage> m_agentsOptionsPage;
|
||||
QPointer<Settings::AgentPipelinesPageNavigator> m_agentPipelinesPageNavigator;
|
||||
std::unique_ptr<Core::IOptionsPage> m_agentPipelinesOptionsPage;
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Internal
|
||||
|
||||
3246
resources/translations/QodeAssist_cs.ts
Normal file
3246
resources/translations/QodeAssist_cs.ts
Normal file
File diff suppressed because it is too large
Load Diff
3244
resources/translations/QodeAssist_da.ts
Normal file
3244
resources/translations/QodeAssist_da.ts
Normal file
File diff suppressed because it is too large
Load Diff
3244
resources/translations/QodeAssist_de.ts
Normal file
3244
resources/translations/QodeAssist_de.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user