mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-06-13 17:59:15 -04:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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_version: "6.10.3",
|
||||||
qt_creator_version: "19.0.1"
|
qt_creator_version: "19.0.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ add_definitions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
add_subdirectory(sources/external/llmqore)
|
add_subdirectory(sources/external/llmqore)
|
||||||
|
add_subdirectory(sources/skills)
|
||||||
add_subdirectory(pluginllmcore)
|
add_subdirectory(pluginllmcore)
|
||||||
add_subdirectory(settings)
|
add_subdirectory(settings)
|
||||||
add_subdirectory(logger)
|
add_subdirectory(logger)
|
||||||
@@ -64,6 +65,7 @@ add_qtc_plugin(QodeAssist
|
|||||||
QtCreator::CPlusPlus
|
QtCreator::CPlusPlus
|
||||||
LLMQore
|
LLMQore
|
||||||
PluginLLMCore
|
PluginLLMCore
|
||||||
|
Skills
|
||||||
QodeAssistChatViewplugin
|
QodeAssistChatViewplugin
|
||||||
SOURCES
|
SOURCES
|
||||||
.github/workflows/build_cmake.yml
|
.github/workflows/build_cmake.yml
|
||||||
@@ -93,6 +95,7 @@ add_qtc_plugin(QodeAssist
|
|||||||
templates/Qwen3CoderFIM.hpp
|
templates/Qwen3CoderFIM.hpp
|
||||||
templates/OpenAIResponses.hpp
|
templates/OpenAIResponses.hpp
|
||||||
providers/Providers.hpp
|
providers/Providers.hpp
|
||||||
|
providers/ProviderUrlUtils.hpp
|
||||||
providers/OllamaProvider.hpp providers/OllamaProvider.cpp
|
providers/OllamaProvider.hpp providers/OllamaProvider.cpp
|
||||||
providers/OllamaCompatProvider.hpp providers/OllamaCompatProvider.cpp
|
providers/OllamaCompatProvider.hpp providers/OllamaCompatProvider.cpp
|
||||||
providers/ClaudeProvider.hpp providers/ClaudeProvider.cpp
|
providers/ClaudeProvider.hpp providers/ClaudeProvider.cpp
|
||||||
@@ -114,6 +117,9 @@ add_qtc_plugin(QodeAssist
|
|||||||
QodeAssistClient.hpp QodeAssistClient.cpp
|
QodeAssistClient.hpp QodeAssistClient.cpp
|
||||||
chat/ChatOutputPane.h chat/ChatOutputPane.cpp
|
chat/ChatOutputPane.h chat/ChatOutputPane.cpp
|
||||||
chat/NavigationPanel.hpp chat/NavigationPanel.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
|
ConfigurationManager.hpp ConfigurationManager.cpp
|
||||||
CodeHandler.hpp CodeHandler.cpp
|
CodeHandler.hpp CodeHandler.cpp
|
||||||
UpdateStatusWidget.hpp UpdateStatusWidget.cpp
|
UpdateStatusWidget.hpp UpdateStatusWidget.cpp
|
||||||
@@ -146,6 +152,8 @@ add_qtc_plugin(QodeAssist
|
|||||||
tools/ReadFileTool.hpp tools/ReadFileTool.cpp
|
tools/ReadFileTool.hpp tools/ReadFileTool.cpp
|
||||||
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp
|
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp
|
||||||
tools/TodoTool.hpp tools/TodoTool.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/McpServerManager.hpp mcp/McpServerManager.cpp
|
||||||
mcp/McpServerConnection.hpp mcp/McpServerConnection.cpp
|
mcp/McpServerConnection.hpp mcp/McpServerConnection.cpp
|
||||||
mcp/McpClientsManager.hpp mcp/McpClientsManager.cpp
|
mcp/McpClientsManager.hpp mcp/McpClientsManager.cpp
|
||||||
|
|||||||
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,6 +23,7 @@ qt_add_qml_module(QodeAssistChatView
|
|||||||
qml/controls/FileMentionPopup.qml
|
qml/controls/FileMentionPopup.qml
|
||||||
qml/controls/FileEditsActionBar.qml
|
qml/controls/FileEditsActionBar.qml
|
||||||
qml/controls/ContextViewer.qml
|
qml/controls/ContextViewer.qml
|
||||||
|
qml/controls/SkillCommandPopup.qml
|
||||||
qml/controls/Toast.qml
|
qml/controls/Toast.qml
|
||||||
qml/controls/TopBar.qml
|
qml/controls/TopBar.qml
|
||||||
qml/controls/SplitDropZone.qml
|
qml/controls/SplitDropZone.qml
|
||||||
@@ -43,9 +44,11 @@ qt_add_qml_module(QodeAssistChatView
|
|||||||
icons/window-unlock.svg
|
icons/window-unlock.svg
|
||||||
icons/chat-icon.svg
|
icons/chat-icon.svg
|
||||||
icons/chat-pause-icon.svg
|
icons/chat-pause-icon.svg
|
||||||
|
icons/new-chat-icon.svg
|
||||||
icons/rules-icon.svg
|
icons/rules-icon.svg
|
||||||
icons/context-icon.svg
|
icons/context-icon.svg
|
||||||
icons/open-in-editor.svg
|
icons/open-in-editor.svg
|
||||||
|
icons/open-in-window.svg
|
||||||
icons/apply-changes-button.svg
|
icons/apply-changes-button.svg
|
||||||
icons/undo-changes-button.svg
|
icons/undo-changes-button.svg
|
||||||
icons/reject-changes-button.svg
|
icons/reject-changes-button.svg
|
||||||
@@ -69,7 +72,13 @@ qt_add_qml_module(QodeAssistChatView
|
|||||||
FileItem.hpp FileItem.cpp
|
FileItem.hpp FileItem.cpp
|
||||||
ChatFileManager.hpp ChatFileManager.cpp
|
ChatFileManager.hpp ChatFileManager.cpp
|
||||||
ChatCompressor.hpp ChatCompressor.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
|
FileMentionItem.hpp FileMentionItem.cpp
|
||||||
|
SessionFileRegistry.hpp SessionFileRegistry.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(QodeAssistChatView
|
target_link_libraries(QodeAssistChatView
|
||||||
@@ -86,8 +95,9 @@ target_link_libraries(QodeAssistChatView
|
|||||||
QodeAssistUIControlsplugin
|
QodeAssistUIControlsplugin
|
||||||
QodeAssistLogger
|
QodeAssistLogger
|
||||||
LLMQore
|
LLMQore
|
||||||
|
Skills
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(QodeAssistChatView
|
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();
|
summaryMessage["images"] = QJsonArray();
|
||||||
|
|
||||||
root["messages"] = QJsonArray{summaryMessage};
|
root["messages"] = QJsonArray{summaryMessage};
|
||||||
|
root["compressedFrom"] = sourcePath;
|
||||||
|
root["compressedAt"] = QDateTime::currentDateTime().toString(Qt::ISODate);
|
||||||
|
|
||||||
if (QFile::exists(destPath))
|
if (QFile::exists(destPath))
|
||||||
QFile::remove(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 <QUrl>
|
||||||
#include <QtQml>
|
#include <QtQml>
|
||||||
|
|
||||||
#include "ChatAssistantSettings.hpp"
|
|
||||||
#include "Logger.hpp"
|
#include "Logger.hpp"
|
||||||
#include "context/ChangesManager.h"
|
#include "context/ChangesManager.h"
|
||||||
|
|
||||||
@@ -20,14 +19,6 @@ namespace QodeAssist::Chat {
|
|||||||
ChatModel::ChatModel(QObject *parent)
|
ChatModel::ChatModel(QObject *parent)
|
||||||
: QAbstractListModel(parent)
|
: QAbstractListModel(parent)
|
||||||
{
|
{
|
||||||
auto &settings = Settings::chatAssistantSettings();
|
|
||||||
|
|
||||||
connect(
|
|
||||||
&settings.chatTokensThreshold,
|
|
||||||
&Utils::BaseAspect::changed,
|
|
||||||
this,
|
|
||||||
&ChatModel::tokensThresholdChanged);
|
|
||||||
|
|
||||||
connect(&Context::ChangesManager::instance(),
|
connect(&Context::ChangesManager::instance(),
|
||||||
&Context::ChangesManager::fileEditApplied,
|
&Context::ChangesManager::fileEditApplied,
|
||||||
this,
|
this,
|
||||||
@@ -86,6 +77,16 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
|
|||||||
case Roles::IsRedacted: {
|
case Roles::IsRedacted: {
|
||||||
return message.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: {
|
case Roles::Images: {
|
||||||
QVariantList imagesList;
|
QVariantList imagesList;
|
||||||
for (const auto &image : message.images) {
|
for (const auto &image : message.images) {
|
||||||
@@ -124,6 +125,11 @@ QHash<int, QByteArray> ChatModel::roleNames() const
|
|||||||
roles[Roles::Attachments] = "attachments";
|
roles[Roles::Attachments] = "attachments";
|
||||||
roles[Roles::IsRedacted] = "isRedacted";
|
roles[Roles::IsRedacted] = "isRedacted";
|
||||||
roles[Roles::Images] = "images";
|
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;
|
return roles;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +213,7 @@ void ChatModel::clear()
|
|||||||
m_messages.clear();
|
m_messages.clear();
|
||||||
endResetModel();
|
endResetModel();
|
||||||
emit modelReseted();
|
emit modelReseted();
|
||||||
|
emit sessionUsageChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<MessagePart> ChatModel::processMessageContent(const QString &content) const
|
QList<MessagePart> ChatModel::processMessageContent(const QString &content) const
|
||||||
@@ -310,12 +317,6 @@ QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) con
|
|||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
int ChatModel::tokensThreshold() const
|
|
||||||
{
|
|
||||||
auto &settings = Settings::chatAssistantSettings();
|
|
||||||
return settings.chatTokensThreshold();
|
|
||||||
}
|
|
||||||
|
|
||||||
QString ChatModel::lastMessageId() const
|
QString ChatModel::lastMessageId() const
|
||||||
{
|
{
|
||||||
return !m_messages.isEmpty() ? m_messages.last().id : "";
|
return !m_messages.isEmpty() ? m_messages.last().id : "";
|
||||||
@@ -330,11 +331,15 @@ void ChatModel::resetModelTo(int index)
|
|||||||
beginRemoveRows(QModelIndex(), index, m_messages.size() - 1);
|
beginRemoveRows(QModelIndex(), index, m_messages.size() - 1);
|
||||||
m_messages.remove(index, m_messages.size() - index);
|
m_messages.remove(index, m_messages.size() - index);
|
||||||
endRemoveRows();
|
endRemoveRows();
|
||||||
|
emit sessionUsageChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatModel::addToolExecutionStatus(
|
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;
|
QString content = toolName;
|
||||||
|
|
||||||
@@ -345,11 +350,15 @@ void ChatModel::addToolExecutionStatus(
|
|||||||
&& m_messages.last().role == ChatRole::Tool) {
|
&& m_messages.last().role == ChatRole::Tool) {
|
||||||
Message &lastMessage = m_messages.last();
|
Message &lastMessage = m_messages.last();
|
||||||
lastMessage.content = content;
|
lastMessage.content = content;
|
||||||
|
lastMessage.toolName = toolName;
|
||||||
|
lastMessage.toolArguments = toolArguments;
|
||||||
LOG_MESSAGE(QString("Updated existing tool message at index %1").arg(m_messages.size() - 1));
|
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));
|
emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1));
|
||||||
} else {
|
} else {
|
||||||
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
|
beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size());
|
||||||
Message newMessage{ChatRole::Tool, content, toolId};
|
Message newMessage{ChatRole::Tool, content, toolId};
|
||||||
|
newMessage.toolName = toolName;
|
||||||
|
newMessage.toolArguments = toolArguments;
|
||||||
m_messages.append(newMessage);
|
m_messages.append(newMessage);
|
||||||
endInsertRows();
|
endInsertRows();
|
||||||
LOG_MESSAGE(QString("Created new tool message at index %1 with toolId=%2")
|
LOG_MESSAGE(QString("Created new tool message at index %1 with toolId=%2")
|
||||||
@@ -358,6 +367,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(
|
void ChatModel::updateToolResult(
|
||||||
const QString &requestId, const QString &toolId, const QString &toolName, const QString &result)
|
const QString &requestId, const QString &toolId, const QString &toolName, const QString &result)
|
||||||
{
|
{
|
||||||
@@ -377,6 +418,8 @@ void ChatModel::updateToolResult(
|
|||||||
for (int i = m_messages.size() - 1; i >= 0; --i) {
|
for (int i = m_messages.size() - 1; i >= 0; --i) {
|
||||||
if (m_messages[i].id == toolId && m_messages[i].role == ChatRole::Tool) {
|
if (m_messages[i].id == toolId && m_messages[i].role == ChatRole::Tool) {
|
||||||
m_messages[i].content = toolName + "\n" + result;
|
m_messages[i].content = toolName + "\n" + result;
|
||||||
|
m_messages[i].toolName = toolName;
|
||||||
|
m_messages[i].toolResult = result;
|
||||||
emit dataChanged(index(i), index(i));
|
emit dataChanged(index(i), index(i));
|
||||||
toolMessageFound = true;
|
toolMessageFound = true;
|
||||||
LOG_MESSAGE(QString("Updated tool result at index %1").arg(i));
|
LOG_MESSAGE(QString("Updated tool result at index %1").arg(i));
|
||||||
@@ -507,6 +550,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)
|
void ChatModel::setLoadingFromHistory(bool loading)
|
||||||
{
|
{
|
||||||
m_loadingFromHistory = loading;
|
m_loadingFromHistory = loading;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include <QAbstractListModel>
|
#include <QAbstractListModel>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
#include <QtQmlIntegration>
|
#include <QtQmlIntegration>
|
||||||
|
|
||||||
#include "context/ContentFile.hpp"
|
#include "context/ContentFile.hpp"
|
||||||
@@ -17,14 +18,28 @@ namespace QodeAssist::Chat {
|
|||||||
class ChatModel : public QAbstractListModel
|
class ChatModel : public QAbstractListModel
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
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
|
QML_ELEMENT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking };
|
enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking };
|
||||||
Q_ENUM(ChatRole)
|
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)
|
Q_ENUM(Roles)
|
||||||
|
|
||||||
struct ImageAttachment
|
struct ImageAttachment
|
||||||
@@ -44,6 +59,15 @@ public:
|
|||||||
|
|
||||||
QList<Context::ContentFile> attachments;
|
QList<Context::ContentFile> attachments;
|
||||||
QList<ImageAttachment> images;
|
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);
|
explicit ChatModel(QObject *parent = nullptr);
|
||||||
@@ -66,15 +90,22 @@ public:
|
|||||||
QVector<Message> getChatHistory() const;
|
QVector<Message> getChatHistory() const;
|
||||||
QJsonArray prepareMessagesForRequest(const QString &systemPrompt) const;
|
QJsonArray prepareMessagesForRequest(const QString &systemPrompt) const;
|
||||||
|
|
||||||
int tokensThreshold() const;
|
|
||||||
|
|
||||||
QString currentModel() const;
|
QString currentModel() const;
|
||||||
QString lastMessageId() const;
|
QString lastMessageId() const;
|
||||||
|
|
||||||
Q_INVOKABLE void resetModelTo(int index);
|
Q_INVOKABLE void resetModelTo(int index);
|
||||||
|
|
||||||
void addToolExecutionStatus(
|
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(
|
void updateToolResult(
|
||||||
const QString &requestId,
|
const QString &requestId,
|
||||||
const QString &toolId,
|
const QString &toolId,
|
||||||
@@ -85,6 +116,18 @@ public:
|
|||||||
void addRedactedThinkingBlock(const QString &requestId, const QString &signature);
|
void addRedactedThinkingBlock(const QString &requestId, const QString &signature);
|
||||||
void updateMessageContent(const QString &messageId, const QString &newContent);
|
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);
|
void setLoadingFromHistory(bool loading);
|
||||||
bool isLoadingFromHistory() const;
|
bool isLoadingFromHistory() const;
|
||||||
|
|
||||||
@@ -92,8 +135,8 @@ public:
|
|||||||
QString chatFilePath() const;
|
QString chatFilePath() const;
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void tokensThresholdChanged();
|
|
||||||
void modelReseted();
|
void modelReseted();
|
||||||
|
void sessionUsageChanged();
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onFileEditApplied(const QString &editId);
|
void onFileEditApplied(const QString &editId);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QPointer>
|
||||||
#include <QQuickItem>
|
#include <QQuickItem>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
|
|
||||||
@@ -12,9 +13,19 @@
|
|||||||
#include "pluginllmcore/PromptProviderChat.hpp"
|
#include "pluginllmcore/PromptProviderChat.hpp"
|
||||||
#include <coreplugin/editormanager/editormanager.h>
|
#include <coreplugin/editormanager/editormanager.h>
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
class ChatCompressor;
|
class ChatCompressor;
|
||||||
|
class AgentRoleController;
|
||||||
|
class ChatConfigurationController;
|
||||||
|
class FileEditController;
|
||||||
|
class InputTokenCounter;
|
||||||
|
class ChatHistoryStore;
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
class ChatRootView : public QQuickItem
|
class ChatRootView : public QQuickItem
|
||||||
{
|
{
|
||||||
@@ -52,11 +63,14 @@ class ChatRootView : public QQuickItem
|
|||||||
Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL)
|
Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL)
|
||||||
Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL)
|
Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL)
|
||||||
Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged 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
|
QML_ELEMENT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
ChatRootView(QQuickItem *parent = nullptr);
|
ChatRootView(QQuickItem *parent = nullptr);
|
||||||
|
~ChatRootView() override;
|
||||||
|
|
||||||
ChatModel *chatModel() const;
|
ChatModel *chatModel() const;
|
||||||
QString currentTemplate() const;
|
QString currentTemplate() const;
|
||||||
@@ -91,6 +105,11 @@ public:
|
|||||||
|
|
||||||
Q_INVOKABLE void openFileInEditor(const QString &filePath);
|
Q_INVOKABLE void openFileInEditor(const QString &filePath);
|
||||||
|
|
||||||
|
Q_INVOKABLE void relocateToSplit();
|
||||||
|
Q_INVOKABLE void relocateToWindow();
|
||||||
|
|
||||||
|
void consumePendingChatFile();
|
||||||
|
|
||||||
Q_INVOKABLE void updateInputTokensCount();
|
Q_INVOKABLE void updateInputTokensCount();
|
||||||
int inputTokensCount() const;
|
int inputTokensCount() const;
|
||||||
|
|
||||||
@@ -122,6 +141,8 @@ public:
|
|||||||
Q_INVOKABLE QString getRuleContent(int index);
|
Q_INVOKABLE QString getRuleContent(int index);
|
||||||
Q_INVOKABLE void refreshRules();
|
Q_INVOKABLE void refreshRules();
|
||||||
|
|
||||||
|
Q_INVOKABLE QVariantList searchSkills(const QString &query) const;
|
||||||
|
|
||||||
bool useTools() const;
|
bool useTools() const;
|
||||||
void setUseTools(bool enabled);
|
void setUseTools(bool enabled);
|
||||||
bool useThinking() const;
|
bool useThinking() const;
|
||||||
@@ -164,6 +185,13 @@ public:
|
|||||||
|
|
||||||
bool isCompressing() const;
|
bool isCompressing() const;
|
||||||
|
|
||||||
|
bool isInEditor() const;
|
||||||
|
void setInEditor(bool value);
|
||||||
|
|
||||||
|
QString chatTitle() const;
|
||||||
|
|
||||||
|
Q_INVOKABLE void requestNewChat();
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
void sendMessage(const QString &message);
|
void sendMessage(const QString &message);
|
||||||
void copyToClipboard(const QString &text);
|
void copyToClipboard(const QString &text);
|
||||||
@@ -209,15 +237,34 @@ signals:
|
|||||||
void compressionCompleted(const QString &compressedChatPath);
|
void compressionCompleted(const QString &compressedChatPath);
|
||||||
void compressionFailed(const QString &error);
|
void compressionFailed(const QString &error);
|
||||||
|
|
||||||
|
void isInEditorChanged();
|
||||||
|
void chatTitleChanged();
|
||||||
|
|
||||||
void openFilesChanged();
|
void openFilesChanged();
|
||||||
|
|
||||||
|
void closeHostRequested();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void updateFileEditStatus(const QString &editId, const QString &status);
|
QString computeChatTitle() const;
|
||||||
QString getChatsHistoryDir() const;
|
void triggerOpenChatCommand(Utils::Id commandId);
|
||||||
QString getSuggestedFileName() const;
|
void handOffSession();
|
||||||
QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
|
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;
|
bool hasImageAttachments(const QStringList &attachments) const;
|
||||||
|
|
||||||
|
SessionFileRegistry *sessionFileRegistry() const;
|
||||||
|
Skills::SkillsManager *skillsManager() const;
|
||||||
|
|
||||||
ChatModel *m_chatModel;
|
ChatModel *m_chatModel;
|
||||||
PluginLLMCore::PromptProviderChat m_promptProvider;
|
PluginLLMCore::PromptProviderChat m_promptProvider;
|
||||||
ClientInterface *m_clientInterface;
|
ClientInterface *m_clientInterface;
|
||||||
@@ -226,28 +273,36 @@ private:
|
|||||||
QString m_recentFilePath;
|
QString m_recentFilePath;
|
||||||
QStringList m_attachmentFiles;
|
QStringList m_attachmentFiles;
|
||||||
QStringList m_linkedFiles;
|
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_isSyncOpenFiles;
|
||||||
|
bool m_isInEditor = false;
|
||||||
|
mutable QString m_cachedChatTitle;
|
||||||
QList<Core::IEditor *> m_currentEditors;
|
QList<Core::IEditor *> m_currentEditors;
|
||||||
bool m_isRequestInProgress;
|
bool m_isRequestInProgress;
|
||||||
QString m_lastErrorMessage;
|
QString m_lastErrorMessage;
|
||||||
QVariantList m_activeRules;
|
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;
|
QString m_lastInfoMessage;
|
||||||
|
|
||||||
QStringList m_availableConfigurations;
|
|
||||||
QString m_currentConfiguration;
|
|
||||||
|
|
||||||
QStringList m_availableAgentRoles;
|
|
||||||
QString m_currentAgentRole;
|
|
||||||
|
|
||||||
ChatCompressor *m_chatCompressor;
|
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
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ QJsonObject ChatSerializer::serializeMessage(
|
|||||||
messageObj["signature"] = message.signature;
|
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()) {
|
if (!message.attachments.isEmpty()) {
|
||||||
QJsonArray attachmentsArray;
|
QJsonArray attachmentsArray;
|
||||||
for (const auto &attachment : message.attachments) {
|
for (const auto &attachment : message.attachments) {
|
||||||
@@ -103,6 +112,17 @@ QJsonObject ChatSerializer::serializeMessage(
|
|||||||
messageObj["images"] = imagesArray;
|
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;
|
return messageObj;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +135,9 @@ ChatModel::Message ChatSerializer::deserializeMessage(
|
|||||||
message.id = json["id"].toString();
|
message.id = json["id"].toString();
|
||||||
message.isRedacted = json["isRedacted"].toBool(false);
|
message.isRedacted = json["isRedacted"].toBool(false);
|
||||||
message.signature = json["signature"].toString();
|
message.signature = json["signature"].toString();
|
||||||
|
message.toolName = json["toolName"].toString();
|
||||||
|
message.toolArguments = json["toolArguments"].toObject();
|
||||||
|
message.toolResult = json["toolResult"].toString();
|
||||||
|
|
||||||
if (json.contains("attachments")) {
|
if (json.contains("attachments")) {
|
||||||
QJsonArray attachmentsArray = json["attachments"].toArray();
|
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;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +211,10 @@ bool ChatSerializer::deserializeChat(
|
|||||||
message.images,
|
message.images,
|
||||||
message.isRedacted,
|
message.isRedacted,
|
||||||
message.signature);
|
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")
|
LOG_MESSAGE(QString("Loaded message with %1 image(s), isRedacted=%2, signature length=%3")
|
||||||
.arg(message.images.size())
|
.arg(message.images.size())
|
||||||
.arg(message.isRedacted)
|
.arg(message.isRedacted)
|
||||||
|
|||||||
@@ -3,14 +3,22 @@
|
|||||||
|
|
||||||
#include "ChatView.hpp"
|
#include "ChatView.hpp"
|
||||||
|
|
||||||
|
#include <QQmlComponent>
|
||||||
#include <QQmlContext>
|
#include <QQmlContext>
|
||||||
#include <QQmlEngine>
|
#include <QQmlEngine>
|
||||||
|
#include <QQuickItem>
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
#include <QVariantMap>
|
#include <QVariantMap>
|
||||||
|
|
||||||
#include <coreplugin/actionmanager/actionmanager.h>
|
#include <coreplugin/actionmanager/actionmanager.h>
|
||||||
|
#include <coreplugin/actionmanager/command.h>
|
||||||
#include <logger/Logger.hpp>
|
#include <logger/Logger.hpp>
|
||||||
|
|
||||||
|
#include "ChatRootView.hpp"
|
||||||
|
#include "QodeAssistConstants.hpp"
|
||||||
|
#include "SessionFileRegistry.hpp"
|
||||||
|
#include "sources/skills/SkillsManager.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint
|
constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint
|
||||||
| Qt::WindowMinimizeButtonHint | Qt::WindowMaximizeButtonHint
|
| Qt::WindowMinimizeButtonHint | Qt::WindowMaximizeButtonHint
|
||||||
@@ -19,30 +27,65 @@ constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::Win
|
|||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ChatView::ChatView()
|
ChatView::ChatView(
|
||||||
: m_isPin(false)
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager)
|
||||||
|
: QQuickView{engine, nullptr}
|
||||||
|
, m_isPin(false)
|
||||||
{
|
{
|
||||||
setTitle("QodeAssist Chat");
|
setTitle("QodeAssist Chat");
|
||||||
engine()->rootContext()->setContextProperty("_chatview", this);
|
/// @note setup quick view content
|
||||||
setSource(QUrl("qrc:/qt/qml/ChatView/qml/RootItem.qml"));
|
{
|
||||||
|
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);
|
setResizeMode(QQuickView::SizeRootObjectToView);
|
||||||
setMinimumSize({400, 300});
|
setMinimumSize({400, 300});
|
||||||
setFlags(baseFlags);
|
setFlags(baseFlags);
|
||||||
|
|
||||||
if (auto action = Core::ActionManager::command("QodeAssist.CloseChatView")) {
|
bindCommandShortcut("QodeAssist.CloseChatView", [this] { close(); });
|
||||||
m_closeShortcut = new QShortcut(action->keySequence(), this);
|
bindCommandShortcut(Constants::QODE_ASSIST_CHAT_SEND_MESSAGE, [this] {
|
||||||
connect(m_closeShortcut, &QShortcut::activated, this, &QQuickView::close);
|
QMetaObject::invokeMethod(rootObject(), "sendChatMessage");
|
||||||
|
});
|
||||||
connect(action, &Core::Command::keySequenceChanged, this, [action, this]() {
|
bindCommandShortcut(Constants::QODE_ASSIST_CHAT_CLEAR_SESSION, [this] {
|
||||||
if (m_closeShortcut) {
|
QMetaObject::invokeMethod(rootObject(), "clearChat");
|
||||||
m_closeShortcut->setKey(action->keySequence());
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreSettings();
|
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)
|
void ChatView::closeEvent(QCloseEvent *event)
|
||||||
{
|
{
|
||||||
saveSettings();
|
saveSettings();
|
||||||
|
|||||||
@@ -3,17 +3,30 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
#include <utils/id.h>
|
||||||
|
|
||||||
#include <QQuickView>
|
#include <QQuickView>
|
||||||
#include <QShortcut>
|
#include <QShortcut>
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
class ChatView : public QQuickView
|
class ChatView : public QQuickView
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
Q_PROPERTY(bool isPin READ isPin WRITE setIsPin NOTIFY isPinChanged FINAL)
|
Q_PROPERTY(bool isPin READ isPin WRITE setIsPin NOTIFY isPinChanged FINAL)
|
||||||
public:
|
public:
|
||||||
ChatView();
|
ChatView(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager);
|
||||||
|
|
||||||
bool isPin() const;
|
bool isPin() const;
|
||||||
void setIsPin(bool newIsPin);
|
void setIsPin(bool newIsPin);
|
||||||
@@ -27,9 +40,9 @@ protected:
|
|||||||
private:
|
private:
|
||||||
void saveSettings();
|
void saveSettings();
|
||||||
void restoreSettings();
|
void restoreSettings();
|
||||||
|
void bindCommandShortcut(Utils::Id commandId, const std::function<void()> &onActivated);
|
||||||
|
|
||||||
bool m_isPin;
|
bool m_isPin;
|
||||||
QShortcut *m_closeShortcut;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -3,16 +3,54 @@
|
|||||||
|
|
||||||
#include "ChatWidget.hpp"
|
#include "ChatWidget.hpp"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
#include <QQmlContext>
|
#include <QQmlContext>
|
||||||
#include <QQmlEngine>
|
#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 {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ChatWidget::ChatWidget(QWidget *parent)
|
ChatWidget::ChatWidget(
|
||||||
: QQuickWidget(parent)
|
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);
|
setResizeMode(QQuickWidget::SizeRootObjectToView);
|
||||||
|
setFocusPolicy(Qt::StrongFocus);
|
||||||
|
|
||||||
|
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::clear()
|
void ChatWidget::clear()
|
||||||
@@ -24,4 +62,35 @@ void ChatWidget::scrollToBottom()
|
|||||||
{
|
{
|
||||||
QMetaObject::invokeMethod(rootObject(), "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
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -5,21 +5,43 @@
|
|||||||
|
|
||||||
#include <QtQuickWidgets/QtQuickWidgets>
|
#include <QtQuickWidgets/QtQuickWidgets>
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
class ChatWidget : public QQuickWidget
|
class ChatWidget : public QQuickWidget
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ChatWidget(QWidget *parent = nullptr);
|
explicit ChatWidget(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager,
|
||||||
|
bool registerOwnContext = true,
|
||||||
|
QWidget *parent = nullptr);
|
||||||
~ChatWidget() = default;
|
~ChatWidget() = default;
|
||||||
|
|
||||||
Q_INVOKABLE void clear();
|
Q_INVOKABLE void clear();
|
||||||
Q_INVOKABLE void scrollToBottom();
|
Q_INVOKABLE void scrollToBottom();
|
||||||
|
Q_INVOKABLE void focusInput();
|
||||||
|
|
||||||
|
void sendMessage();
|
||||||
|
void clearSession();
|
||||||
|
|
||||||
|
bool isChatFocused() const;
|
||||||
|
|
||||||
|
static ChatWidget *focusedInstance();
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void clearPressed();
|
void clearPressed();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void focusInEvent(QFocusEvent *event) override;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QMimeDatabase>
|
#include <QMimeDatabase>
|
||||||
|
#include <QRegularExpression>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
|
|
||||||
#include <coreplugin/editormanager/editormanager.h>
|
#include <coreplugin/editormanager/editormanager.h>
|
||||||
@@ -28,27 +29,36 @@
|
|||||||
|
|
||||||
#include <LLMQore/ToolsManager.hpp>
|
#include <LLMQore/ToolsManager.hpp>
|
||||||
|
|
||||||
|
#include "tools/ReadOriginalHistoryTool.hpp"
|
||||||
#include "tools/TodoTool.hpp"
|
#include "tools/TodoTool.hpp"
|
||||||
|
|
||||||
#include "ChatAssistantSettings.hpp"
|
#include "ChatAssistantSettings.hpp"
|
||||||
#include "ChatSerializer.hpp"
|
#include "ChatSerializer.hpp"
|
||||||
#include "GeneralSettings.hpp"
|
#include "GeneralSettings.hpp"
|
||||||
#include "Logger.hpp"
|
#include "Logger.hpp"
|
||||||
|
#include "ProjectSettings.hpp"
|
||||||
#include "ProvidersManager.hpp"
|
#include "ProvidersManager.hpp"
|
||||||
|
#include "SkillsSettings.hpp"
|
||||||
#include "ToolsSettings.hpp"
|
#include "ToolsSettings.hpp"
|
||||||
#include <RulesLoader.hpp>
|
#include <RulesLoader.hpp>
|
||||||
#include <context/ChangesManager.h>
|
#include <context/ChangesManager.h>
|
||||||
|
#include <sources/skills/SkillsManager.hpp>
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ClientInterface::ClientInterface(
|
ClientInterface::ClientInterface(
|
||||||
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent)
|
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
, m_chatModel(chatModel)
|
|
||||||
, m_promptProvider(promptProvider)
|
, m_promptProvider(promptProvider)
|
||||||
|
, m_chatModel(chatModel)
|
||||||
, m_contextManager(new Context::ContextManager(this))
|
, m_contextManager(new Context::ContextManager(this))
|
||||||
{}
|
{}
|
||||||
|
|
||||||
|
void ClientInterface::setSkillsManager(Skills::SkillsManager *skillsManager)
|
||||||
|
{
|
||||||
|
m_skillsManager = skillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
ClientInterface::~ClientInterface()
|
ClientInterface::~ClientInterface()
|
||||||
{
|
{
|
||||||
cancelRequest();
|
cancelRequest();
|
||||||
@@ -61,6 +71,11 @@ void ClientInterface::sendMessage(
|
|||||||
bool useTools,
|
bool useTools,
|
||||||
bool useThinking)
|
bool useThinking)
|
||||||
{
|
{
|
||||||
|
if (message.trimmed().isEmpty() && attachments.isEmpty()) {
|
||||||
|
LOG_MESSAGE("Ignoring empty chat message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
cancelRequest();
|
cancelRequest();
|
||||||
m_accumulatedResponses.clear();
|
m_accumulatedResponses.clear();
|
||||||
|
|
||||||
@@ -159,14 +174,21 @@ void ClientInterface::sendMessage(
|
|||||||
auto project = PluginLLMCore::RulesLoader::getActiveProject();
|
auto project = PluginLLMCore::RulesLoader::getActiveProject();
|
||||||
|
|
||||||
if (project) {
|
if (project) {
|
||||||
systemPrompt += QString("\n# Active project name: %1").arg(project->displayName());
|
systemPrompt += QString("\n# Active project: %1").arg(project->displayName());
|
||||||
systemPrompt += QString("\n# Active Project path: %1")
|
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());
|
.arg(project->projectDirectory().toUrlishString());
|
||||||
|
|
||||||
if (auto target = project->activeTarget()) {
|
if (auto target = project->activeTarget()) {
|
||||||
if (auto buildConfig = target->activeBuildConfiguration()) {
|
if (auto buildConfig = target->activeBuildConfiguration()) {
|
||||||
systemPrompt += QString("\n# Active Build directory: %1")
|
systemPrompt
|
||||||
.arg(buildConfig->buildDirectory().toUrlishString());
|
+= 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");
|
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()) {
|
if (!linkedFiles.isEmpty()) {
|
||||||
systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles);
|
systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles);
|
||||||
}
|
}
|
||||||
context.systemPrompt = systemPrompt;
|
context.systemPrompt = systemPrompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bool toolHistory = promptTemplate->supportsToolHistory();
|
||||||
|
|
||||||
QVector<PluginLLMCore::Message> messages;
|
QVector<PluginLLMCore::Message> messages;
|
||||||
|
int toolCallMsgIdx = -1;
|
||||||
for (const auto &msg : m_chatModel->getChatHistory()) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +349,12 @@ void ClientInterface::sendMessage(
|
|||||||
this,
|
this,
|
||||||
&ClientInterface::handleFullResponse,
|
&ClientInterface::handleFullResponse,
|
||||||
Qt::UniqueConnection);
|
Qt::UniqueConnection);
|
||||||
|
connect(
|
||||||
|
provider->client(),
|
||||||
|
&::LLMQore::BaseClient::requestFinalized,
|
||||||
|
this,
|
||||||
|
&ClientInterface::handleRequestFinalized,
|
||||||
|
Qt::UniqueConnection);
|
||||||
connect(
|
connect(
|
||||||
provider->client(),
|
provider->client(),
|
||||||
&::LLMQore::BaseClient::requestFailed,
|
&::LLMQore::BaseClient::requestFailed,
|
||||||
@@ -289,7 +387,7 @@ void ClientInterface::sendMessage(
|
|||||||
= provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint);
|
= provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint);
|
||||||
QJsonObject request{{"id", requestId}};
|
QJsonObject request{{"id", requestId}};
|
||||||
|
|
||||||
m_activeRequests[requestId] = {request, provider};
|
m_activeRequests[requestId] = {request, provider, !toolHistory};
|
||||||
|
|
||||||
emit requestStarted(requestId);
|
emit requestStarted(requestId);
|
||||||
|
|
||||||
@@ -299,6 +397,10 @@ void ClientInterface::sendMessage(
|
|||||||
provider->toolsManager()->tool("todo_tool"))) {
|
provider->toolsManager()->tool("todo_tool"))) {
|
||||||
todoTool->setCurrentSessionId(m_chatFilePath);
|
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);
|
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)
|
void ClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
|
||||||
{
|
{
|
||||||
auto it = m_activeRequests.find(requestId);
|
auto it = m_activeRequests.find(requestId);
|
||||||
@@ -485,14 +610,21 @@ void ClientInterface::handleThinkingBlockReceived(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ClientInterface::handleToolExecutionStarted(
|
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));
|
LOG_MESSAGE(QString("Ignoring tool execution start for non-chat request: %1").arg(requestId));
|
||||||
return;
|
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);
|
m_awaitingContinuation.insert(requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,13 @@
|
|||||||
#include "ChatModel.hpp"
|
#include "ChatModel.hpp"
|
||||||
#include "Provider.hpp"
|
#include "Provider.hpp"
|
||||||
#include "pluginllmcore/IPromptProvider.hpp"
|
#include "pluginllmcore/IPromptProvider.hpp"
|
||||||
|
#include <LLMQore/BaseClient.hpp>
|
||||||
#include <context/ContextManager.hpp>
|
#include <context/ContextManager.hpp>
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
class ClientInterface : public QObject
|
class ClientInterface : public QObject
|
||||||
@@ -24,6 +29,8 @@ public:
|
|||||||
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent = nullptr);
|
ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent = nullptr);
|
||||||
~ClientInterface();
|
~ClientInterface();
|
||||||
|
|
||||||
|
void setSkillsManager(Skills::SkillsManager *skillsManager);
|
||||||
|
|
||||||
void sendMessage(
|
void sendMessage(
|
||||||
const QString &message,
|
const QString &message,
|
||||||
const QList<QString> &attachments = {},
|
const QList<QString> &attachments = {},
|
||||||
@@ -42,15 +49,21 @@ signals:
|
|||||||
void errorOccurred(const QString &error);
|
void errorOccurred(const QString &error);
|
||||||
void messageReceivedCompletely();
|
void messageReceivedCompletely();
|
||||||
void requestStarted(const QString &requestId);
|
void requestStarted(const QString &requestId);
|
||||||
|
void messageUsageReceived(
|
||||||
|
int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void handlePartialResponse(const QString &requestId, const QString &partialText);
|
void handlePartialResponse(const QString &requestId, const QString &partialText);
|
||||||
void handleFullResponse(const QString &requestId, const QString &fullText);
|
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 handleRequestFailed(const QString &requestId, const QString &error);
|
||||||
void handleThinkingBlockReceived(
|
void handleThinkingBlockReceived(
|
||||||
const QString &requestId, const QString &thinking, const QString &signature);
|
const QString &requestId, const QString &thinking, const QString &signature);
|
||||||
void handleToolExecutionStarted(
|
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(
|
void handleToolExecutionCompleted(
|
||||||
const QString &requestId,
|
const QString &requestId,
|
||||||
const QString &toolId,
|
const QString &toolId,
|
||||||
@@ -71,11 +84,13 @@ private:
|
|||||||
{
|
{
|
||||||
QJsonObject originalRequest;
|
QJsonObject originalRequest;
|
||||||
PluginLLMCore::Provider *provider;
|
PluginLLMCore::Provider *provider;
|
||||||
|
bool dropPreToolText = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
PluginLLMCore::IPromptProvider *m_promptProvider = nullptr;
|
PluginLLMCore::IPromptProvider *m_promptProvider = nullptr;
|
||||||
ChatModel *m_chatModel;
|
ChatModel *m_chatModel;
|
||||||
Context::ContextManager *m_contextManager;
|
Context::ContextManager *m_contextManager;
|
||||||
|
Skills::SkillsManager *m_skillsManager = nullptr;
|
||||||
QString m_chatFilePath;
|
QString m_chatFilePath;
|
||||||
|
|
||||||
QHash<QString, RequestContext> m_activeRequests;
|
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 |
@@ -1,17 +1,6 @@
|
|||||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="44" height="44" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g clip-path="url(#clip0_74_52)">
|
<g transform="translate(10 8) skewX(-15)" stroke="black" stroke-width="2" stroke-linejoin="round">
|
||||||
<mask id="mask0_74_52" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="44" height="44">
|
<rect x="10" y="0" width="22" height="15" rx="3" ry="3" fill="black"/>
|
||||||
<path d="M44 0H0V44H44V0Z" fill="white"/>
|
<rect x="0" y="12" width="22" height="15" rx="3" ry="3" fill="none"/>
|
||||||
</mask>
|
</g>
|
||||||
<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>
|
</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.preferredWidth: parent.width
|
||||||
Layout.preferredHeight: childrenRect.height + 10
|
Layout.preferredHeight: childrenRect.height + 10
|
||||||
|
|
||||||
|
isInEditor: root.isInEditor
|
||||||
saveButton.onClicked: root.showSaveDialog()
|
saveButton.onClicked: root.showSaveDialog()
|
||||||
loadButton.onClicked: root.showLoadDialog()
|
loadButton.onClicked: root.showLoadDialog()
|
||||||
clearButton.onClicked: root.clearChat()
|
clearButton.onClicked: root.clearChat()
|
||||||
|
newChatButton.onClicked: root.requestNewChat()
|
||||||
tokensBadge {
|
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 {
|
recentPath {
|
||||||
text: qsTr("Сhat name: %1").arg(root.chatFileName.length > 0 ? root.chatFileName : "Unsaved")
|
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
|
checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false
|
||||||
onCheckedChanged: _chatview.isPin = topBar.pinButton.checked
|
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"
|
||||||
|
ToolTip.text: (typeof _chatview !== 'undefined')
|
||||||
|
? qsTr("Move this chat to an editor tab")
|
||||||
|
: qsTr("Move this chat to a separate window")
|
||||||
|
onClicked: {
|
||||||
|
if (typeof _chatview !== 'undefined')
|
||||||
|
root.relocateToSplit()
|
||||||
|
else
|
||||||
|
root.relocateToWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
toolsButton {
|
toolsButton {
|
||||||
checked: root.useTools
|
checked: root.useTools
|
||||||
onCheckedChanged: {
|
onCheckedChanged: {
|
||||||
@@ -259,6 +290,7 @@ ChatRootView {
|
|||||||
id: chatItemInstance
|
id: chatItemInstance
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
chatViewport: chatListView
|
||||||
msgModel: root.chatModel.processMessageContent(model.content)
|
msgModel: root.chatModel.processMessageContent(model.content)
|
||||||
messageAttachments: model.attachments
|
messageAttachments: model.attachments
|
||||||
messageImages: model.images
|
messageImages: model.images
|
||||||
@@ -270,6 +302,10 @@ ChatRootView {
|
|||||||
codeFontSize: root.codeFontSize
|
codeFontSize: root.codeFontSize
|
||||||
textFontSize: root.textFontSize
|
textFontSize: root.textFontSize
|
||||||
textFormat: root.textFormat
|
textFormat: root.textFormat
|
||||||
|
promptTokens: model.promptTokens || 0
|
||||||
|
completionTokens: model.completionTokens || 0
|
||||||
|
cachedPromptTokens: model.cachedPromptTokens || 0
|
||||||
|
reasoningTokens: model.reasoningTokens || 0
|
||||||
|
|
||||||
onResetChatToMessage: function(idx) {
|
onResetChatToMessage: function(idx) {
|
||||||
messageInput.text = model.content
|
messageInput.text = model.content
|
||||||
@@ -372,15 +408,31 @@ ChatRootView {
|
|||||||
root.calculateMessageTokensCount(messageInput.text)
|
root.calculateMessageTokensCount(messageInput.text)
|
||||||
var cursorPos = messageInput.cursorPosition
|
var cursorPos = messageInput.cursorPosition
|
||||||
var textBefore = messageInput.text.substring(0, cursorPos)
|
var textBefore = messageInput.text.substring(0, cursorPos)
|
||||||
|
|
||||||
var atIndex = textBefore.lastIndexOf('@')
|
var atIndex = textBefore.lastIndexOf('@')
|
||||||
if (atIndex >= 0) {
|
if (atIndex >= 0) {
|
||||||
var query = textBefore.substring(atIndex + 1)
|
var query = textBefore.substring(atIndex + 1)
|
||||||
if (query.indexOf(' ') === -1 && query.indexOf('\n') === -1) {
|
if (query.indexOf(' ') === -1 && query.indexOf('\n') === -1) {
|
||||||
fileMentionPopup.updateSearch(query)
|
fileMentionPopup.updateSearch(query)
|
||||||
|
skillCommandPopup.dismiss()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fileMentionPopup.dismiss()
|
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) {
|
Keys.onPressed: function(event) {
|
||||||
@@ -398,6 +450,20 @@ ChatRootView {
|
|||||||
fileMentionPopup.dismiss()
|
fileMentionPopup.dismiss()
|
||||||
event.accepted = true
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,15 +575,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() {
|
function clearChat() {
|
||||||
root.clearMessages()
|
root.clearMessages()
|
||||||
root.clearAttachmentFiles()
|
root.clearAttachmentFiles()
|
||||||
@@ -528,6 +585,10 @@ ChatRootView {
|
|||||||
Qt.callLater(chatListView.positionViewAtEnd)
|
Qt.callLater(chatListView.positionViewAtEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
messageInput.forceActiveFocus()
|
||||||
|
}
|
||||||
|
|
||||||
function applyMentionSelection() {
|
function applyMentionSelection() {
|
||||||
var result = fileMentionPopup.applyCurrentSelection(
|
var result = fileMentionPopup.applyCurrentSelection(
|
||||||
messageInput.text, messageInput.cursorPosition, root.useTools)
|
messageInput.text, messageInput.cursorPosition, root.useTools)
|
||||||
@@ -537,6 +598,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() {
|
function sendChatMessage() {
|
||||||
root.sendMessage(fileMentionPopup.expandMentions(messageInput.text))
|
root.sendMessage(fileMentionPopup.expandMentions(messageInput.text))
|
||||||
messageInput.text = ""
|
messageInput.text = ""
|
||||||
@@ -636,7 +714,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: {
|
Component.onCompleted: {
|
||||||
messageInput.forceActiveFocus()
|
focusInput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,16 @@ Rectangle {
|
|||||||
property int textFontSize: Qt.application.font.pointSize
|
property int textFontSize: Qt.application.font.pointSize
|
||||||
property int codeFontSize: Qt.application.font.pointSize
|
property int codeFontSize: Qt.application.font.pointSize
|
||||||
property int textFormat: 0
|
property int textFormat: 0
|
||||||
|
property Flickable chatViewport: null
|
||||||
|
|
||||||
property bool isUserMessage: false
|
property bool isUserMessage: false
|
||||||
property int messageIndex: -1
|
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 resetChatToMessage(int index)
|
||||||
signal openFileRequested(string filePath)
|
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 {
|
Rectangle {
|
||||||
@@ -221,6 +260,7 @@ Rectangle {
|
|||||||
language: itemData.language
|
language: itemData.language
|
||||||
codeFontFamily: root.codeFontFamily
|
codeFontFamily: root.codeFontFamily
|
||||||
codeFontSize: root.codeFontSize
|
codeFontSize: root.codeFontSize
|
||||||
|
viewport: root.chatViewport
|
||||||
}
|
}
|
||||||
|
|
||||||
component AttachmentComponent : Rectangle {
|
component AttachmentComponent : Rectangle {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Rectangle {
|
|||||||
property string code: ""
|
property string code: ""
|
||||||
property string language: ""
|
property string language: ""
|
||||||
property bool expanded: false
|
property bool expanded: false
|
||||||
|
property Flickable viewport: null
|
||||||
|
|
||||||
property alias codeFontFamily: codeText.font.family
|
property alias codeFontFamily: codeText.font.family
|
||||||
property alias codeFontSize: codeText.font.pointSize
|
property alias codeFontSize: codeText.font.pointSize
|
||||||
@@ -122,7 +123,16 @@ Rectangle {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.rightMargin: 5
|
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")
|
text: qsTr("Copy")
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
|
|||||||
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,13 +10,17 @@ import UIControls
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
property bool isInEditor: false
|
||||||
|
|
||||||
property alias saveButton: saveButtonId
|
property alias saveButton: saveButtonId
|
||||||
property alias loadButton: loadButtonId
|
property alias loadButton: loadButtonId
|
||||||
property alias clearButton: clearButtonId
|
property alias clearButton: clearButtonId
|
||||||
|
property alias newChatButton: newChatButtonId
|
||||||
property alias tokensBadge: tokensBadgeId
|
property alias tokensBadge: tokensBadgeId
|
||||||
property alias recentPath: recentPathId
|
property alias recentPath: recentPathId
|
||||||
property alias openChatHistory: openChatHistoryId
|
property alias openChatHistory: openChatHistoryId
|
||||||
property alias pinButton: pinButtonId
|
property alias pinButton: pinButtonId
|
||||||
|
property alias relocateButton: relocateButtonId
|
||||||
property alias contextButton: contextButtonId
|
property alias contextButton: contextButtonId
|
||||||
property alias toolsButton: toolsButtonId
|
property alias toolsButton: toolsButtonId
|
||||||
property alias thinkingMode: thinkingModeId
|
property alias thinkingMode: thinkingModeId
|
||||||
@@ -61,6 +65,58 @@ Rectangle {
|
|||||||
: qsTr("Pin chat window to the top")
|
: 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
|
||||||
|
}
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.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
|
||||||
|
}
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.delay: 250
|
||||||
|
ToolTip.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
|
||||||
|
}
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.delay: 250
|
||||||
|
ToolTip.text: qsTr("Open new chat in a new tab")
|
||||||
|
}
|
||||||
|
|
||||||
QoAComboBox {
|
QoAComboBox {
|
||||||
id: configSelectorId
|
id: configSelectorId
|
||||||
|
|
||||||
@@ -260,21 +316,6 @@ Rectangle {
|
|||||||
ToolTip.delay: 250
|
ToolTip.delay: 250
|
||||||
ToolTip.text: qsTr("Current amount tokens in chat and LLM limit threshold")
|
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);
|
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)
|
void LLMClientInterface::handleRequestFailed(const QString &requestId, const QString &error)
|
||||||
{
|
{
|
||||||
auto it = m_activeRequests.find(requestId);
|
auto it = m_activeRequests.find(requestId);
|
||||||
@@ -325,6 +340,12 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request)
|
|||||||
this,
|
this,
|
||||||
&LLMClientInterface::handleFullResponse,
|
&LLMClientInterface::handleFullResponse,
|
||||||
Qt::UniqueConnection);
|
Qt::UniqueConnection);
|
||||||
|
connect(
|
||||||
|
provider->client(),
|
||||||
|
&::LLMQore::BaseClient::requestFinalized,
|
||||||
|
this,
|
||||||
|
&LLMClientInterface::handleRequestFinalized,
|
||||||
|
Qt::UniqueConnection);
|
||||||
connect(
|
connect(
|
||||||
provider->client(),
|
provider->client(),
|
||||||
&::LLMQore::BaseClient::requestFailed,
|
&::LLMQore::BaseClient::requestFailed,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <LLMQore/BaseClient.hpp>
|
||||||
#include <languageclient/languageclientinterface.h>
|
#include <languageclient/languageclientinterface.h>
|
||||||
#include <texteditor/texteditor.h>
|
#include <texteditor/texteditor.h>
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ protected:
|
|||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void handleFullResponse(const QString &requestId, const QString &fullText);
|
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 handleRequestFailed(const QString &requestId, const QString &error);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Id" : "qodeassist",
|
"Id" : "qodeassist",
|
||||||
"Name" : "QodeAssist",
|
"Name" : "QodeAssist",
|
||||||
"Version" : "0.9.13",
|
"Version" : "0.9.16",
|
||||||
"CompatVersion" : "${IDE_VERSION}",
|
"CompatVersion" : "${IDE_VERSION}",
|
||||||
"Vendor" : "Petr Mironychev",
|
"Vendor" : "Petr Mironychev",
|
||||||
"VendorId" : "petrmironychev",
|
"VendorId" : "petrmironychev",
|
||||||
|
|||||||
@@ -10,4 +10,16 @@ const char MENU_ID[] = "QodeAssist.Menu";
|
|||||||
|
|
||||||
const char QODE_ASSIST_REQUEST_SUGGESTION[] = "QodeAssist.RequestSuggestion";
|
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
|
} // namespace QodeAssist::Constants
|
||||||
|
|||||||
@@ -152,6 +152,13 @@ void QuickRefactorHandler::prepareAndSendRequest(
|
|||||||
&QuickRefactorHandler::handleFullResponse,
|
&QuickRefactorHandler::handleFullResponse,
|
||||||
Qt::UniqueConnection);
|
Qt::UniqueConnection);
|
||||||
|
|
||||||
|
connect(
|
||||||
|
provider->client(),
|
||||||
|
&::LLMQore::BaseClient::requestFinalized,
|
||||||
|
this,
|
||||||
|
&QuickRefactorHandler::handleRequestFinalized,
|
||||||
|
Qt::UniqueConnection);
|
||||||
|
|
||||||
connect(
|
connect(
|
||||||
provider->client(),
|
provider->client(),
|
||||||
&::LLMQore::BaseClient::requestFailed,
|
&::LLMQore::BaseClient::requestFailed,
|
||||||
@@ -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)
|
void QuickRefactorHandler::handleRequestFailed(const QString &requestId, const QString &error)
|
||||||
{
|
{
|
||||||
if (requestId == m_lastRequestId) {
|
if (requestId == m_lastRequestId) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
|
||||||
|
#include <LLMQore/BaseClient.hpp>
|
||||||
#include <texteditor/texteditor.h>
|
#include <texteditor/texteditor.h>
|
||||||
#include <utils/textutils.h>
|
#include <utils/textutils.h>
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ signals:
|
|||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void handleFullResponse(const QString &requestId, const QString &fullText);
|
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 handleRequestFailed(const QString &requestId, const QString &error);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -35,6 +35,7 @@ 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
|
- **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
|
- **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 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 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)
|
- **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
|
- **File Context** — attach, link, or auto-sync open editor files for richer prompts
|
||||||
@@ -253,6 +254,41 @@ Chat and Quick Refactor can call tools to inspect and modify your project. Each
|
|||||||
| `execute_terminal_command` | Run a shell command (with confirmation) |
|
| `execute_terminal_command` | Run a shell command (with confirmation) |
|
||||||
| `todo_tool` | Track multi-step task progress during a conversation |
|
| `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
|
### 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.
|
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 +490,7 @@ For additional support, join our [Discord Community](https://discord.gg/BGMkUsXU
|
|||||||
- [x] Quick refactoring with custom-instructions library
|
- [x] Quick refactoring with custom-instructions library
|
||||||
- [x] Diff sharing with models
|
- [x] Diff sharing with models
|
||||||
- [x] Tools / function calling (file I/O, build, terminal, diagnostics)
|
- [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] Project-specific rules (`.qodeassist/rules/`)
|
||||||
- [x] MCP (Model Context Protocol) — QodeAssist as a server
|
- [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)
|
- [x] MCP — QodeAssist as a client (consume external MCP tools; authenticated MCP servers not yet supported)
|
||||||
@@ -470,6 +507,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.
|
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:
|
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`
|
- Bitcoin (BTC): `bc1qndq7f0mpnlya48vk7kugvyqj5w89xrg4wzg68t`
|
||||||
- Ethereum (ETH): `0xA5e8c37c94b24e25F9f1f292a01AF55F03099D8D`
|
- Ethereum (ETH): `0xA5e8c37c94b24e25F9f1f292a01AF55F03099D8D`
|
||||||
- Litecoin (LTC): `ltc1qlrxnk30s2pcjchzx4qrxvdjt5gzuervy5mv0vy`
|
- Litecoin (LTC): `ltc1qlrxnk30s2pcjchzx4qrxvdjt5gzuervy5mv0vy`
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
#include "UpdateStatusWidget.hpp"
|
#include "UpdateStatusWidget.hpp"
|
||||||
|
|
||||||
|
#include <QMenu>
|
||||||
|
|
||||||
namespace QodeAssist {
|
namespace QodeAssist {
|
||||||
|
|
||||||
UpdateStatusWidget::UpdateStatusWidget(QWidget *parent)
|
UpdateStatusWidget::UpdateStatusWidget(QWidget *parent)
|
||||||
@@ -57,6 +59,16 @@ void UpdateStatusWidget::setChatButtonAction(QAction *action)
|
|||||||
m_chatButton->setDefaultAction(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
|
QPushButton *UpdateStatusWidget::updateButton() const
|
||||||
{
|
{
|
||||||
return m_updateButton;
|
return m_updateButton;
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
|
|
||||||
|
QT_BEGIN_NAMESPACE
|
||||||
|
class QMenu;
|
||||||
|
QT_END_NAMESPACE
|
||||||
|
|
||||||
namespace QodeAssist {
|
namespace QodeAssist {
|
||||||
|
|
||||||
class UpdateStatusWidget : public QFrame
|
class UpdateStatusWidget : public QFrame
|
||||||
@@ -21,6 +25,7 @@ public:
|
|||||||
void showUpdateAvailable(const QString &version);
|
void showUpdateAvailable(const QString &version);
|
||||||
void hideUpdateInfo();
|
void hideUpdateInfo();
|
||||||
void setChatButtonAction(QAction *action);
|
void setChatButtonAction(QAction *action);
|
||||||
|
void setChatButtonMenu(QMenu *menu);
|
||||||
|
|
||||||
QPushButton *updateButton() const;
|
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 {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ChatOutputPane::ChatOutputPane(QObject *parent)
|
ChatOutputPane::ChatOutputPane(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager,
|
||||||
|
QObject *parent)
|
||||||
: Core::IOutputPane(parent)
|
: Core::IOutputPane(parent)
|
||||||
, m_chatWidget(new ChatWidget)
|
, m_chatWidget{new ChatWidget{engine, sessionFileRegistry, skillsManager}}
|
||||||
{
|
{
|
||||||
setId("QodeAssistChat");
|
setId("QodeAssistChat");
|
||||||
setDisplayName(Tr::tr("QodeAssist Chat"));
|
setDisplayName(Tr::tr("QodeAssist Chat"));
|
||||||
@@ -38,18 +42,20 @@ void ChatOutputPane::clearContents()
|
|||||||
|
|
||||||
void ChatOutputPane::visibilityChanged(bool visible)
|
void ChatOutputPane::visibilityChanged(bool visible)
|
||||||
{
|
{
|
||||||
if (visible)
|
if (visible) {
|
||||||
m_chatWidget->scrollToBottom();
|
m_chatWidget->scrollToBottom();
|
||||||
|
m_chatWidget->focusInput();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatOutputPane::setFocus()
|
void ChatOutputPane::setFocus()
|
||||||
{
|
{
|
||||||
m_chatWidget->setFocus();
|
m_chatWidget->focusInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatOutputPane::hasFocus() const
|
bool ChatOutputPane::hasFocus() const
|
||||||
{
|
{
|
||||||
return m_chatWidget->hasFocus();
|
return m_chatWidget->isChatFocused();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatOutputPane::canFocus() const
|
bool ChatOutputPane::canFocus() const
|
||||||
|
|||||||
@@ -6,14 +6,24 @@
|
|||||||
#include "ChatView/ChatWidget.hpp"
|
#include "ChatView/ChatWidget.hpp"
|
||||||
#include <coreplugin/ioutputpane.h>
|
#include <coreplugin/ioutputpane.h>
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
class ChatOutputPane : public Core::IOutputPane
|
class ChatOutputPane : public Core::IOutputPane
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ChatOutputPane(QObject *parent = nullptr);
|
explicit ChatOutputPane(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager,
|
||||||
|
QObject *parent = nullptr);
|
||||||
~ChatOutputPane() override;
|
~ChatOutputPane() override;
|
||||||
|
|
||||||
QWidget *outputWidget(QWidget *parent) override;
|
QWidget *outputWidget(QWidget *parent) override;
|
||||||
|
|||||||
@@ -4,14 +4,23 @@
|
|||||||
#include "NavigationPanel.hpp"
|
#include "NavigationPanel.hpp"
|
||||||
|
|
||||||
#include "ChatView/ChatWidget.hpp"
|
#include "ChatView/ChatWidget.hpp"
|
||||||
|
#include "ChatView/SessionFileRegistry.hpp"
|
||||||
|
#include "QodeAssistConstants.hpp"
|
||||||
|
#include "sources/skills/SkillsManager.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
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"));
|
setDisplayName(tr("QodeAssist Chat"));
|
||||||
setPriority(500);
|
setPriority(500);
|
||||||
setId("QodeAssistChat");
|
setId(Constants::QODE_ASSIST_CHAT_NAV_ID);
|
||||||
setActivationSequence(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_C));
|
setActivationSequence(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_C));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,10 +28,7 @@ NavigationPanel::~NavigationPanel() {}
|
|||||||
|
|
||||||
Core::NavigationView NavigationPanel::createWidget()
|
Core::NavigationView NavigationPanel::createWidget()
|
||||||
{
|
{
|
||||||
Core::NavigationView view;
|
return {.widget = new ChatWidget{m_engine, m_sessionFileRegistry, m_skillsManager}};
|
||||||
view.widget = new ChatWidget;
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -5,17 +5,34 @@
|
|||||||
|
|
||||||
#include <coreplugin/inavigationwidgetfactory.h>
|
#include <coreplugin/inavigationwidgetfactory.h>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
|
|
||||||
|
class QQmlEngine;
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
class NavigationPanel : public Core::INavigationWidgetFactory
|
class NavigationPanel : public Core::INavigationWidgetFactory
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
explicit NavigationPanel();
|
explicit NavigationPanel(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager);
|
||||||
~NavigationPanel();
|
~NavigationPanel();
|
||||||
|
|
||||||
Core::NavigationView createWidget() override;
|
Core::NavigationView createWidget() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QPointer<QQmlEngine> m_engine;
|
||||||
|
QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
||||||
|
QPointer<Skills::SkillsManager> m_skillsManager;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ add_library(Context STATIC
|
|||||||
target_link_libraries(Context
|
target_link_libraries(Context
|
||||||
PUBLIC
|
PUBLIC
|
||||||
Qt::Core
|
Qt::Core
|
||||||
|
Qt::Gui
|
||||||
QtCreator::Core
|
QtCreator::Core
|
||||||
QtCreator::TextEditor
|
QtCreator::TextEditor
|
||||||
QtCreator::Utils
|
QtCreator::Utils
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
|
|
||||||
#include "TokenUtils.hpp"
|
#include "TokenUtils.hpp"
|
||||||
|
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QImageReader>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QSize>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace QodeAssist::Context {
|
namespace QodeAssist::Context {
|
||||||
|
|
||||||
int TokenUtils::estimateTokens(const QString &text)
|
int TokenUtils::estimateTokens(const QString &text)
|
||||||
@@ -15,8 +23,48 @@ int TokenUtils::estimateTokens(const QString &text)
|
|||||||
return text.length() / 4;
|
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)
|
int TokenUtils::estimateFileTokens(const Context::ContentFile &file)
|
||||||
{
|
{
|
||||||
|
if (isImageFilePath(file.filename))
|
||||||
|
return estimateImageAttachmentTokens(QString());
|
||||||
|
|
||||||
int total = 0;
|
int total = 0;
|
||||||
|
|
||||||
total += estimateTokens(file.filename);
|
total += estimateTokens(file.filename);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public:
|
|||||||
static int estimateTokens(const QString &text);
|
static int estimateTokens(const QString &text);
|
||||||
static int estimateFileTokens(const Context::ContentFile &file);
|
static int estimateFileTokens(const Context::ContentFile &file);
|
||||||
static int estimateFilesTokens(const QList<Context::ContentFile> &files);
|
static int estimateFilesTokens(const QList<Context::ContentFile> &files);
|
||||||
|
static bool isImageFilePath(const QString &filePath);
|
||||||
|
static int estimateImageAttachmentTokens(const QString &filePath);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Context
|
} // namespace QodeAssist::Context
|
||||||
|
|||||||
@@ -189,19 +189,42 @@ QList<PluginLLMCore::Provider *> McpClientsManager::toolsCapableProviders() cons
|
|||||||
return out;
|
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 McpClientsManager::readRoot() const
|
||||||
{
|
{
|
||||||
|
QJsonObject root{{QLatin1String(kServersKey), QJsonObject{}}};
|
||||||
|
|
||||||
QFile f(configFilePath());
|
QFile f(configFilePath());
|
||||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
|
if (f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
return QJsonObject{{QLatin1String(kServersKey), QJsonObject{}}};
|
QJsonParseError err;
|
||||||
QJsonParseError err;
|
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &err);
|
||||||
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &err);
|
f.close();
|
||||||
f.close();
|
if (err.error == QJsonParseError::NoError && doc.isObject())
|
||||||
if (err.error != QJsonParseError::NoError || !doc.isObject())
|
root = doc.object();
|
||||||
return QJsonObject{{QLatin1String(kServersKey), QJsonObject{}}};
|
}
|
||||||
QJsonObject root = doc.object();
|
|
||||||
if (!root.contains(QLatin1String(kServersKey)))
|
QJsonObject servers = root.value(QLatin1String(kServersKey)).toObject();
|
||||||
root.insert(QLatin1String(kServersKey), QJsonObject{});
|
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;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ private:
|
|||||||
void updateWatchedPaths();
|
void updateWatchedPaths();
|
||||||
|
|
||||||
QList<PluginLLMCore::Provider *> toolsCapableProviders() const;
|
QList<PluginLLMCore::Provider *> toolsCapableProviders() const;
|
||||||
|
static QJsonObject builtinServers();
|
||||||
QJsonObject readRoot() const;
|
QJsonObject readRoot() const;
|
||||||
bool writeRoot(const QJsonObject &root);
|
bool writeRoot(const QJsonObject &root);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QVector>
|
#include <QVector>
|
||||||
|
|
||||||
@@ -17,6 +18,15 @@ struct ImageAttachment
|
|||||||
bool operator==(const ImageAttachment &) const = default;
|
bool operator==(const ImageAttachment &) const = default;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct ToolCall
|
||||||
|
{
|
||||||
|
QString id;
|
||||||
|
QString name;
|
||||||
|
QJsonObject arguments;
|
||||||
|
|
||||||
|
bool operator==(const ToolCall &) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
struct Message
|
struct Message
|
||||||
{
|
{
|
||||||
QString role;
|
QString role;
|
||||||
@@ -26,6 +36,10 @@ struct Message
|
|||||||
bool isRedacted = false;
|
bool isRedacted = false;
|
||||||
std::optional<QVector<ImageAttachment>> images;
|
std::optional<QVector<ImageAttachment>> images;
|
||||||
|
|
||||||
|
QVector<ToolCall> toolCalls;
|
||||||
|
QString toolCallId;
|
||||||
|
QString toolName;
|
||||||
|
|
||||||
// clang-format off
|
// clang-format off
|
||||||
bool operator==(const Message&) const = default;
|
bool operator==(const Message&) const = default;
|
||||||
// clang-format on
|
// clang-format on
|
||||||
|
|||||||
@@ -25,12 +25,8 @@ public:
|
|||||||
virtual QString description() const = 0;
|
virtual QString description() const = 0;
|
||||||
virtual bool isSupportProvider(ProviderID id) 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 QString endpoint() const { return {}; }
|
||||||
|
|
||||||
|
virtual bool supportsToolHistory() const { return false; }
|
||||||
};
|
};
|
||||||
} // namespace QodeAssist::PluginLLMCore
|
} // namespace QodeAssist::PluginLLMCore
|
||||||
|
|||||||
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 <LLMQore/ToolsManager.hpp>
|
||||||
|
|
||||||
|
#include "ClaudeCacheControl.hpp"
|
||||||
#include "logger/Logger.hpp"
|
#include "logger/Logger.hpp"
|
||||||
#include "settings/ChatAssistantSettings.hpp"
|
#include "settings/ChatAssistantSettings.hpp"
|
||||||
#include "settings/CodeCompletionSettings.hpp"
|
#include "settings/CodeCompletionSettings.hpp"
|
||||||
@@ -104,6 +105,14 @@ void ClaudeProvider::prepareRequest(
|
|||||||
LOG_MESSAGE(QString("Added %1 tools to Claude request").arg(toolsDefinitions.size()));
|
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)
|
QFuture<QList<QString>> ClaudeProvider::getInstalledModels(const QString &baseUrl)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include <LLMQore/ToolsManager.hpp>
|
#include <LLMQore/ToolsManager.hpp>
|
||||||
|
|
||||||
|
#include "providers/ProviderUrlUtils.hpp"
|
||||||
#include "tools/ToolsRegistration.hpp"
|
#include "tools/ToolsRegistration.hpp"
|
||||||
#include "logger/Logger.hpp"
|
#include "logger/Logger.hpp"
|
||||||
#include "settings/ChatAssistantSettings.hpp"
|
#include "settings/ChatAssistantSettings.hpp"
|
||||||
@@ -38,12 +39,12 @@ QString LMStudioProvider::apiKey() const
|
|||||||
|
|
||||||
QString LMStudioProvider::url() const
|
QString LMStudioProvider::url() const
|
||||||
{
|
{
|
||||||
return "http://localhost:1234/v1";
|
return "http://localhost:1234";
|
||||||
}
|
}
|
||||||
|
|
||||||
QFuture<QList<QString>> LMStudioProvider::getInstalledModels(const QString &url)
|
QFuture<QList<QString>> LMStudioProvider::getInstalledModels(const QString &url)
|
||||||
{
|
{
|
||||||
m_client->setUrl(url);
|
m_client->setUrl(ensureOpenAIV1Base(url));
|
||||||
m_client->setApiKey(apiKey());
|
m_client->setApiKey(apiKey());
|
||||||
return m_client->listModels();
|
return m_client->listModels();
|
||||||
}
|
}
|
||||||
@@ -104,6 +105,13 @@ void LMStudioProvider::prepareRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PluginLLMCore::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
|
::LLMQore::BaseClient *LMStudioProvider::client() const
|
||||||
{
|
{
|
||||||
return m_client;
|
return m_client;
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public:
|
|||||||
::LLMQore::BaseClient *client() const override;
|
::LLMQore::BaseClient *client() const override;
|
||||||
QString apiKey() const override;
|
QString apiKey() const override;
|
||||||
|
|
||||||
|
PluginLLMCore::RequestID sendRequest(
|
||||||
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
::LLMQore::OpenAIClient *m_client;
|
::LLMQore::OpenAIClient *m_client;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <LLMQore/ToolsManager.hpp>
|
#include <LLMQore/ToolsManager.hpp>
|
||||||
|
|
||||||
#include "logger/Logger.hpp"
|
#include "logger/Logger.hpp"
|
||||||
|
#include "providers/ProviderUrlUtils.hpp"
|
||||||
#include "settings/ChatAssistantSettings.hpp"
|
#include "settings/ChatAssistantSettings.hpp"
|
||||||
#include "settings/CodeCompletionSettings.hpp"
|
#include "settings/CodeCompletionSettings.hpp"
|
||||||
#include "settings/GeneralSettings.hpp"
|
#include "settings/GeneralSettings.hpp"
|
||||||
@@ -112,10 +113,7 @@ void LMStudioResponsesProvider::prepareRequest(
|
|||||||
|
|
||||||
QFuture<QList<QString>> LMStudioResponsesProvider::getInstalledModels(const QString &baseUrl)
|
QFuture<QList<QString>> LMStudioResponsesProvider::getInstalledModels(const QString &baseUrl)
|
||||||
{
|
{
|
||||||
QString url = baseUrl;
|
m_client->setUrl(ensureOpenAIV1Base(baseUrl));
|
||||||
if (!url.endsWith(QStringLiteral("/v1")))
|
|
||||||
url += QStringLiteral("/v1");
|
|
||||||
m_client->setUrl(url);
|
|
||||||
m_client->setApiKey(apiKey());
|
m_client->setApiKey(apiKey());
|
||||||
return m_client->listModels();
|
return m_client->listModels();
|
||||||
}
|
}
|
||||||
@@ -123,9 +121,8 @@ QFuture<QList<QString>> LMStudioResponsesProvider::getInstalledModels(const QStr
|
|||||||
PluginLLMCore::RequestID LMStudioResponsesProvider::sendRequest(
|
PluginLLMCore::RequestID LMStudioResponsesProvider::sendRequest(
|
||||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||||
{
|
{
|
||||||
const QString effectiveEndpoint
|
return PluginLLMCore::Provider::sendRequest(
|
||||||
= endpoint.isEmpty() ? QStringLiteral("/v1/responses") : endpoint;
|
QUrl(ensureOpenAIV1Base(url.toString())), payload, endpoint);
|
||||||
return PluginLLMCore::Provider::sendRequest(url, payload, effectiveEndpoint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PluginLLMCore::ProviderID LMStudioResponsesProvider::providerID() const
|
PluginLLMCore::ProviderID LMStudioResponsesProvider::providerID() 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
|
||||||
216
qodeassist.cpp
216
qodeassist.cpp
@@ -10,10 +10,13 @@
|
|||||||
#include <coreplugin/actionmanager/actionmanager.h>
|
#include <coreplugin/actionmanager/actionmanager.h>
|
||||||
#include <coreplugin/actionmanager/command.h>
|
#include <coreplugin/actionmanager/command.h>
|
||||||
#include <coreplugin/coreconstants.h>
|
#include <coreplugin/coreconstants.h>
|
||||||
|
#include <coreplugin/editormanager/documentmodel.h>
|
||||||
|
#include <coreplugin/editormanager/editormanager.h>
|
||||||
#include <coreplugin/icontext.h>
|
#include <coreplugin/icontext.h>
|
||||||
#include <coreplugin/icore.h>
|
#include <coreplugin/icore.h>
|
||||||
#include <coreplugin/messagemanager.h>
|
#include <coreplugin/messagemanager.h>
|
||||||
#include <coreplugin/modemanager.h>
|
#include <coreplugin/modemanager.h>
|
||||||
|
#include <coreplugin/navigationwidget.h>
|
||||||
#include <coreplugin/statusbarmanager.h>
|
#include <coreplugin/statusbarmanager.h>
|
||||||
#include <extensionsystem/iplugin.h>
|
#include <extensionsystem/iplugin.h>
|
||||||
#include <languageclient/languageclientmanager.h>
|
#include <languageclient/languageclientmanager.h>
|
||||||
@@ -31,6 +34,8 @@
|
|||||||
#include "QodeAssistClient.hpp"
|
#include "QodeAssistClient.hpp"
|
||||||
#include "UpdateStatusWidget.hpp"
|
#include "UpdateStatusWidget.hpp"
|
||||||
#include "Version.hpp"
|
#include "Version.hpp"
|
||||||
|
#include "chat/ChatEditor.hpp"
|
||||||
|
#include "chat/ChatEditorFactory.hpp"
|
||||||
#include "chat/ChatOutputPane.h"
|
#include "chat/ChatOutputPane.h"
|
||||||
#include "chat/NavigationPanel.hpp"
|
#include "chat/NavigationPanel.hpp"
|
||||||
#include "context/DocumentReaderQtCreator.hpp"
|
#include "context/DocumentReaderQtCreator.hpp"
|
||||||
@@ -39,6 +44,8 @@
|
|||||||
#include "logger/RequestPerformanceLogger.hpp"
|
#include "logger/RequestPerformanceLogger.hpp"
|
||||||
#include "mcp/McpClientsManager.hpp"
|
#include "mcp/McpClientsManager.hpp"
|
||||||
#include "mcp/McpServerManager.hpp"
|
#include "mcp/McpServerManager.hpp"
|
||||||
|
#include "sources/skills/SkillsManager.hpp"
|
||||||
|
#include "tools/ToolsRegistration.hpp"
|
||||||
#include "providers/Providers.hpp"
|
#include "providers/Providers.hpp"
|
||||||
#include "settings/ChatAssistantSettings.hpp"
|
#include "settings/ChatAssistantSettings.hpp"
|
||||||
#include "settings/GeneralSettings.hpp"
|
#include "settings/GeneralSettings.hpp"
|
||||||
@@ -50,6 +57,11 @@
|
|||||||
#include "widgets/QuickRefactorDialog.hpp"
|
#include "widgets/QuickRefactorDialog.hpp"
|
||||||
#include <ChatView/ChatView.hpp>
|
#include <ChatView/ChatView.hpp>
|
||||||
#include <ChatView/ChatFileManager.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/actioncontainer.h>
|
||||||
#include <coreplugin/actionmanager/actionmanager.h>
|
#include <coreplugin/actionmanager/actionmanager.h>
|
||||||
#include <texteditor/textdocument.h>
|
#include <texteditor/textdocument.h>
|
||||||
@@ -84,6 +96,7 @@ public:
|
|||||||
if (m_navigationPanel) {
|
if (m_navigationPanel) {
|
||||||
delete m_navigationPanel;
|
delete m_navigationPanel;
|
||||||
}
|
}
|
||||||
|
delete m_chatEditorFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadTranslations()
|
void loadTranslations()
|
||||||
@@ -151,12 +164,30 @@ public:
|
|||||||
UpdateDialog::checkForUpdatesAndShow(Core::ICore::mainWindow());
|
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()) {
|
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()) {
|
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();
|
Settings::setupProjectPanel();
|
||||||
ConfigurationManager::instance().init();
|
ConfigurationManager::instance().init();
|
||||||
@@ -196,26 +227,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);
|
const QKeySequence showChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_W);
|
||||||
showChatViewAction.setDefaultKeySequence(showChatViewShortcut);
|
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.setText(Tr::tr("Show QodeAssist Chat"));
|
||||||
showChatViewAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
|
showChatViewAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
|
||||||
showChatViewAction.addOnTriggered(this, [this] {
|
showChatViewAction.addOnTriggered(this, [this] { openChatInEditor(); });
|
||||||
if (!m_chatView) {
|
|
||||||
m_chatView.reset(new Chat::ChatView());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!m_chatView->isVisible()) {
|
|
||||||
m_chatView->show();
|
|
||||||
}
|
|
||||||
|
|
||||||
m_chatView->raise();
|
|
||||||
m_chatView->requestActivate();
|
|
||||||
});
|
|
||||||
m_statusWidget->setChatButtonAction(showChatViewAction.contextAction());
|
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");
|
ActionBuilder closeChatViewAction(this, "QodeAssist.CloseChatView");
|
||||||
const QKeySequence closeChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_S);
|
const QKeySequence closeChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_S);
|
||||||
closeChatViewAction.setDefaultKeySequence(closeChatViewShortcut);
|
closeChatViewAction.setDefaultKeySequence(closeChatViewShortcut);
|
||||||
@@ -228,6 +256,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(
|
Core::ActionContainer *editorContextMenu = Core::ActionManager::actionContainer(
|
||||||
TextEditor::Constants::M_STANDARDCONTEXTMENU);
|
TextEditor::Constants::M_STANDARDCONTEXTMENU);
|
||||||
if (editorContextMenu) {
|
if (editorContextMenu) {
|
||||||
@@ -271,6 +331,125 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
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()
|
void checkForUpdates()
|
||||||
{
|
{
|
||||||
connect(
|
connect(
|
||||||
@@ -297,11 +476,16 @@ private:
|
|||||||
RequestPerformanceLogger m_performanceLogger;
|
RequestPerformanceLogger m_performanceLogger;
|
||||||
QPointer<Chat::ChatOutputPane> m_chatOutputPane;
|
QPointer<Chat::ChatOutputPane> m_chatOutputPane;
|
||||||
QPointer<Chat::NavigationPanel> m_navigationPanel;
|
QPointer<Chat::NavigationPanel> m_navigationPanel;
|
||||||
|
QPointer<Chat::SessionFileRegistry> m_sessionFileRegistry;
|
||||||
|
Chat::ChatEditorFactory *m_chatEditorFactory{nullptr};
|
||||||
|
QPointer<QMenu> m_chatButtonMenu;
|
||||||
QPointer<PluginUpdater> m_updater;
|
QPointer<PluginUpdater> m_updater;
|
||||||
UpdateStatusWidget *m_statusWidget{nullptr};
|
UpdateStatusWidget *m_statusWidget{nullptr};
|
||||||
QString m_lastRefactorInstructions;
|
QString m_lastRefactorInstructions;
|
||||||
QScopedPointer<Chat::ChatView> m_chatView;
|
QScopedPointer<Chat::ChatView> m_chatView;
|
||||||
QPointer<Mcp::McpServerManager> m_mcpServerManager;
|
QPointer<Mcp::McpServerManager> m_mcpServerManager;
|
||||||
|
QPointer<QQmlEngine> m_engine;
|
||||||
|
QPointer<Skills::SkillsManager> m_skillsManager;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Internal
|
} // namespace QodeAssist::Internal
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ add_library(QodeAssistSettings STATIC
|
|||||||
ChatAssistantSettings.hpp ChatAssistantSettings.cpp
|
ChatAssistantSettings.hpp ChatAssistantSettings.cpp
|
||||||
QuickRefactorSettings.hpp QuickRefactorSettings.cpp
|
QuickRefactorSettings.hpp QuickRefactorSettings.cpp
|
||||||
ToolsSettings.hpp ToolsSettings.cpp
|
ToolsSettings.hpp ToolsSettings.cpp
|
||||||
|
SkillsSettings.hpp SkillsSettings.cpp
|
||||||
McpSettings.hpp McpSettings.cpp
|
McpSettings.hpp McpSettings.cpp
|
||||||
SettingsDialog.hpp SettingsDialog.cpp
|
SettingsDialog.hpp SettingsDialog.cpp
|
||||||
ProjectSettings.hpp ProjectSettings.cpp
|
ProjectSettings.hpp ProjectSettings.cpp
|
||||||
@@ -30,5 +31,6 @@ target_link_libraries(QodeAssistSettings
|
|||||||
QtCreator::Core
|
QtCreator::Core
|
||||||
QtCreator::Utils
|
QtCreator::Utils
|
||||||
QodeAssistLogger
|
QodeAssistLogger
|
||||||
|
Skills
|
||||||
)
|
)
|
||||||
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|||||||
@@ -29,14 +29,6 @@ ChatAssistantSettings::ChatAssistantSettings()
|
|||||||
|
|
||||||
setDisplayName(Tr::tr("Chat Assistant"));
|
setDisplayName(Tr::tr("Chat Assistant"));
|
||||||
|
|
||||||
// Chat Settings
|
|
||||||
chatTokensThreshold.setSettingsKey(Constants::CA_TOKENS_THRESHOLD);
|
|
||||||
chatTokensThreshold.setLabelText(Tr::tr("Chat history token limit:"));
|
|
||||||
chatTokensThreshold.setToolTip(Tr::tr("Maximum number of tokens in chat history. When "
|
|
||||||
"exceeded, oldest messages will be removed."));
|
|
||||||
chatTokensThreshold.setRange(1, 99999999);
|
|
||||||
chatTokensThreshold.setDefaultValue(20000);
|
|
||||||
|
|
||||||
linkOpenFiles.setSettingsKey(Constants::CA_LINK_OPEN_FILES);
|
linkOpenFiles.setSettingsKey(Constants::CA_LINK_OPEN_FILES);
|
||||||
linkOpenFiles.setLabelText(Tr::tr("Sync open files with assistant by default"));
|
linkOpenFiles.setLabelText(Tr::tr("Sync open files with assistant by default"));
|
||||||
linkOpenFiles.setDefaultValue(false);
|
linkOpenFiles.setDefaultValue(false);
|
||||||
@@ -58,6 +50,18 @@ ChatAssistantSettings::ChatAssistantSettings()
|
|||||||
enableChatTools.setToolTip(Tr::tr("When enabled, AI can use tools to read files, search project, and build code"));
|
enableChatTools.setToolTip(Tr::tr("When enabled, AI can use tools to read files, search project, and build code"));
|
||||||
enableChatTools.setDefaultValue(false);
|
enableChatTools.setDefaultValue(false);
|
||||||
|
|
||||||
|
autoCompress.setSettingsKey(Constants::CA_AUTO_COMPRESS);
|
||||||
|
autoCompress.setLabelText(Tr::tr("Auto-compress chat when session tokens exceed:"));
|
||||||
|
autoCompress.setToolTip(Tr::tr(
|
||||||
|
"After each assistant response, if the running session token total exceeds the "
|
||||||
|
"threshold, the chat is summarized and a new compressed chat is started "
|
||||||
|
"automatically. The original chat is preserved on disk."));
|
||||||
|
autoCompress.setDefaultValue(false);
|
||||||
|
|
||||||
|
autoCompressThreshold.setSettingsKey(Constants::CA_AUTO_COMPRESS_THRESHOLD);
|
||||||
|
autoCompressThreshold.setRange(1000, 99999999);
|
||||||
|
autoCompressThreshold.setDefaultValue(40000);
|
||||||
|
|
||||||
// General Parameters Settings
|
// General Parameters Settings
|
||||||
temperature.setSettingsKey(Constants::CA_TEMPERATURE);
|
temperature.setSettingsKey(Constants::CA_TEMPERATURE);
|
||||||
temperature.setLabelText(Tr::tr("Temperature:"));
|
temperature.setLabelText(Tr::tr("Temperature:"));
|
||||||
@@ -292,11 +296,9 @@ ChatAssistantSettings::ChatAssistantSettings()
|
|||||||
Group{
|
Group{
|
||||||
title(Tr::tr("Chat Settings")),
|
title(Tr::tr("Chat Settings")),
|
||||||
Column{
|
Column{
|
||||||
Row{chatTokensThreshold, Stretch{1}},
|
|
||||||
linkOpenFiles,
|
linkOpenFiles,
|
||||||
autosave,
|
autosave,
|
||||||
enableChatInBottomToolBar,
|
Row{autoCompress, autoCompressThreshold, Stretch{1}}}},
|
||||||
enableChatInNavigationPanel}},
|
|
||||||
Space{8},
|
Space{8},
|
||||||
Group{
|
Group{
|
||||||
title(Tr::tr("Tools")),
|
title(Tr::tr("Tools")),
|
||||||
@@ -348,7 +350,8 @@ void ChatAssistantSettings::resetSettingsToDefaults()
|
|||||||
QMessageBox::Yes | QMessageBox::No);
|
QMessageBox::Yes | QMessageBox::No);
|
||||||
|
|
||||||
if (reply == QMessageBox::Yes) {
|
if (reply == QMessageBox::Yes) {
|
||||||
resetAspect(chatTokensThreshold);
|
resetAspect(autoCompress);
|
||||||
|
resetAspect(autoCompressThreshold);
|
||||||
resetAspect(temperature);
|
resetAspect(temperature);
|
||||||
resetAspect(maxTokens);
|
resetAspect(maxTokens);
|
||||||
resetAspect(useTopP);
|
resetAspect(useTopP);
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ public:
|
|||||||
ButtonAspect resetToDefaults{this};
|
ButtonAspect resetToDefaults{this};
|
||||||
|
|
||||||
// Chat settings
|
// Chat settings
|
||||||
Utils::IntegerAspect chatTokensThreshold{this};
|
|
||||||
Utils::BoolAspect linkOpenFiles{this};
|
Utils::BoolAspect linkOpenFiles{this};
|
||||||
Utils::BoolAspect autosave{this};
|
Utils::BoolAspect autosave{this};
|
||||||
Utils::BoolAspect enableChatInBottomToolBar{this};
|
Utils::BoolAspect enableChatInBottomToolBar{this};
|
||||||
Utils::BoolAspect enableChatInNavigationPanel{this};
|
Utils::BoolAspect enableChatInNavigationPanel{this};
|
||||||
Utils::BoolAspect enableChatTools{this};
|
Utils::BoolAspect enableChatTools{this};
|
||||||
|
Utils::BoolAspect autoCompress{this};
|
||||||
|
Utils::IntegerAspect autoCompressThreshold{this};
|
||||||
|
|
||||||
// General Parameters Settings
|
// General Parameters Settings
|
||||||
Utils::DoubleAspect temperature{this};
|
Utils::DoubleAspect temperature{this};
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ QVector<AIConfiguration> ConfigurationManager::getPredefinedConfigurations(
|
|||||||
|
|
||||||
AIConfiguration claudeOpus;
|
AIConfiguration claudeOpus;
|
||||||
claudeOpus.id = "preset_claude_opus";
|
claudeOpus.id = "preset_claude_opus";
|
||||||
claudeOpus.name = "Claude Opus 4.6";
|
claudeOpus.name = "Claude Opus 4.7";
|
||||||
claudeOpus.provider = "Claude";
|
claudeOpus.provider = "Claude";
|
||||||
claudeOpus.model = "claude-opus-4-6";
|
claudeOpus.model = "claude-opus-4-7";
|
||||||
claudeOpus.url = "https://api.anthropic.com";
|
claudeOpus.url = "https://api.anthropic.com";
|
||||||
claudeOpus.customEndpoint = "";
|
claudeOpus.customEndpoint = "";
|
||||||
claudeOpus.templateName = "Claude";
|
claudeOpus.templateName = "Claude";
|
||||||
@@ -101,9 +101,9 @@ QVector<AIConfiguration> ConfigurationManager::getPredefinedConfigurations(
|
|||||||
|
|
||||||
AIConfiguration gpt;
|
AIConfiguration gpt;
|
||||||
gpt.id = "preset_gpt";
|
gpt.id = "preset_gpt";
|
||||||
gpt.name = "gpt-5.4";
|
gpt.name = "gpt-5.5";
|
||||||
gpt.provider = "OpenAI (Responses API)";
|
gpt.provider = "OpenAI (Responses API)";
|
||||||
gpt.model = "gpt-5.4";
|
gpt.model = "gpt-5.5";
|
||||||
gpt.url = "https://api.openai.com/v1";
|
gpt.url = "https://api.openai.com/v1";
|
||||||
gpt.customEndpoint = "";
|
gpt.customEndpoint = "";
|
||||||
gpt.templateName = "OpenAI Responses";
|
gpt.templateName = "OpenAI Responses";
|
||||||
|
|||||||
@@ -181,6 +181,14 @@ QList<ExamplePreset> buildExamplePresets()
|
|||||||
{"url", "http://127.0.0.1:3001/sse"},
|
{"url", "http://127.0.0.1:3001/sse"},
|
||||||
{"spec", "2024-11-05"}}});
|
{"spec", "2024-11-05"}}});
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{McpClientsListAspect::tr("qt-docs (Qt documentation)"),
|
||||||
|
QStringLiteral("qt-docs"),
|
||||||
|
QJsonObject{
|
||||||
|
{"enable", true},
|
||||||
|
{"type", "sse"},
|
||||||
|
{"url", "https://qt-docs-mcp.qt.io/mcp"}}});
|
||||||
|
|
||||||
out.append(
|
out.append(
|
||||||
{McpClientsListAspect::tr("remote (SSE / HTTP)"),
|
{McpClientsListAspect::tr("remote (SSE / HTTP)"),
|
||||||
QStringLiteral("remote"),
|
QStringLiteral("remote"),
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ ProjectSettings::ProjectSettings(ProjectExplorer::Project *project)
|
|||||||
|
|
||||||
chatHistoryPath.setDefaultValue(projectChatHistoryPath);
|
chatHistoryPath.setDefaultValue(projectChatHistoryPath);
|
||||||
|
|
||||||
|
projectSkillDirs.setSettingsKey(Constants::SK_PROJECT_SKILL_DIRS);
|
||||||
|
projectSkillDirs.setLabelText(Tr::tr("Skill directories:"));
|
||||||
|
projectSkillDirs.setDisplayStyle(Utils::StringAspect::TextEditDisplay);
|
||||||
|
projectSkillDirs.setToolTip(
|
||||||
|
Tr::tr("Project-relative subdirectories scanned for Agent Skills, one per line. "
|
||||||
|
"Resolved against the project root. These take priority over the global "
|
||||||
|
"skill directories when a skill name appears in both."));
|
||||||
|
projectSkillDirs.setDefaultValue(".qodeassist/skills\n.claude/skills");
|
||||||
|
|
||||||
Utils::Store map = Utils::storeFromVariant(
|
Utils::Store map = Utils::storeFromVariant(
|
||||||
project->namedSettings(Constants::QODE_ASSIST_PROJECT_SETTINGS_ID));
|
project->namedSettings(Constants::QODE_ASSIST_PROJECT_SETTINGS_ID));
|
||||||
fromMap(map);
|
fromMap(map);
|
||||||
@@ -39,6 +48,7 @@ ProjectSettings::ProjectSettings(ProjectExplorer::Project *project)
|
|||||||
enableQodeAssist.addOnChanged(this, [this, project] { save(project); });
|
enableQodeAssist.addOnChanged(this, [this, project] { save(project); });
|
||||||
useGlobalSettings.addOnChanged(this, [this, project] { save(project); });
|
useGlobalSettings.addOnChanged(this, [this, project] { save(project); });
|
||||||
chatHistoryPath.addOnChanged(this, [this, project] { save(project); });
|
chatHistoryPath.addOnChanged(this, [this, project] { save(project); });
|
||||||
|
projectSkillDirs.addOnChanged(this, [this, project] { save(project); });
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProjectSettings::setUseGlobalSettings(bool useGlobal)
|
void ProjectSettings::setUseGlobalSettings(bool useGlobal)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public:
|
|||||||
Utils::BoolAspect enableQodeAssist{this};
|
Utils::BoolAspect enableQodeAssist{this};
|
||||||
Utils::BoolAspect useGlobalSettings{this};
|
Utils::BoolAspect useGlobalSettings{this};
|
||||||
Utils::FilePathAspect chatHistoryPath{this};
|
Utils::FilePathAspect chatHistoryPath{this};
|
||||||
|
Utils::StringAspect projectSkillDirs{this};
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Settings
|
} // namespace QodeAssist::Settings
|
||||||
|
|||||||
@@ -8,9 +8,15 @@
|
|||||||
#include <projectexplorer/projectsettingswidget.h>
|
#include <projectexplorer/projectsettingswidget.h>
|
||||||
#include <utils/layoutbuilder.h>
|
#include <utils/layoutbuilder.h>
|
||||||
|
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QListWidget>
|
||||||
|
|
||||||
#include "ProjectSettings.hpp"
|
#include "ProjectSettings.hpp"
|
||||||
#include "SettingsConstants.hpp"
|
#include "SettingsConstants.hpp"
|
||||||
#include "SettingsTr.hpp"
|
#include "SettingsTr.hpp"
|
||||||
|
#include "SkillsSettings.hpp"
|
||||||
|
#include "sources/skills/SkillsLoader.hpp"
|
||||||
|
#include "sources/skills/SkillsManager.hpp"
|
||||||
|
|
||||||
using namespace ProjectExplorer;
|
using namespace ProjectExplorer;
|
||||||
|
|
||||||
@@ -41,17 +47,59 @@ static ProjectSettingsWidget *createProjectPanel(Project *project)
|
|||||||
&ProjectSettings::setUseGlobalSettings);
|
&ProjectSettings::setUseGlobalSettings);
|
||||||
|
|
||||||
widget->setUseGlobalSettings(settings->useGlobalSettings());
|
widget->setUseGlobalSettings(settings->useGlobalSettings());
|
||||||
widget->setEnabled(!settings->useGlobalSettings());
|
|
||||||
|
|
||||||
QObject::connect(
|
|
||||||
widget, &ProjectSettingsWidget::useGlobalSettingsChanged, widget, [widget](bool useGlobal) {
|
|
||||||
widget->setEnabled(!useGlobal);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
auto generalWidget = new QWidget;
|
||||||
Column{
|
Column{
|
||||||
settings->enableQodeAssist,
|
settings->enableQodeAssist,
|
||||||
Space{8},
|
Space{8},
|
||||||
settings->chatHistoryPath,
|
settings->chatHistoryPath,
|
||||||
|
}
|
||||||
|
.attachTo(generalWidget);
|
||||||
|
|
||||||
|
generalWidget->setEnabled(!settings->useGlobalSettings());
|
||||||
|
QObject::connect(
|
||||||
|
widget,
|
||||||
|
&ProjectSettingsWidget::useGlobalSettingsChanged,
|
||||||
|
generalWidget,
|
||||||
|
[generalWidget](bool useGlobal) { generalWidget->setEnabled(!useGlobal); });
|
||||||
|
|
||||||
|
auto skillsList = new QListWidget;
|
||||||
|
skillsList->setSelectionMode(QAbstractItemView::NoSelection);
|
||||||
|
skillsList->setMaximumHeight(160);
|
||||||
|
|
||||||
|
auto refreshSkills = [skillsList, project, settings] {
|
||||||
|
skillsList->clear();
|
||||||
|
|
||||||
|
// Project-only roots: the global page shows global skills separately.
|
||||||
|
const QStringList roots = Skills::SkillsManager::resolveRoots(
|
||||||
|
project->projectDirectory().toFSPathString(),
|
||||||
|
{},
|
||||||
|
SkillsSettings::splitLines(settings->projectSkillDirs()));
|
||||||
|
|
||||||
|
const QVector<Skills::AgentSkill> skills = Skills::SkillsLoader::scan(roots);
|
||||||
|
for (const Skills::AgentSkill &skill : skills) {
|
||||||
|
auto *item = new QListWidgetItem(
|
||||||
|
QStringLiteral("%1 — %2").arg(skill.name, skill.description), skillsList);
|
||||||
|
item->setToolTip(skill.skillDir);
|
||||||
|
}
|
||||||
|
if (skills.isEmpty())
|
||||||
|
new QListWidgetItem(Tr::tr("No skills discovered."), skillsList);
|
||||||
|
};
|
||||||
|
refreshSkills();
|
||||||
|
QObject::connect(
|
||||||
|
&settings->projectSkillDirs, &Utils::BaseAspect::changed, skillsList, refreshSkills);
|
||||||
|
|
||||||
|
Column{
|
||||||
|
generalWidget,
|
||||||
|
Space{8},
|
||||||
|
Group{
|
||||||
|
title(Tr::tr("Skills")),
|
||||||
|
Column{
|
||||||
|
settings->projectSkillDirs,
|
||||||
|
new QLabel(Tr::tr("Discovered project skills:")),
|
||||||
|
skillsList,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
.attachTo(widget);
|
.attachTo(widget);
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ ProviderSettings::ProviderSettings()
|
|||||||
claudeApiKey.setDefaultValue("");
|
claudeApiKey.setDefaultValue("");
|
||||||
claudeApiKey.setAutoApply(true);
|
claudeApiKey.setAutoApply(true);
|
||||||
|
|
||||||
|
claudeEnablePromptCaching.setSettingsKey(Constants::CLAUDE_ENABLE_PROMPT_CACHING);
|
||||||
|
claudeEnablePromptCaching.setLabelText(Tr::tr("Enable prompt caching"));
|
||||||
|
claudeEnablePromptCaching.setToolTip(
|
||||||
|
Tr::tr("Marks the system prompt, tool definitions, and stable chat history with "
|
||||||
|
"cache_control so Anthropic caches the request prefix (5-minute TTL). "
|
||||||
|
"Reduces cost and latency on repeated turns."));
|
||||||
|
claudeEnablePromptCaching.setDefaultValue(false);
|
||||||
|
claudeEnablePromptCaching.setAutoApply(true);
|
||||||
|
|
||||||
|
claudeUseExtendedCacheTTL.setSettingsKey(Constants::CLAUDE_USE_EXTENDED_CACHE_TTL);
|
||||||
|
claudeUseExtendedCacheTTL.setLabelText(Tr::tr("Use 1h cache TTL (beta)"));
|
||||||
|
claudeUseExtendedCacheTTL.setToolTip(
|
||||||
|
Tr::tr("Requests Anthropic's 1-hour cache TTL instead of the default 5 minutes. "
|
||||||
|
"Sends the extended-cache-ttl-2025-04-11 beta header."));
|
||||||
|
claudeUseExtendedCacheTTL.setDefaultValue(false);
|
||||||
|
claudeUseExtendedCacheTTL.setAutoApply(true);
|
||||||
|
|
||||||
// OpenAI Settings
|
// OpenAI Settings
|
||||||
openAiApiKey.setSettingsKey(Constants::OPEN_AI_API_KEY);
|
openAiApiKey.setSettingsKey(Constants::OPEN_AI_API_KEY);
|
||||||
openAiApiKey.setLabelText(Tr::tr("OpenAI API Key:"));
|
openAiApiKey.setLabelText(Tr::tr("OpenAI API Key:"));
|
||||||
@@ -124,7 +141,9 @@ ProviderSettings::ProviderSettings()
|
|||||||
Space{8},
|
Space{8},
|
||||||
Group{title(Tr::tr("OpenAI Compatible Settings")), Column{openAiCompatApiKey}},
|
Group{title(Tr::tr("OpenAI Compatible Settings")), Column{openAiCompatApiKey}},
|
||||||
Space{8},
|
Space{8},
|
||||||
Group{title(Tr::tr("Claude Settings")), Column{claudeApiKey}},
|
Group{
|
||||||
|
title(Tr::tr("Claude Settings")),
|
||||||
|
Column{claudeApiKey, claudeEnablePromptCaching, claudeUseExtendedCacheTTL}},
|
||||||
Space{8},
|
Space{8},
|
||||||
Group{title(Tr::tr("Mistral AI Settings")), Column{mistralAiApiKey, codestralApiKey}},
|
Group{title(Tr::tr("Mistral AI Settings")), Column{mistralAiApiKey, codestralApiKey}},
|
||||||
Space{8},
|
Space{8},
|
||||||
@@ -148,6 +167,12 @@ void ProviderSettings::setupConnections()
|
|||||||
openAiCompatApiKey.writeSettings();
|
openAiCompatApiKey.writeSettings();
|
||||||
});
|
});
|
||||||
connect(&claudeApiKey, &ButtonAspect::changed, this, [this]() { claudeApiKey.writeSettings(); });
|
connect(&claudeApiKey, &ButtonAspect::changed, this, [this]() { claudeApiKey.writeSettings(); });
|
||||||
|
connect(&claudeEnablePromptCaching, &Utils::BoolAspect::changed, this, [this]() {
|
||||||
|
claudeEnablePromptCaching.writeSettings();
|
||||||
|
});
|
||||||
|
connect(&claudeUseExtendedCacheTTL, &Utils::BoolAspect::changed, this, [this]() {
|
||||||
|
claudeUseExtendedCacheTTL.writeSettings();
|
||||||
|
});
|
||||||
connect(&openAiApiKey, &ButtonAspect::changed, this, [this]() { openAiApiKey.writeSettings(); });
|
connect(&openAiApiKey, &ButtonAspect::changed, this, [this]() { openAiApiKey.writeSettings(); });
|
||||||
connect(&mistralAiApiKey, &ButtonAspect::changed, this, [this]() {
|
connect(&mistralAiApiKey, &ButtonAspect::changed, this, [this]() {
|
||||||
mistralAiApiKey.writeSettings();
|
mistralAiApiKey.writeSettings();
|
||||||
@@ -179,6 +204,8 @@ void ProviderSettings::resetSettingsToDefaults()
|
|||||||
resetAspect(openRouterApiKey);
|
resetAspect(openRouterApiKey);
|
||||||
resetAspect(openAiCompatApiKey);
|
resetAspect(openAiCompatApiKey);
|
||||||
resetAspect(claudeApiKey);
|
resetAspect(claudeApiKey);
|
||||||
|
resetAspect(claudeEnablePromptCaching);
|
||||||
|
resetAspect(claudeUseExtendedCacheTTL);
|
||||||
resetAspect(openAiApiKey);
|
resetAspect(openAiApiKey);
|
||||||
resetAspect(mistralAiApiKey);
|
resetAspect(mistralAiApiKey);
|
||||||
resetAspect(googleAiApiKey);
|
resetAspect(googleAiApiKey);
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ public:
|
|||||||
Utils::StringAspect openRouterApiKey{this};
|
Utils::StringAspect openRouterApiKey{this};
|
||||||
Utils::StringAspect openAiCompatApiKey{this};
|
Utils::StringAspect openAiCompatApiKey{this};
|
||||||
Utils::StringAspect claudeApiKey{this};
|
Utils::StringAspect claudeApiKey{this};
|
||||||
|
Utils::BoolAspect claudeEnablePromptCaching{this};
|
||||||
|
Utils::BoolAspect claudeUseExtendedCacheTTL{this};
|
||||||
Utils::StringAspect openAiApiKey{this};
|
Utils::StringAspect openAiApiKey{this};
|
||||||
Utils::StringAspect mistralAiApiKey{this};
|
Utils::StringAspect mistralAiApiKey{this};
|
||||||
Utils::StringAspect codestralApiKey{this};
|
Utils::StringAspect codestralApiKey{this};
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ const char MAX_FILE_THRESHOLD[] = "QodeAssist.maxFileThreshold";
|
|||||||
const char CC_MULTILINE_COMPLETION[] = "QodeAssist.ccMultilineCompletion";
|
const char CC_MULTILINE_COMPLETION[] = "QodeAssist.ccMultilineCompletion";
|
||||||
const char CC_MODEL_OUTPUT_HANDLER[] = "QodeAssist.ccModelOutputHandler";
|
const char CC_MODEL_OUTPUT_HANDLER[] = "QodeAssist.ccModelOutputHandler";
|
||||||
const char CA_AUTO_APPLY_FILE_EDITS[] = "QodeAssist.caAutoApplyFileEdits";
|
const char CA_AUTO_APPLY_FILE_EDITS[] = "QodeAssist.caAutoApplyFileEdits";
|
||||||
const char CA_TOKENS_THRESHOLD[] = "QodeAssist.caTokensThreshold";
|
const char CA_AUTO_COMPRESS[] = "QodeAssist.caAutoCompress";
|
||||||
|
const char CA_AUTO_COMPRESS_THRESHOLD[] = "QodeAssist.caAutoCompressThreshold";
|
||||||
const char CA_LINK_OPEN_FILES[] = "QodeAssist.caLinkOpenFiles";
|
const char CA_LINK_OPEN_FILES[] = "QodeAssist.caLinkOpenFiles";
|
||||||
const char CA_AUTOSAVE[] = "QodeAssist.caAutosave";
|
const char CA_AUTOSAVE[] = "QodeAssist.caAutosave";
|
||||||
const char CC_CUSTOM_LANGUAGES[] = "QodeAssist.ccCustomLanguages";
|
const char CC_CUSTOM_LANGUAGES[] = "QodeAssist.ccCustomLanguages";
|
||||||
@@ -99,12 +100,20 @@ const char CA_ENABLE_EDIT_FILE_TOOL[] = "QodeAssist.caEnableEditFileToolV2";
|
|||||||
const char CA_ENABLE_BUILD_PROJECT_TOOL[] = "QodeAssist.caEnableBuildProjectToolV2";
|
const char CA_ENABLE_BUILD_PROJECT_TOOL[] = "QodeAssist.caEnableBuildProjectToolV2";
|
||||||
const char CA_ENABLE_TERMINAL_COMMAND_TOOL[] = "QodeAssist.caEnableTerminalCommandToolV2";
|
const char CA_ENABLE_TERMINAL_COMMAND_TOOL[] = "QodeAssist.caEnableTerminalCommandToolV2";
|
||||||
const char CA_ENABLE_TODO_TOOL[] = "QodeAssist.caEnableTodoToolV2";
|
const char CA_ENABLE_TODO_TOOL[] = "QodeAssist.caEnableTodoToolV2";
|
||||||
|
const char CA_ENABLE_READ_ORIGINAL_HISTORY_TOOL[]
|
||||||
|
= "QodeAssist.caEnableReadOriginalHistoryTool";
|
||||||
|
const char CA_ENABLE_SKILL_TOOL[] = "QodeAssist.caEnableSkillTool";
|
||||||
const char CA_ALLOWED_TERMINAL_COMMANDS[] = "QodeAssist.caAllowedTerminalCommands";
|
const char CA_ALLOWED_TERMINAL_COMMANDS[] = "QodeAssist.caAllowedTerminalCommands";
|
||||||
const char CA_ALLOWED_TERMINAL_COMMANDS_LINUX[] = "QodeAssist.caAllowedTerminalCommandsLinux";
|
const char CA_ALLOWED_TERMINAL_COMMANDS_LINUX[] = "QodeAssist.caAllowedTerminalCommandsLinux";
|
||||||
const char CA_ALLOWED_TERMINAL_COMMANDS_MACOS[] = "QodeAssist.caAllowedTerminalCommandsMacOS";
|
const char CA_ALLOWED_TERMINAL_COMMANDS_MACOS[] = "QodeAssist.caAllowedTerminalCommandsMacOS";
|
||||||
const char CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS[] = "QodeAssist.caAllowedTerminalCommandsWindows";
|
const char CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS[] = "QodeAssist.caAllowedTerminalCommandsWindows";
|
||||||
const char CA_TERMINAL_COMMAND_TIMEOUT[] = "QodeAssist.caTerminalCommandTimeout";
|
const char CA_TERMINAL_COMMAND_TIMEOUT[] = "QodeAssist.caTerminalCommandTimeout";
|
||||||
|
|
||||||
|
// Skills settings
|
||||||
|
const char SK_ENABLE_SKILLS[] = "QodeAssist.skEnableSkills";
|
||||||
|
const char SK_GLOBAL_SKILL_ROOTS[] = "QodeAssist.skGlobalSkillRoots";
|
||||||
|
const char SK_PROJECT_SKILL_DIRS[] = "QodeAssist.skProjectSkillDirs";
|
||||||
|
|
||||||
// MCP server settings
|
// MCP server settings
|
||||||
const char MCP_ENABLE_SERVER[] = "QodeAssist.mcpEnableServer";
|
const char MCP_ENABLE_SERVER[] = "QodeAssist.mcpEnableServer";
|
||||||
const char MCP_SERVER_PORT[] = "QodeAssist.mcpServerPort";
|
const char MCP_SERVER_PORT[] = "QodeAssist.mcpServerPort";
|
||||||
@@ -121,6 +130,7 @@ const char QODE_ASSIST_QUICK_REFACTOR_SETTINGS_PAGE_ID[]
|
|||||||
= "QodeAssist.4QuickRefactorSettingsPageId";
|
= "QodeAssist.4QuickRefactorSettingsPageId";
|
||||||
const char QODE_ASSIST_TOOLS_SETTINGS_PAGE_ID[] = "QodeAssist.5ToolsSettingsPageId";
|
const char QODE_ASSIST_TOOLS_SETTINGS_PAGE_ID[] = "QodeAssist.5ToolsSettingsPageId";
|
||||||
const char QODE_ASSIST_MCP_SETTINGS_PAGE_ID[] = "QodeAssist.6McpSettingsPageId";
|
const char QODE_ASSIST_MCP_SETTINGS_PAGE_ID[] = "QodeAssist.6McpSettingsPageId";
|
||||||
|
const char QODE_ASSIST_SKILLS_SETTINGS_PAGE_ID[] = "QodeAssist.8SkillsSettingsPageId";
|
||||||
|
|
||||||
const char QODE_ASSIST_GENERAL_OPTIONS_CATEGORY[] = "QodeAssist.Category";
|
const char QODE_ASSIST_GENERAL_OPTIONS_CATEGORY[] = "QodeAssist.Category";
|
||||||
const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist";
|
const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist";
|
||||||
@@ -148,6 +158,9 @@ const char OLLAMA_BASIC_AUTH_API_KEY_HISTORY[] = "QodeAssist.ollamaBasicAuthApiK
|
|||||||
const char LLAMA_CPP_API_KEY[] = "QodeAssist.llamaCppApiKey";
|
const char LLAMA_CPP_API_KEY[] = "QodeAssist.llamaCppApiKey";
|
||||||
const char LLAMA_CPP_API_KEY_HISTORY[] = "QodeAssist.llamaCppApiKeyHistory";
|
const char LLAMA_CPP_API_KEY_HISTORY[] = "QodeAssist.llamaCppApiKeyHistory";
|
||||||
|
|
||||||
|
const char CLAUDE_ENABLE_PROMPT_CACHING[] = "QodeAssist.claudeEnablePromptCaching";
|
||||||
|
const char CLAUDE_USE_EXTENDED_CACHE_TTL[] = "QodeAssist.claudeUseExtendedCacheTTL";
|
||||||
|
|
||||||
// context settings
|
// context settings
|
||||||
const char CC_READ_FULL_FILE[] = "QodeAssist.ccReadFullFile";
|
const char CC_READ_FULL_FILE[] = "QodeAssist.ccReadFullFile";
|
||||||
const char CC_READ_STRINGS_BEFORE_CURSOR[] = "QodeAssist.ccReadStringsBeforeCursor";
|
const char CC_READ_STRINGS_BEFORE_CURSOR[] = "QodeAssist.ccReadStringsBeforeCursor";
|
||||||
|
|||||||
135
settings/SkillsSettings.cpp
Normal file
135
settings/SkillsSettings.cpp
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Copyright (C) 2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "SkillsSettings.hpp"
|
||||||
|
|
||||||
|
#include <coreplugin/dialogs/ioptionspage.h>
|
||||||
|
#include <coreplugin/icore.h>
|
||||||
|
#include <utils/layoutbuilder.h>
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QListWidget>
|
||||||
|
|
||||||
|
#include "SettingsConstants.hpp"
|
||||||
|
#include "SettingsTr.hpp"
|
||||||
|
#include "sources/skills/SkillsLoader.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
SkillsSettings &skillsSettings()
|
||||||
|
{
|
||||||
|
static SkillsSettings settings;
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList SkillsSettings::splitLines(const QString &value)
|
||||||
|
{
|
||||||
|
QStringList lines;
|
||||||
|
const auto parts = value.split('\n', Qt::SkipEmptyParts);
|
||||||
|
for (const QString &part : parts) {
|
||||||
|
const QString trimmed = part.trimmed();
|
||||||
|
if (!trimmed.isEmpty())
|
||||||
|
lines << trimmed;
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList SkillsSettings::splitPaths(const QString &value)
|
||||||
|
{
|
||||||
|
QStringList paths;
|
||||||
|
for (QString path : splitLines(value)) {
|
||||||
|
if (path == QLatin1String("~"))
|
||||||
|
path = QDir::homePath();
|
||||||
|
else if (path.startsWith(QLatin1String("~/")))
|
||||||
|
path = QDir::homePath() + path.mid(1);
|
||||||
|
paths << QDir::cleanPath(path);
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
SkillsSettings::SkillsSettings()
|
||||||
|
{
|
||||||
|
setAutoApply(false);
|
||||||
|
|
||||||
|
setDisplayName(Tr::tr("Skills"));
|
||||||
|
|
||||||
|
enableSkills.setSettingsKey(Constants::SK_ENABLE_SKILLS);
|
||||||
|
enableSkills.setLabelText(Tr::tr("Enable skills"));
|
||||||
|
enableSkills.setToolTip(
|
||||||
|
Tr::tr("Discover Agent Skills from the configured skill directories and expose them "
|
||||||
|
"to the chat assistant. Each skill is a folder containing a SKILL.md file."));
|
||||||
|
enableSkills.setDefaultValue(true);
|
||||||
|
|
||||||
|
const QString defaultGlobalRoots
|
||||||
|
= Core::ICore::userResourcePath().toFSPathString() + "/qodeassist/skills\n"
|
||||||
|
+ QDir::homePath() + "/.claude/skills";
|
||||||
|
|
||||||
|
globalSkillRoots.setSettingsKey(Constants::SK_GLOBAL_SKILL_ROOTS);
|
||||||
|
globalSkillRoots.setLabelText(Tr::tr("Global skill directories:"));
|
||||||
|
globalSkillRoots.setDisplayStyle(Utils::StringAspect::TextEditDisplay);
|
||||||
|
globalSkillRoots.setToolTip(
|
||||||
|
Tr::tr("Absolute paths scanned for skills, one per line. Each path is a directory "
|
||||||
|
"whose subfolders contain SKILL.md files. A leading ~ expands to your home "
|
||||||
|
"directory. Lets QodeAssist pick up skills shared with other agents "
|
||||||
|
"(e.g. ~/.claude/skills)."));
|
||||||
|
globalSkillRoots.setDefaultValue(defaultGlobalRoots);
|
||||||
|
|
||||||
|
readSettings();
|
||||||
|
|
||||||
|
setLayouter([this]() {
|
||||||
|
using namespace Layouting;
|
||||||
|
|
||||||
|
auto skillsList = new QListWidget;
|
||||||
|
skillsList->setSelectionMode(QAbstractItemView::NoSelection);
|
||||||
|
skillsList->setMaximumHeight(160);
|
||||||
|
|
||||||
|
auto refreshSkills = [skillsList, this] {
|
||||||
|
skillsList->clear();
|
||||||
|
const QVector<Skills::AgentSkill> skills
|
||||||
|
= Skills::SkillsLoader::scan(splitPaths(globalSkillRoots()));
|
||||||
|
for (const Skills::AgentSkill &skill : skills) {
|
||||||
|
auto *item = new QListWidgetItem(
|
||||||
|
QStringLiteral("%1 — %2").arg(skill.name, skill.description), skillsList);
|
||||||
|
item->setToolTip(skill.skillDir);
|
||||||
|
}
|
||||||
|
if (skills.isEmpty())
|
||||||
|
new QListWidgetItem(Tr::tr("No skills discovered."), skillsList);
|
||||||
|
};
|
||||||
|
refreshSkills();
|
||||||
|
connect(&globalSkillRoots, &Utils::BaseAspect::changed, skillsList, refreshSkills);
|
||||||
|
|
||||||
|
return Column{
|
||||||
|
Group{
|
||||||
|
title(Tr::tr("Skills")),
|
||||||
|
Column{
|
||||||
|
Row{enableSkills, Stretch{1}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Group{
|
||||||
|
title(Tr::tr("Skill Directories")),
|
||||||
|
Column{
|
||||||
|
globalSkillRoots,
|
||||||
|
new QLabel(Tr::tr("Discovered global skills:")),
|
||||||
|
skillsList,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Stretch{1}};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SkillsSettingsPage : public Core::IOptionsPage
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
SkillsSettingsPage()
|
||||||
|
{
|
||||||
|
setId(Constants::QODE_ASSIST_SKILLS_SETTINGS_PAGE_ID);
|
||||||
|
setDisplayName(Tr::tr("Skills"));
|
||||||
|
setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY);
|
||||||
|
setSettingsProvider([] { return &skillsSettings(); });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SkillsSettingsPage skillsSettingsPage;
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
25
settings/SkillsSettings.hpp
Normal file
25
settings/SkillsSettings.hpp
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright (C) 2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <utils/aspects.h>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class SkillsSettings : public Utils::AspectContainer
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
SkillsSettings();
|
||||||
|
|
||||||
|
Utils::BoolAspect enableSkills{this};
|
||||||
|
|
||||||
|
Utils::StringAspect globalSkillRoots{this};
|
||||||
|
|
||||||
|
static QStringList splitLines(const QString &value);
|
||||||
|
static QStringList splitPaths(const QString &value);
|
||||||
|
};
|
||||||
|
|
||||||
|
SkillsSettings &skillsSettings();
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
@@ -111,13 +111,31 @@ ToolsSettings::ToolsSettings()
|
|||||||
Tr::tr("Lets the AI maintain a session-scoped todo list for multi-step workflows."));
|
Tr::tr("Lets the AI maintain a session-scoped todo list for multi-step workflows."));
|
||||||
enableTodoTool.setDefaultValue(true);
|
enableTodoTool.setDefaultValue(true);
|
||||||
|
|
||||||
|
enableReadOriginalHistoryTool.setSettingsKey(Constants::CA_ENABLE_READ_ORIGINAL_HISTORY_TOOL);
|
||||||
|
enableReadOriginalHistoryTool.setLabelText(Tr::tr("Read Original History (Pre-Compression)"));
|
||||||
|
enableReadOriginalHistoryTool.setToolTip(
|
||||||
|
Tr::tr("Lets the AI read the original, full chat history from before the conversation "
|
||||||
|
"was compressed into a summary. Useful when a detail is missing from the "
|
||||||
|
"summary currently in context. Has no effect if the chat was never compressed."));
|
||||||
|
enableReadOriginalHistoryTool.setDefaultValue(true);
|
||||||
|
|
||||||
|
enableSkillTool.setSettingsKey(Constants::CA_ENABLE_SKILL_TOOL);
|
||||||
|
enableSkillTool.setLabelText(Tr::tr("Load Skill"));
|
||||||
|
enableSkillTool.setToolTip(
|
||||||
|
Tr::tr("Lets the AI load the full instructions of a skill on demand. The Available "
|
||||||
|
"Skills catalog in the system prompt lists each skill; this tool pulls a "
|
||||||
|
"skill's complete instructions into context when needed."));
|
||||||
|
enableSkillTool.setDefaultValue(true);
|
||||||
|
|
||||||
allowedTerminalCommandsLinux.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_LINUX);
|
allowedTerminalCommandsLinux.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_LINUX);
|
||||||
allowedTerminalCommandsLinux.setLabelText(Tr::tr("Allowed Commands (Linux)"));
|
allowedTerminalCommandsLinux.setLabelText(Tr::tr("Allowed Commands (Linux)"));
|
||||||
allowedTerminalCommandsLinux.setToolTip(
|
allowedTerminalCommandsLinux.setToolTip(
|
||||||
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Linux. "
|
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Linux. "
|
||||||
"Example: git, ls, cat, grep, find, cmake"));
|
"Example: git, ls, cat, grep, find, cmake"));
|
||||||
allowedTerminalCommandsLinux.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
|
allowedTerminalCommandsLinux.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
|
||||||
allowedTerminalCommandsLinux.setDefaultValue("git, ls, cat, grep, find");
|
allowedTerminalCommandsLinux.setDefaultValue(
|
||||||
|
"git, ls, cat, grep, find, pwd, echo, head, tail, wc, which, file, stat, tree, uname, "
|
||||||
|
"date, whoami, hostname");
|
||||||
|
|
||||||
allowedTerminalCommandsMacOS.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_MACOS);
|
allowedTerminalCommandsMacOS.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_MACOS);
|
||||||
allowedTerminalCommandsMacOS.setLabelText(Tr::tr("Allowed Commands (macOS)"));
|
allowedTerminalCommandsMacOS.setLabelText(Tr::tr("Allowed Commands (macOS)"));
|
||||||
@@ -125,7 +143,9 @@ ToolsSettings::ToolsSettings()
|
|||||||
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on macOS. "
|
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on macOS. "
|
||||||
"Example: git, ls, cat, grep, find, cmake"));
|
"Example: git, ls, cat, grep, find, cmake"));
|
||||||
allowedTerminalCommandsMacOS.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
|
allowedTerminalCommandsMacOS.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
|
||||||
allowedTerminalCommandsMacOS.setDefaultValue("git, ls, cat, grep, find");
|
allowedTerminalCommandsMacOS.setDefaultValue(
|
||||||
|
"git, ls, cat, grep, find, pwd, echo, head, tail, wc, which, file, stat, tree, uname, "
|
||||||
|
"date, whoami, hostname");
|
||||||
|
|
||||||
allowedTerminalCommandsWindows.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS);
|
allowedTerminalCommandsWindows.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS);
|
||||||
allowedTerminalCommandsWindows.setLabelText(Tr::tr("Allowed Commands (Windows)"));
|
allowedTerminalCommandsWindows.setLabelText(Tr::tr("Allowed Commands (Windows)"));
|
||||||
@@ -133,7 +153,8 @@ ToolsSettings::ToolsSettings()
|
|||||||
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Windows. "
|
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Windows. "
|
||||||
"Example: git, dir, type, findstr, where, cmake"));
|
"Example: git, dir, type, findstr, where, cmake"));
|
||||||
allowedTerminalCommandsWindows.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
|
allowedTerminalCommandsWindows.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
|
||||||
allowedTerminalCommandsWindows.setDefaultValue("git, dir, type, findstr, where");
|
allowedTerminalCommandsWindows.setDefaultValue(
|
||||||
|
"git, dir, type, findstr, where, echo, whoami, hostname, ver, tree, fc");
|
||||||
|
|
||||||
terminalCommandTimeout.setSettingsKey(Constants::CA_TERMINAL_COMMAND_TIMEOUT);
|
terminalCommandTimeout.setSettingsKey(Constants::CA_TERMINAL_COMMAND_TIMEOUT);
|
||||||
terminalCommandTimeout.setLabelText(Tr::tr("Command Timeout (seconds)"));
|
terminalCommandTimeout.setLabelText(Tr::tr("Command Timeout (seconds)"));
|
||||||
@@ -177,7 +198,9 @@ ToolsSettings::ToolsSettings()
|
|||||||
enableBuildProjectTool,
|
enableBuildProjectTool,
|
||||||
enableGetIssuesListTool,
|
enableGetIssuesListTool,
|
||||||
enableTerminalCommandTool,
|
enableTerminalCommandTool,
|
||||||
enableTodoTool}},
|
enableTodoTool,
|
||||||
|
enableReadOriginalHistoryTool,
|
||||||
|
enableSkillTool}},
|
||||||
Space{8},
|
Space{8},
|
||||||
Group{
|
Group{
|
||||||
title(Tr::tr("Tool Settings")),
|
title(Tr::tr("Tool Settings")),
|
||||||
@@ -227,6 +250,8 @@ void ToolsSettings::resetSettingsToDefaults()
|
|||||||
resetAspect(enableGetIssuesListTool);
|
resetAspect(enableGetIssuesListTool);
|
||||||
resetAspect(enableTerminalCommandTool);
|
resetAspect(enableTerminalCommandTool);
|
||||||
resetAspect(enableTodoTool);
|
resetAspect(enableTodoTool);
|
||||||
|
resetAspect(enableReadOriginalHistoryTool);
|
||||||
|
resetAspect(enableSkillTool);
|
||||||
resetAspect(allowedTerminalCommandsLinux);
|
resetAspect(allowedTerminalCommandsLinux);
|
||||||
resetAspect(allowedTerminalCommandsMacOS);
|
resetAspect(allowedTerminalCommandsMacOS);
|
||||||
resetAspect(allowedTerminalCommandsWindows);
|
resetAspect(allowedTerminalCommandsWindows);
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public:
|
|||||||
Utils::BoolAspect enableGetIssuesListTool{this};
|
Utils::BoolAspect enableGetIssuesListTool{this};
|
||||||
Utils::BoolAspect enableTerminalCommandTool{this};
|
Utils::BoolAspect enableTerminalCommandTool{this};
|
||||||
Utils::BoolAspect enableTodoTool{this};
|
Utils::BoolAspect enableTodoTool{this};
|
||||||
|
Utils::BoolAspect enableReadOriginalHistoryTool{this};
|
||||||
|
Utils::BoolAspect enableSkillTool{this};
|
||||||
|
|
||||||
Utils::StringAspect allowedTerminalCommandsLinux{this};
|
Utils::StringAspect allowedTerminalCommandsLinux{this};
|
||||||
Utils::StringAspect allowedTerminalCommandsMacOS{this};
|
Utils::StringAspect allowedTerminalCommandsMacOS{this};
|
||||||
|
|||||||
2
sources/external/llmqore
vendored
2
sources/external/llmqore
vendored
Submodule sources/external/llmqore updated: 82067dc46a...95a7b3ac51
29
sources/skills/AgentSkill.hpp
Normal file
29
sources/skills/AgentSkill.hpp
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright (C) 2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
|
||||||
|
struct AgentSkill
|
||||||
|
{
|
||||||
|
QString name;
|
||||||
|
QString description;
|
||||||
|
QString body; // Markdown body after the frontmatter
|
||||||
|
QString skillDir; // absolute path to the skill folder
|
||||||
|
QString rootPath; // the scan root this skill was found in
|
||||||
|
QString license;
|
||||||
|
QString compatibility;
|
||||||
|
QStringList allowedTools;
|
||||||
|
QHash<QString, QString> metadata;
|
||||||
|
bool enabled = true;
|
||||||
|
bool alwaysOn = false;
|
||||||
|
|
||||||
|
bool isValid() const { return !name.isEmpty(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Skills
|
||||||
12
sources/skills/CMakeLists.txt
Normal file
12
sources/skills/CMakeLists.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
add_library(Skills STATIC
|
||||||
|
AgentSkill.hpp
|
||||||
|
SkillsLoader.hpp SkillsLoader.cpp
|
||||||
|
SkillsManager.hpp SkillsManager.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(Skills
|
||||||
|
PUBLIC
|
||||||
|
Qt::Core
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(Skills PUBLIC ${CMAKE_SOURCE_DIR})
|
||||||
269
sources/skills/SkillsLoader.cpp
Normal file
269
sources/skills/SkillsLoader.cpp
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
// Copyright (C) 2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "SkillsLoader.hpp"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QSet>
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString unquote(QString value)
|
||||||
|
{
|
||||||
|
value = value.trimmed();
|
||||||
|
if (value.size() >= 2
|
||||||
|
&& ((value.startsWith('"') && value.endsWith('"'))
|
||||||
|
|| (value.startsWith('\'') && value.endsWith('\'')))) {
|
||||||
|
value = value.mid(1, value.size() - 2);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
int indentOf(const QString &line)
|
||||||
|
{
|
||||||
|
int i = 0;
|
||||||
|
while (i < line.size() && line[i] == ' ')
|
||||||
|
++i;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int SkillsLoader::maxBodyChars()
|
||||||
|
{
|
||||||
|
return 64 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SkillsLoader::parseFrontmatter(
|
||||||
|
const QString &rawText, AgentSkill &skill, QString &body, QString &error)
|
||||||
|
{
|
||||||
|
// Normalize line endings so CRLF/CR files parse identically to LF.
|
||||||
|
QString text = rawText;
|
||||||
|
text.replace(QLatin1String("\r\n"), QLatin1String("\n"));
|
||||||
|
text.replace('\r', '\n');
|
||||||
|
|
||||||
|
const QStringList lines = text.split('\n');
|
||||||
|
if (lines.isEmpty() || lines.first().trimmed() != QLatin1String("---")) {
|
||||||
|
error = QStringLiteral("missing YAML frontmatter");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int closing = -1;
|
||||||
|
for (int i = 1; i < lines.size(); ++i) {
|
||||||
|
if (lines[i].trimmed() == QLatin1String("---")) {
|
||||||
|
closing = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (closing < 0) {
|
||||||
|
error = QStringLiteral("unterminated frontmatter");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = lines.mid(closing + 1).join('\n').trimmed();
|
||||||
|
|
||||||
|
QHash<QString, QString> fields;
|
||||||
|
int i = 1;
|
||||||
|
while (i < closing) {
|
||||||
|
const QString line = lines[i];
|
||||||
|
const QString trimmed = line.trimmed();
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith('#') || indentOf(line) != 0) {
|
||||||
|
++i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int colon = line.indexOf(':');
|
||||||
|
if (colon < 0) {
|
||||||
|
++i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString key = line.left(colon).trimmed();
|
||||||
|
QString value = line.mid(colon + 1).trimmed();
|
||||||
|
++i;
|
||||||
|
|
||||||
|
if (key == QLatin1String("metadata") && value.isEmpty()) {
|
||||||
|
while (i < closing && (lines[i].trimmed().isEmpty() || indentOf(lines[i]) > 0)) {
|
||||||
|
const QString entry = lines[i].trimmed();
|
||||||
|
++i;
|
||||||
|
if (entry.isEmpty() || entry.startsWith('#'))
|
||||||
|
continue;
|
||||||
|
const int entryColon = entry.indexOf(':');
|
||||||
|
if (entryColon < 0)
|
||||||
|
continue;
|
||||||
|
skill.metadata.insert(
|
||||||
|
entry.left(entryColon).trimmed(), unquote(entry.mid(entryColon + 1)));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('>') || value.startsWith('|')) {
|
||||||
|
const bool literal = value.startsWith('|');
|
||||||
|
QStringList block; // raw lines, indentation preserved
|
||||||
|
while (i < closing && (lines[i].trimmed().isEmpty() || indentOf(lines[i]) > 0)) {
|
||||||
|
block.append(lines[i]);
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
while (!block.isEmpty() && block.last().trimmed().isEmpty())
|
||||||
|
block.removeLast();
|
||||||
|
|
||||||
|
if (literal) {
|
||||||
|
// Strip the common leading indentation, keep the rest verbatim.
|
||||||
|
int common = -1;
|
||||||
|
for (const QString &blockLine : block) {
|
||||||
|
if (blockLine.trimmed().isEmpty())
|
||||||
|
continue;
|
||||||
|
const int indent = indentOf(blockLine);
|
||||||
|
if (common < 0 || indent < common)
|
||||||
|
common = indent;
|
||||||
|
}
|
||||||
|
if (common < 0)
|
||||||
|
common = 0;
|
||||||
|
QStringList stripped;
|
||||||
|
for (const QString &blockLine : block)
|
||||||
|
stripped.append(blockLine.mid(qMin(common, blockLine.size())));
|
||||||
|
value = stripped.join('\n');
|
||||||
|
} else {
|
||||||
|
// Folded scalar: join non-blank lines with single spaces.
|
||||||
|
QStringList folded;
|
||||||
|
for (const QString &blockLine : block) {
|
||||||
|
const QString trimmedLine = blockLine.trimmed();
|
||||||
|
if (!trimmedLine.isEmpty())
|
||||||
|
folded.append(trimmedLine);
|
||||||
|
}
|
||||||
|
value = folded.join(' ');
|
||||||
|
}
|
||||||
|
fields.insert(key, value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.insert(key, unquote(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
skill.name = fields.value(QStringLiteral("name"));
|
||||||
|
skill.description = fields.value(QStringLiteral("description"));
|
||||||
|
skill.license = fields.value(QStringLiteral("license"));
|
||||||
|
skill.compatibility = fields.value(QStringLiteral("compatibility"));
|
||||||
|
const QString tools = fields.value(QStringLiteral("allowed-tools"));
|
||||||
|
if (!tools.isEmpty())
|
||||||
|
skill.allowedTools = tools.split(' ', Qt::SkipEmptyParts);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SkillsLoader::validateName(const QString &name, const QString &dirName, QString &error)
|
||||||
|
{
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
error = QStringLiteral("missing 'name'");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (name.size() > 64) {
|
||||||
|
error = QStringLiteral("'name' exceeds 64 characters");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const QChar c : name) {
|
||||||
|
const bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
|
||||||
|
if (!ok) {
|
||||||
|
error = QStringLiteral(
|
||||||
|
"'name' may only contain lowercase letters, digits and hyphens");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (name.startsWith('-') || name.endsWith('-')) {
|
||||||
|
error = QStringLiteral("'name' must not start or end with a hyphen");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (name.contains(QLatin1String("--"))) {
|
||||||
|
error = QStringLiteral("'name' must not contain consecutive hyphens");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The directory name may differ in case on case-insensitive filesystems
|
||||||
|
// (macOS, Windows); the spec only requires the names to match.
|
||||||
|
if (name.compare(dirName, Qt::CaseInsensitive) != 0) {
|
||||||
|
error = QStringLiteral("'name' (%1) must match the skill directory name (%2)")
|
||||||
|
.arg(name, dirName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
SkillsLoader::ParseResult SkillsLoader::parseSkillFile(
|
||||||
|
const QString &skillDir, const QString &rootPath)
|
||||||
|
{
|
||||||
|
ParseResult result;
|
||||||
|
|
||||||
|
const QString skillMdPath = QDir(skillDir).absoluteFilePath(QStringLiteral("SKILL.md"));
|
||||||
|
QFile file(skillMdPath);
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
result.error = QStringLiteral("cannot open SKILL.md");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const QString text = QString::fromUtf8(file.readAll());
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
AgentSkill skill;
|
||||||
|
QString body;
|
||||||
|
if (!parseFrontmatter(text, skill, body, result.error))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
const QString dirName = QDir(skillDir).dirName();
|
||||||
|
if (!validateName(skill.name, dirName, result.error))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
if (skill.description.isEmpty()) {
|
||||||
|
result.error = QStringLiteral("missing 'description'");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (skill.description.size() > 1024) {
|
||||||
|
result.error = QStringLiteral("'description' exceeds 1024 characters");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
skill.alwaysOn = skill.metadata.value(QStringLiteral("always-on"))
|
||||||
|
.compare(QLatin1String("true"), Qt::CaseInsensitive)
|
||||||
|
== 0;
|
||||||
|
if (body.size() > maxBodyChars()) {
|
||||||
|
body.truncate(maxBodyChars());
|
||||||
|
body += QStringLiteral("\n\n[skill body truncated]");
|
||||||
|
}
|
||||||
|
skill.body = body;
|
||||||
|
skill.skillDir = QDir(skillDir).absolutePath();
|
||||||
|
skill.rootPath = rootPath;
|
||||||
|
result.skill = skill;
|
||||||
|
result.valid = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<AgentSkill> SkillsLoader::scan(const QStringList &rootPaths)
|
||||||
|
{
|
||||||
|
QVector<AgentSkill> skills;
|
||||||
|
QSet<QString> seenNames;
|
||||||
|
|
||||||
|
for (const QString &root : rootPaths) {
|
||||||
|
QDir rootDir(root);
|
||||||
|
if (!rootDir.exists())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const QStringList entries = rootDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||||
|
for (const QString &entry : entries) {
|
||||||
|
const QString skillDir = rootDir.absoluteFilePath(entry);
|
||||||
|
if (!QFile::exists(QDir(skillDir).absoluteFilePath(QStringLiteral("SKILL.md"))))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const ParseResult result = parseSkillFile(skillDir, root);
|
||||||
|
if (!result.valid) {
|
||||||
|
qWarning().noquote()
|
||||||
|
<< "QodeAssist Skills: skipping" << skillDir << "-" << result.error;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seenNames.contains(result.skill.name))
|
||||||
|
continue; // earlier root wins
|
||||||
|
seenNames.insert(result.skill.name);
|
||||||
|
skills.append(result.skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Skills
|
||||||
36
sources/skills/SkillsLoader.hpp
Normal file
36
sources/skills/SkillsLoader.hpp
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Copyright (C) 2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
#include "AgentSkill.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
|
||||||
|
class SkillsLoader
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
struct ParseResult
|
||||||
|
{
|
||||||
|
AgentSkill skill;
|
||||||
|
bool valid = false;
|
||||||
|
QString error;
|
||||||
|
};
|
||||||
|
|
||||||
|
static QVector<AgentSkill> scan(const QStringList &rootPaths);
|
||||||
|
|
||||||
|
static ParseResult parseSkillFile(const QString &skillDir, const QString &rootPath);
|
||||||
|
|
||||||
|
static int maxBodyChars();
|
||||||
|
|
||||||
|
private:
|
||||||
|
static bool parseFrontmatter(
|
||||||
|
const QString &text, AgentSkill &skill, QString &body, QString &error);
|
||||||
|
static bool validateName(const QString &name, const QString &dirName, QString &error);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Skills
|
||||||
129
sources/skills/SkillsManager.cpp
Normal file
129
sources/skills/SkillsManager.cpp
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// Copyright (C) 2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "SkillsManager.hpp"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileSystemWatcher>
|
||||||
|
|
||||||
|
#include "SkillsLoader.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
|
||||||
|
SkillsManager::SkillsManager(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_watcher(new QFileSystemWatcher(this))
|
||||||
|
{
|
||||||
|
connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, [this] { reload(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void SkillsManager::configure(
|
||||||
|
const QString &projectPath,
|
||||||
|
const QStringList &globalRoots,
|
||||||
|
const QStringList &projectSubdirs)
|
||||||
|
{
|
||||||
|
if (m_projectPath == projectPath && m_globalRoots == globalRoots
|
||||||
|
&& m_projectSubdirs == projectSubdirs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_projectPath = projectPath;
|
||||||
|
m_globalRoots = globalRoots;
|
||||||
|
m_projectSubdirs = projectSubdirs;
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList SkillsManager::resolveRoots(
|
||||||
|
const QString &projectPath,
|
||||||
|
const QStringList &globalRoots,
|
||||||
|
const QStringList &projectSubdirs)
|
||||||
|
{
|
||||||
|
// Project-relative roots first so they win on a name collision.
|
||||||
|
QStringList roots;
|
||||||
|
if (!projectPath.isEmpty()) {
|
||||||
|
const QDir projectDir(projectPath);
|
||||||
|
const QString projectRoot = QDir::cleanPath(projectDir.absolutePath());
|
||||||
|
for (const QString &subdir : projectSubdirs) {
|
||||||
|
const QString resolved = QDir::cleanPath(projectDir.absoluteFilePath(subdir));
|
||||||
|
// Drop subdirs that escape the project root (e.g. "../../etc").
|
||||||
|
if (resolved == projectRoot
|
||||||
|
|| resolved.startsWith(projectRoot + QLatin1Char('/'))) {
|
||||||
|
roots << resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const QString &root : globalRoots)
|
||||||
|
roots << QDir::cleanPath(root);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SkillsManager::reload()
|
||||||
|
{
|
||||||
|
const QStringList roots = resolveRoots(m_projectPath, m_globalRoots, m_projectSubdirs);
|
||||||
|
m_skills = SkillsLoader::scan(roots);
|
||||||
|
updateWatcher(roots);
|
||||||
|
emit skillsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SkillsManager::updateWatcher(const QStringList &roots)
|
||||||
|
{
|
||||||
|
const QStringList watched = m_watcher->directories();
|
||||||
|
if (!watched.isEmpty())
|
||||||
|
m_watcher->removePaths(watched);
|
||||||
|
|
||||||
|
QStringList toWatch;
|
||||||
|
for (const QString &root : roots) {
|
||||||
|
if (QDir(root).exists())
|
||||||
|
toWatch << root;
|
||||||
|
}
|
||||||
|
for (const AgentSkill &skill : m_skills)
|
||||||
|
toWatch << skill.skillDir;
|
||||||
|
|
||||||
|
if (!toWatch.isEmpty())
|
||||||
|
m_watcher->addPaths(toWatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<AgentSkill> SkillsManager::skills() const
|
||||||
|
{
|
||||||
|
return m_skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<AgentSkill> SkillsManager::findByName(const QString &name) const
|
||||||
|
{
|
||||||
|
for (const AgentSkill &skill : m_skills) {
|
||||||
|
if (skill.name == name)
|
||||||
|
return skill;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SkillsManager::catalogText() const
|
||||||
|
{
|
||||||
|
QStringList entries;
|
||||||
|
for (const AgentSkill &skill : m_skills) {
|
||||||
|
if (!skill.enabled || skill.alwaysOn)
|
||||||
|
continue;
|
||||||
|
entries << QStringLiteral("- %1: %2").arg(skill.name, skill.description);
|
||||||
|
}
|
||||||
|
if (entries.isEmpty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
return QStringLiteral("# Available Skills\n"
|
||||||
|
"Specialized skills are available for the tasks below. When a "
|
||||||
|
"request matches a skill, call the load_skill tool with that "
|
||||||
|
"skill's name to load its full instructions, then follow them.\n\n")
|
||||||
|
+ entries.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SkillsManager::alwaysOnBodies() const
|
||||||
|
{
|
||||||
|
QStringList bodies;
|
||||||
|
for (const AgentSkill &skill : m_skills) {
|
||||||
|
if (!skill.enabled || !skill.alwaysOn)
|
||||||
|
continue;
|
||||||
|
if (!skill.body.isEmpty())
|
||||||
|
bodies << skill.body;
|
||||||
|
}
|
||||||
|
return bodies.join(QStringLiteral("\n\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Skills
|
||||||
59
sources/skills/SkillsManager.hpp
Normal file
59
sources/skills/SkillsManager.hpp
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Copyright (C) 2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
#include "AgentSkill.hpp"
|
||||||
|
|
||||||
|
class QFileSystemWatcher;
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
|
||||||
|
class SkillsManager : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit SkillsManager(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
void configure(
|
||||||
|
const QString &projectPath,
|
||||||
|
const QStringList &globalRoots,
|
||||||
|
const QStringList &projectSubdirs);
|
||||||
|
|
||||||
|
void reload();
|
||||||
|
|
||||||
|
QVector<AgentSkill> skills() const;
|
||||||
|
|
||||||
|
std::optional<AgentSkill> findByName(const QString &name) const;
|
||||||
|
|
||||||
|
static QStringList resolveRoots(
|
||||||
|
const QString &projectPath,
|
||||||
|
const QStringList &globalRoots,
|
||||||
|
const QStringList &projectSubdirs);
|
||||||
|
|
||||||
|
QString catalogText() const;
|
||||||
|
|
||||||
|
QString alwaysOnBodies() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void skillsChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void updateWatcher(const QStringList &roots);
|
||||||
|
|
||||||
|
QString m_projectPath;
|
||||||
|
QStringList m_globalRoots;
|
||||||
|
QStringList m_projectSubdirs;
|
||||||
|
QVector<AgentSkill> m_skills;
|
||||||
|
QFileSystemWatcher *m_watcher = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Skills
|
||||||
@@ -15,6 +15,7 @@ public:
|
|||||||
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
||||||
QString name() const override { return "Claude"; }
|
QString name() const override { return "Claude"; }
|
||||||
QStringList stopWords() const override { return QStringList(); }
|
QStringList stopWords() const override { return QStringList(); }
|
||||||
|
bool supportsToolHistory() const override { return true; }
|
||||||
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
||||||
{
|
{
|
||||||
QJsonArray messages;
|
QJsonArray messages;
|
||||||
@@ -24,9 +25,48 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (context.history) {
|
if (context.history) {
|
||||||
|
int toolResultUserIdx = -1;
|
||||||
for (const auto &msg : context.history.value()) {
|
for (const auto &msg : context.history.value()) {
|
||||||
if (msg.role == "system") continue;
|
if (msg.role == "system") continue;
|
||||||
|
|
||||||
|
if (!msg.toolCalls.isEmpty()) {
|
||||||
|
toolResultUserIdx = -1;
|
||||||
|
QJsonArray content;
|
||||||
|
if (!msg.content.isEmpty()) {
|
||||||
|
content.append(QJsonObject{{"type", "text"}, {"text", msg.content}});
|
||||||
|
}
|
||||||
|
for (const auto &call : msg.toolCalls) {
|
||||||
|
content.append(QJsonObject{
|
||||||
|
{"type", "tool_use"},
|
||||||
|
{"id", call.id},
|
||||||
|
{"name", call.name},
|
||||||
|
{"input", call.arguments}});
|
||||||
|
}
|
||||||
|
messages.append(QJsonObject{{"role", "assistant"}, {"content", content}});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role == "tool") {
|
||||||
|
QJsonObject resultBlock{
|
||||||
|
{"type", "tool_result"},
|
||||||
|
{"tool_use_id", msg.toolCallId},
|
||||||
|
{"content", msg.content}};
|
||||||
|
if (toolResultUserIdx >= 0) {
|
||||||
|
QJsonObject userMsg = messages[toolResultUserIdx].toObject();
|
||||||
|
QJsonArray content = userMsg["content"].toArray();
|
||||||
|
content.append(resultBlock);
|
||||||
|
userMsg["content"] = content;
|
||||||
|
messages[toolResultUserIdx] = userMsg;
|
||||||
|
} else {
|
||||||
|
messages.append(QJsonObject{
|
||||||
|
{"role", "user"}, {"content", QJsonArray{resultBlock}}});
|
||||||
|
toolResultUserIdx = messages.size() - 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
toolResultUserIdx = -1;
|
||||||
|
|
||||||
if (msg.isThinking) {
|
if (msg.isThinking) {
|
||||||
// Claude API requires signature for thinking blocks
|
// Claude API requires signature for thinking blocks
|
||||||
if (msg.signature.isEmpty()) {
|
if (msg.signature.isEmpty()) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public:
|
|||||||
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
||||||
QString name() const override { return "Google AI"; }
|
QString name() const override { return "Google AI"; }
|
||||||
QStringList stopWords() const override { return QStringList(); }
|
QStringList stopWords() const override { return QStringList(); }
|
||||||
|
bool supportsToolHistory() const override { return true; }
|
||||||
|
|
||||||
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
||||||
{
|
{
|
||||||
@@ -26,7 +27,45 @@ public:
|
|||||||
{"parts", QJsonObject{{"text", context.systemPrompt.value()}}}};
|
{"parts", QJsonObject{{"text", context.systemPrompt.value()}}}};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int toolResultIdx = -1;
|
||||||
for (const auto &msg : context.history.value()) {
|
for (const auto &msg : context.history.value()) {
|
||||||
|
if (!msg.toolCalls.isEmpty()) {
|
||||||
|
toolResultIdx = -1;
|
||||||
|
QJsonArray callParts;
|
||||||
|
if (!msg.content.isEmpty()) {
|
||||||
|
callParts.append(QJsonObject{{"text", msg.content}});
|
||||||
|
}
|
||||||
|
for (const auto &call : msg.toolCalls) {
|
||||||
|
callParts.append(QJsonObject{
|
||||||
|
{"functionCall",
|
||||||
|
QJsonObject{{"name", call.name}, {"args", call.arguments}}}});
|
||||||
|
}
|
||||||
|
contents.append(QJsonObject{{"role", "model"}, {"parts", callParts}});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role == "tool") {
|
||||||
|
QJsonObject responsePart{
|
||||||
|
{"functionResponse",
|
||||||
|
QJsonObject{
|
||||||
|
{"name", msg.toolName},
|
||||||
|
{"response", QJsonObject{{"result", msg.content}}}}}};
|
||||||
|
if (toolResultIdx >= 0) {
|
||||||
|
QJsonObject fnMsg = contents[toolResultIdx].toObject();
|
||||||
|
QJsonArray fnParts = fnMsg["parts"].toArray();
|
||||||
|
fnParts.append(responsePart);
|
||||||
|
fnMsg["parts"] = fnParts;
|
||||||
|
contents[toolResultIdx] = fnMsg;
|
||||||
|
} else {
|
||||||
|
contents.append(
|
||||||
|
QJsonObject{{"role", "function"}, {"parts", QJsonArray{responsePart}}});
|
||||||
|
toolResultIdx = contents.size() - 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
toolResultIdx = -1;
|
||||||
|
|
||||||
QJsonObject content;
|
QJsonObject content;
|
||||||
QJsonArray parts;
|
QJsonArray parts;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
|
|
||||||
#include "pluginllmcore/PromptTemplate.hpp"
|
#include "pluginllmcore/PromptTemplate.hpp"
|
||||||
|
#include "templates/ToolMessages.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Templates {
|
namespace QodeAssist::Templates {
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ public:
|
|||||||
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
||||||
QString name() const override { return "Mistral AI Chat"; }
|
QString name() const override { return "Mistral AI Chat"; }
|
||||||
QStringList stopWords() const override { return QStringList(); }
|
QStringList stopWords() const override { return QStringList(); }
|
||||||
|
bool supportsToolHistory() const override { return true; }
|
||||||
|
|
||||||
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
||||||
{
|
{
|
||||||
@@ -59,6 +61,9 @@ public:
|
|||||||
|
|
||||||
if (context.history) {
|
if (context.history) {
|
||||||
for (const auto &msg : context.history.value()) {
|
for (const auto &msg : context.history.value()) {
|
||||||
|
if (appendOpenAIToolMessage(messages, msg)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (msg.images && !msg.images->isEmpty()) {
|
if (msg.images && !msg.images->isEmpty()) {
|
||||||
QJsonArray content;
|
QJsonArray content;
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public:
|
|||||||
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
||||||
QString name() const override { return "Ollama Chat"; }
|
QString name() const override { return "Ollama Chat"; }
|
||||||
QStringList stopWords() const override { return QStringList(); }
|
QStringList stopWords() const override { return QStringList(); }
|
||||||
|
bool supportsToolHistory() const override { return true; }
|
||||||
|
|
||||||
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
||||||
{
|
{
|
||||||
@@ -63,7 +64,27 @@ public:
|
|||||||
for (const auto &msg : context.history.value()) {
|
for (const auto &msg : context.history.value()) {
|
||||||
QJsonObject messageObj;
|
QJsonObject messageObj;
|
||||||
messageObj["role"] = msg.role;
|
messageObj["role"] = msg.role;
|
||||||
messageObj["content"] = msg.content;
|
|
||||||
|
if (!msg.toolCalls.isEmpty()) {
|
||||||
|
QJsonArray toolCalls;
|
||||||
|
for (const auto &call : msg.toolCalls) {
|
||||||
|
toolCalls.append(QJsonObject{
|
||||||
|
{"type", "function"},
|
||||||
|
{"function",
|
||||||
|
QJsonObject{{"name", call.name}, {"arguments", call.arguments}}}});
|
||||||
|
}
|
||||||
|
messageObj["tool_calls"] = toolCalls;
|
||||||
|
if (!msg.content.isEmpty()) {
|
||||||
|
messageObj["content"] = msg.content;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
messageObj["content"] = msg.content;
|
||||||
|
// Ollama correlates a tool result to its originating
|
||||||
|
// call by tool_name; omitting it breaks multi-tool turns.
|
||||||
|
if (msg.role == QLatin1String("tool") && !msg.toolName.isEmpty()) {
|
||||||
|
messageObj["tool_name"] = msg.toolName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.images && !msg.images->isEmpty()) {
|
if (msg.images && !msg.images->isEmpty()) {
|
||||||
QJsonArray images;
|
QJsonArray images;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
|
|
||||||
#include "pluginllmcore/PromptTemplate.hpp"
|
#include "pluginllmcore/PromptTemplate.hpp"
|
||||||
|
#include "templates/ToolMessages.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Templates {
|
namespace QodeAssist::Templates {
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ public:
|
|||||||
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
||||||
QString name() const override { return "OpenAI"; }
|
QString name() const override { return "OpenAI"; }
|
||||||
QStringList stopWords() const override { return QStringList(); }
|
QStringList stopWords() const override { return QStringList(); }
|
||||||
|
bool supportsToolHistory() const override { return true; }
|
||||||
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
||||||
{
|
{
|
||||||
QJsonArray messages;
|
QJsonArray messages;
|
||||||
@@ -26,6 +28,9 @@ public:
|
|||||||
|
|
||||||
if (context.history) {
|
if (context.history) {
|
||||||
for (const auto &msg : context.history.value()) {
|
for (const auto &msg : context.history.value()) {
|
||||||
|
if (appendOpenAIToolMessage(messages, msg)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (msg.images && !msg.images->isEmpty()) {
|
if (msg.images && !msg.images->isEmpty()) {
|
||||||
QJsonArray content;
|
QJsonArray content;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
|
|
||||||
#include "pluginllmcore/PromptTemplate.hpp"
|
#include "pluginllmcore/PromptTemplate.hpp"
|
||||||
|
#include "templates/ToolMessages.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Templates {
|
namespace QodeAssist::Templates {
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ public:
|
|||||||
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
|
||||||
QString name() const override { return "OpenAI Compatible"; }
|
QString name() const override { return "OpenAI Compatible"; }
|
||||||
QStringList stopWords() const override { return QStringList(); }
|
QStringList stopWords() const override { return QStringList(); }
|
||||||
|
bool supportsToolHistory() const override { return true; }
|
||||||
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
||||||
{
|
{
|
||||||
QJsonArray messages;
|
QJsonArray messages;
|
||||||
@@ -26,6 +28,9 @@ public:
|
|||||||
|
|
||||||
if (context.history) {
|
if (context.history) {
|
||||||
for (const auto &msg : context.history.value()) {
|
for (const auto &msg : context.history.value()) {
|
||||||
|
if (appendOpenAIToolMessage(messages, msg)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (msg.images && !msg.images->isEmpty()) {
|
if (msg.images && !msg.images->isEmpty()) {
|
||||||
QJsonArray content;
|
QJsonArray content;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include "pluginllmcore/PromptTemplate.hpp"
|
#include "pluginllmcore/PromptTemplate.hpp"
|
||||||
|
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
|
||||||
namespace QodeAssist::Templates {
|
namespace QodeAssist::Templates {
|
||||||
@@ -22,6 +23,8 @@ public:
|
|||||||
|
|
||||||
QStringList stopWords() const override { return {}; }
|
QStringList stopWords() const override { return {}; }
|
||||||
|
|
||||||
|
bool supportsToolHistory() const override { return true; }
|
||||||
|
|
||||||
void prepareRequest(
|
void prepareRequest(
|
||||||
QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
QJsonObject &request, const PluginLLMCore::ContextData &context) const override
|
||||||
{
|
{
|
||||||
@@ -39,6 +42,30 @@ public:
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!msg.toolCalls.isEmpty()) {
|
||||||
|
if (!msg.content.isEmpty()) {
|
||||||
|
input.append(QJsonObject{{"role", "assistant"}, {"content", msg.content}});
|
||||||
|
}
|
||||||
|
for (const auto &call : msg.toolCalls) {
|
||||||
|
input.append(QJsonObject{
|
||||||
|
{"type", "function_call"},
|
||||||
|
{"call_id", call.id},
|
||||||
|
{"name", call.name},
|
||||||
|
{"arguments",
|
||||||
|
QString::fromUtf8(
|
||||||
|
QJsonDocument(call.arguments).toJson(QJsonDocument::Compact))}});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role == "tool") {
|
||||||
|
input.append(QJsonObject{
|
||||||
|
{"type", "function_call_output"},
|
||||||
|
{"call_id", msg.toolCallId},
|
||||||
|
{"output", msg.content}});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
QJsonObject message;
|
QJsonObject message;
|
||||||
message["role"] = msg.role;
|
message["role"] = msg.role;
|
||||||
|
|
||||||
|
|||||||
45
templates/ToolMessages.hpp
Normal file
45
templates/ToolMessages.hpp
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
|
|
||||||
|
#include "pluginllmcore/ContextData.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Templates {
|
||||||
|
|
||||||
|
inline bool appendOpenAIToolMessage(QJsonArray &messages, const PluginLLMCore::Message &msg)
|
||||||
|
{
|
||||||
|
if (!msg.toolCalls.isEmpty()) {
|
||||||
|
QJsonArray toolCalls;
|
||||||
|
for (const auto &call : msg.toolCalls) {
|
||||||
|
toolCalls.append(QJsonObject{
|
||||||
|
{"id", call.id},
|
||||||
|
{"type", "function"},
|
||||||
|
{"function",
|
||||||
|
QJsonObject{
|
||||||
|
{"name", call.name},
|
||||||
|
{"arguments",
|
||||||
|
QString::fromUtf8(
|
||||||
|
QJsonDocument(call.arguments).toJson(QJsonDocument::Compact))}}}});
|
||||||
|
}
|
||||||
|
QJsonObject toolMessage{{"role", "assistant"}, {"tool_calls", toolCalls}};
|
||||||
|
toolMessage["content"] = msg.content.isEmpty() ? QJsonValue() : QJsonValue(msg.content);
|
||||||
|
messages.append(toolMessage);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role == QLatin1String("tool")) {
|
||||||
|
messages.append(QJsonObject{
|
||||||
|
{"role", "tool"}, {"tool_call_id", msg.toolCallId}, {"content", msg.content}});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Templates
|
||||||
@@ -3,6 +3,7 @@ add_executable(QodeAssistTest
|
|||||||
../LLMClientInterface.cpp
|
../LLMClientInterface.cpp
|
||||||
../LLMSuggestion.cpp
|
../LLMSuggestion.cpp
|
||||||
CodeHandlerTest.cpp
|
CodeHandlerTest.cpp
|
||||||
|
ClaudeCacheControlTest.cpp
|
||||||
DocumentContextReaderTest.cpp
|
DocumentContextReaderTest.cpp
|
||||||
LLMSuggestionTest.cpp
|
LLMSuggestionTest.cpp
|
||||||
# LLMClientInterfaceTests.cpp
|
# LLMClientInterfaceTests.cpp
|
||||||
@@ -21,6 +22,8 @@ target_link_libraries(QodeAssistTest PRIVATE
|
|||||||
LLMQore
|
LLMQore
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_include_directories(QodeAssistTest PRIVATE ${CMAKE_SOURCE_DIR})
|
||||||
|
|
||||||
target_compile_definitions(QodeAssistTest PRIVATE CMAKE_CURRENT_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}")
|
target_compile_definitions(QodeAssistTest PRIVATE CMAKE_CURRENT_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}")
|
||||||
|
|
||||||
add_test(NAME QodeAssistTest COMMAND QodeAssistTest)
|
add_test(NAME QodeAssistTest COMMAND QodeAssistTest)
|
||||||
|
|||||||
181
test/ClaudeCacheControlTest.cpp
Normal file
181
test/ClaudeCacheControlTest.cpp
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
#include "providers/ClaudeCacheControl.hpp"
|
||||||
|
|
||||||
|
using namespace QodeAssist::Providers::ClaudeCacheControl;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QJsonObject expectedEphemeral(bool extendedTtl)
|
||||||
|
{
|
||||||
|
QJsonObject obj{{"type", "ephemeral"}};
|
||||||
|
if (extendedTtl)
|
||||||
|
obj["ttl"] = "1h";
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(ClaudeCacheControlTest, BreakpointWithoutExtendedTTL)
|
||||||
|
{
|
||||||
|
const QJsonObject cc = buildBreakpoint(false);
|
||||||
|
EXPECT_EQ(cc.value("type").toString(), "ephemeral");
|
||||||
|
EXPECT_FALSE(cc.contains("ttl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClaudeCacheControlTest, BreakpointWithExtendedTTL)
|
||||||
|
{
|
||||||
|
const QJsonObject cc = buildBreakpoint(true);
|
||||||
|
EXPECT_EQ(cc.value("type").toString(), "ephemeral");
|
||||||
|
EXPECT_EQ(cc.value("ttl").toString(), "1h");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClaudeCacheControlTest, SystemAsStringWrappedIntoArray)
|
||||||
|
{
|
||||||
|
QJsonObject request;
|
||||||
|
request["system"] = "you are a helpful agent";
|
||||||
|
|
||||||
|
apply(request, false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(request.value("system").isArray());
|
||||||
|
const QJsonArray sys = request.value("system").toArray();
|
||||||
|
ASSERT_EQ(sys.size(), 1);
|
||||||
|
|
||||||
|
const QJsonObject block = sys.first().toObject();
|
||||||
|
EXPECT_EQ(block.value("type").toString(), "text");
|
||||||
|
EXPECT_EQ(block.value("text").toString(), "you are a helpful agent");
|
||||||
|
EXPECT_EQ(block.value("cache_control").toObject(), expectedEphemeral(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClaudeCacheControlTest, EmptySystemStringIsNotWrapped)
|
||||||
|
{
|
||||||
|
QJsonObject request;
|
||||||
|
request["system"] = "";
|
||||||
|
|
||||||
|
apply(request, false);
|
||||||
|
|
||||||
|
EXPECT_TRUE(request.value("system").isString());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClaudeCacheControlTest, SystemAsArrayMarksLastBlock)
|
||||||
|
{
|
||||||
|
QJsonObject request;
|
||||||
|
request["system"] = QJsonArray{
|
||||||
|
QJsonObject{{"type", "text"}, {"text", "a"}},
|
||||||
|
QJsonObject{{"type", "text"}, {"text", "b"}}};
|
||||||
|
|
||||||
|
apply(request, false);
|
||||||
|
|
||||||
|
const QJsonArray sys = request.value("system").toArray();
|
||||||
|
ASSERT_EQ(sys.size(), 2);
|
||||||
|
EXPECT_FALSE(sys[0].toObject().contains("cache_control"));
|
||||||
|
EXPECT_EQ(sys[1].toObject().value("cache_control").toObject(), expectedEphemeral(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClaudeCacheControlTest, ToolsLastEntryGetsCacheControl)
|
||||||
|
{
|
||||||
|
QJsonObject request;
|
||||||
|
request["tools"] = QJsonArray{
|
||||||
|
QJsonObject{{"name", "read_file"}},
|
||||||
|
QJsonObject{{"name", "edit_file"}},
|
||||||
|
QJsonObject{{"name", "search"}}};
|
||||||
|
|
||||||
|
apply(request, true);
|
||||||
|
|
||||||
|
const QJsonArray tools = request.value("tools").toArray();
|
||||||
|
ASSERT_EQ(tools.size(), 3);
|
||||||
|
EXPECT_FALSE(tools[0].toObject().contains("cache_control"));
|
||||||
|
EXPECT_FALSE(tools[1].toObject().contains("cache_control"));
|
||||||
|
EXPECT_EQ(tools[2].toObject().value("cache_control").toObject(), expectedEphemeral(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClaudeCacheControlTest, SingleMessageHistorySkipped)
|
||||||
|
{
|
||||||
|
QJsonObject request;
|
||||||
|
request["messages"]
|
||||||
|
= QJsonArray{QJsonObject{{"role", "user"}, {"content", "first message"}}};
|
||||||
|
|
||||||
|
apply(request, false);
|
||||||
|
|
||||||
|
const QJsonArray msgs = request.value("messages").toArray();
|
||||||
|
ASSERT_EQ(msgs.size(), 1);
|
||||||
|
EXPECT_TRUE(msgs[0].toObject().value("content").isString());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClaudeCacheControlTest, HistoryBreakpointOnSecondToLastMessage)
|
||||||
|
{
|
||||||
|
QJsonObject request;
|
||||||
|
request["messages"] = QJsonArray{
|
||||||
|
QJsonObject{{"role", "user"}, {"content", "u1"}},
|
||||||
|
QJsonObject{{"role", "assistant"}, {"content", "a1"}},
|
||||||
|
QJsonObject{{"role", "user"}, {"content", "u2-current"}}};
|
||||||
|
|
||||||
|
apply(request, false);
|
||||||
|
|
||||||
|
const QJsonArray msgs = request.value("messages").toArray();
|
||||||
|
ASSERT_EQ(msgs.size(), 3);
|
||||||
|
|
||||||
|
EXPECT_TRUE(msgs[0].toObject().value("content").isString());
|
||||||
|
|
||||||
|
const QJsonArray a1Content = msgs[1].toObject().value("content").toArray();
|
||||||
|
ASSERT_EQ(a1Content.size(), 1);
|
||||||
|
EXPECT_EQ(a1Content.first().toObject().value("text").toString(), "a1");
|
||||||
|
EXPECT_EQ(
|
||||||
|
a1Content.first().toObject().value("cache_control").toObject(),
|
||||||
|
expectedEphemeral(false));
|
||||||
|
|
||||||
|
EXPECT_TRUE(msgs[2].toObject().value("content").isString());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClaudeCacheControlTest, HistoryArrayContentMarksLastBlock)
|
||||||
|
{
|
||||||
|
QJsonObject request;
|
||||||
|
request["messages"] = QJsonArray{
|
||||||
|
QJsonObject{
|
||||||
|
{"role", "user"},
|
||||||
|
{"content",
|
||||||
|
QJsonArray{
|
||||||
|
QJsonObject{{"type", "text"}, {"text", "describe this"}},
|
||||||
|
QJsonObject{{"type", "image"}}}}},
|
||||||
|
QJsonObject{{"role", "assistant"}, {"content", "ok"}}};
|
||||||
|
|
||||||
|
apply(request, false);
|
||||||
|
|
||||||
|
const QJsonArray msgs = request.value("messages").toArray();
|
||||||
|
const QJsonArray content = msgs[0].toObject().value("content").toArray();
|
||||||
|
ASSERT_EQ(content.size(), 2);
|
||||||
|
EXPECT_FALSE(content[0].toObject().contains("cache_control"));
|
||||||
|
EXPECT_EQ(content[1].toObject().value("cache_control").toObject(), expectedEphemeral(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClaudeCacheControlTest, NoSystemNoToolsNoMessagesIsNoop)
|
||||||
|
{
|
||||||
|
QJsonObject request;
|
||||||
|
request["model"] = "claude-sonnet-4-5";
|
||||||
|
request["max_tokens"] = 1024;
|
||||||
|
|
||||||
|
apply(request, false);
|
||||||
|
|
||||||
|
EXPECT_EQ(request.value("model").toString(), "claude-sonnet-4-5");
|
||||||
|
EXPECT_EQ(request.value("max_tokens").toInt(), 1024);
|
||||||
|
EXPECT_FALSE(request.contains("system"));
|
||||||
|
EXPECT_FALSE(request.contains("tools"));
|
||||||
|
EXPECT_FALSE(request.contains("messages"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClaudeCacheControlTest, EmptyToolsArrayIsNoop)
|
||||||
|
{
|
||||||
|
QJsonObject request;
|
||||||
|
request["tools"] = QJsonArray{};
|
||||||
|
|
||||||
|
apply(request, false);
|
||||||
|
|
||||||
|
EXPECT_TRUE(request.value("tools").isArray());
|
||||||
|
EXPECT_TRUE(request.value("tools").toArray().isEmpty());
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user