mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-06-12 17:29:13 -04:00
feat: Add skills feature for tool and chat calling (#351)
This commit is contained in:
@@ -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
|
||||||
@@ -151,6 +153,7 @@ add_qtc_plugin(QodeAssist
|
|||||||
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/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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -92,6 +93,7 @@ target_link_libraries(QodeAssistChatView
|
|||||||
QodeAssistUIControlsplugin
|
QodeAssistUIControlsplugin
|
||||||
QodeAssistLogger
|
QodeAssistLogger
|
||||||
LLMQore
|
LLMQore
|
||||||
|
Skills
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(QodeAssistChatView
|
target_include_directories(QodeAssistChatView
|
||||||
|
|||||||
@@ -40,6 +40,9 @@
|
|||||||
#include "SessionFileRegistry.hpp"
|
#include "SessionFileRegistry.hpp"
|
||||||
#include "context/ContextManager.hpp"
|
#include "context/ContextManager.hpp"
|
||||||
#include "pluginllmcore/RulesLoader.hpp"
|
#include "pluginllmcore/RulesLoader.hpp"
|
||||||
|
#include "ProjectSettings.hpp"
|
||||||
|
#include "SkillsSettings.hpp"
|
||||||
|
#include "sources/skills/SkillsManager.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
@@ -313,6 +316,52 @@ SessionFileRegistry *ChatRootView::sessionFileRegistry() const
|
|||||||
return m_sessionFileRegistry;
|
return m_sessionFileRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Skills::SkillsManager *ChatRootView::skillsManager() const
|
||||||
|
{
|
||||||
|
if (!m_skillsManagerResolved) {
|
||||||
|
m_skillsManagerResolved = true;
|
||||||
|
if (auto context = qmlContext(this)) {
|
||||||
|
m_skillsManager = qobject_cast<Skills::SkillsManager *>(
|
||||||
|
context->contextProperty("skillsManager").value<QObject *>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m_skillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantList ChatRootView::searchSkills(const QString &query) const
|
||||||
|
{
|
||||||
|
QVariantList results;
|
||||||
|
auto *manager = skillsManager();
|
||||||
|
if (!manager || !Settings::skillsSettings().enableSkills())
|
||||||
|
return results;
|
||||||
|
|
||||||
|
auto *project = PluginLLMCore::RulesLoader::getActiveProject();
|
||||||
|
QStringList projectSkillDirs;
|
||||||
|
if (project) {
|
||||||
|
Settings::ProjectSettings projectSettings(project);
|
||||||
|
projectSkillDirs = Settings::SkillsSettings::splitLines(
|
||||||
|
projectSettings.projectSkillDirs());
|
||||||
|
}
|
||||||
|
manager->configure(
|
||||||
|
project ? project->projectDirectory().toFSPathString() : QString(),
|
||||||
|
Settings::SkillsSettings::splitPaths(Settings::skillsSettings().globalSkillRoots()),
|
||||||
|
projectSkillDirs);
|
||||||
|
|
||||||
|
const QString needle = query.trimmed().toLower();
|
||||||
|
for (const Skills::AgentSkill &skill : manager->skills()) {
|
||||||
|
if (!skill.enabled)
|
||||||
|
continue;
|
||||||
|
if (!needle.isEmpty() && !skill.name.toLower().contains(needle)
|
||||||
|
&& !skill.description.toLower().contains(needle)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.append(QVariantMap{
|
||||||
|
{QStringLiteral("name"), skill.name},
|
||||||
|
{QStringLiteral("description"), skill.description}});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
ChatModel *ChatRootView::chatModel() const
|
ChatModel *ChatRootView::chatModel() const
|
||||||
{
|
{
|
||||||
return m_chatModel;
|
return m_chatModel;
|
||||||
@@ -387,6 +436,7 @@ void ChatRootView::dispatchSend(
|
|||||||
|
|
||||||
m_tokenCounter->recordSent();
|
m_tokenCounter->recordSent();
|
||||||
|
|
||||||
|
m_clientInterface->setSkillsManager(skillsManager());
|
||||||
m_clientInterface->sendMessage(message, attachments, linkedFiles, useToolsArg, useThinkingArg);
|
m_clientInterface->sendMessage(message, attachments, linkedFiles, useToolsArg, useThinkingArg);
|
||||||
|
|
||||||
m_fileManager->clearIntermediateStorage();
|
m_fileManager->clearIntermediateStorage();
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
#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;
|
||||||
@@ -135,6 +139,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;
|
||||||
@@ -244,6 +250,7 @@ private:
|
|||||||
bool hasImageAttachments(const QStringList &attachments) const;
|
bool hasImageAttachments(const QStringList &attachments) const;
|
||||||
|
|
||||||
SessionFileRegistry *sessionFileRegistry() const;
|
SessionFileRegistry *sessionFileRegistry() const;
|
||||||
|
Skills::SkillsManager *skillsManager() const;
|
||||||
|
|
||||||
ChatModel *m_chatModel;
|
ChatModel *m_chatModel;
|
||||||
PluginLLMCore::PromptProviderChat m_promptProvider;
|
PluginLLMCore::PromptProviderChat m_promptProvider;
|
||||||
@@ -279,6 +286,8 @@ private:
|
|||||||
ChatHistoryStore *m_historyStore;
|
ChatHistoryStore *m_historyStore;
|
||||||
mutable QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
mutable QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
||||||
mutable bool m_sessionFileRegistryResolved = false;
|
mutable bool m_sessionFileRegistryResolved = false;
|
||||||
|
mutable QPointer<Skills::SkillsManager> m_skillsManager;
|
||||||
|
mutable bool m_skillsManagerResolved = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "ChatRootView.hpp"
|
#include "ChatRootView.hpp"
|
||||||
#include "QodeAssistConstants.hpp"
|
#include "QodeAssistConstants.hpp"
|
||||||
#include "SessionFileRegistry.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
|
||||||
@@ -26,7 +27,10 @@ constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::Win
|
|||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ChatView::ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
ChatView::ChatView(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager)
|
||||||
: QQuickView{engine, nullptr}
|
: QQuickView{engine, nullptr}
|
||||||
, m_isPin(false)
|
, m_isPin(false)
|
||||||
{
|
{
|
||||||
@@ -36,6 +40,7 @@ ChatView::ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
|||||||
auto context = new QQmlContext{engine, this};
|
auto context = new QQmlContext{engine, this};
|
||||||
context->setContextProperty("_chatview", this);
|
context->setContextProperty("_chatview", this);
|
||||||
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
|
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
|
||||||
|
context->setContextProperty("skillsManager", skillsManager);
|
||||||
|
|
||||||
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
||||||
auto rootItem = component->create(context);
|
auto rootItem = component->create(context);
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
#include <QQuickView>
|
#include <QQuickView>
|
||||||
#include <QShortcut>
|
#include <QShortcut>
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
class SessionFileRegistry;
|
class SessionFileRegistry;
|
||||||
@@ -19,7 +23,10 @@ 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(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
ChatView(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager);
|
||||||
|
|
||||||
bool isPin() const;
|
bool isPin() const;
|
||||||
void setIsPin(bool newIsPin);
|
void setIsPin(bool newIsPin);
|
||||||
|
|||||||
@@ -13,16 +13,22 @@
|
|||||||
|
|
||||||
#include "QodeAssistConstants.hpp"
|
#include "QodeAssistConstants.hpp"
|
||||||
#include "SessionFileRegistry.hpp"
|
#include "SessionFileRegistry.hpp"
|
||||||
|
#include "sources/skills/SkillsManager.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ChatWidget::ChatWidget(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, QWidget *parent)
|
ChatWidget::ChatWidget(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager,
|
||||||
|
QWidget *parent)
|
||||||
: QQuickWidget{engine, parent}
|
: QQuickWidget{engine, parent}
|
||||||
{
|
{
|
||||||
/// @note setup quick view content
|
/// @note setup quick view content
|
||||||
{
|
{
|
||||||
auto context = new QQmlContext{engine, this};
|
auto context = new QQmlContext{engine, this};
|
||||||
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
|
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
|
||||||
|
context->setContextProperty("skillsManager", skillsManager);
|
||||||
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
||||||
auto rootItem = component->create(context);
|
auto rootItem = component->create(context);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
|
|
||||||
#include <QtQuickWidgets/QtQuickWidgets>
|
#include <QtQuickWidgets/QtQuickWidgets>
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
class SessionFileRegistry;
|
class SessionFileRegistry;
|
||||||
@@ -17,6 +21,7 @@ public:
|
|||||||
explicit ChatWidget(
|
explicit ChatWidget(
|
||||||
QQmlEngine *engine,
|
QQmlEngine *engine,
|
||||||
SessionFileRegistry *sessionFileRegistry,
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager,
|
||||||
QWidget *parent = nullptr);
|
QWidget *parent = nullptr);
|
||||||
~ChatWidget() = default;
|
~ChatWidget() = default;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -35,21 +36,29 @@
|
|||||||
#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();
|
||||||
@@ -186,6 +195,44 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
#include <LLMQore/BaseClient.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
|
||||||
@@ -25,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 = {},
|
||||||
@@ -84,6 +90,7 @@ private:
|
|||||||
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;
|
||||||
|
|||||||
@@ -401,15 +401,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) {
|
||||||
@@ -427,6 +443,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +591,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 = ""
|
||||||
@@ -660,6 +707,20 @@ 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: {
|
||||||
focusInput()
|
focusInput()
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
README.md
37
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)
|
||||||
|
|||||||
@@ -17,11 +17,15 @@
|
|||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ChatEditor::ChatEditor(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
ChatEditor::ChatEditor(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager)
|
||||||
: m_engine(engine)
|
: m_engine(engine)
|
||||||
, m_sessionFileRegistry(sessionFileRegistry)
|
, m_sessionFileRegistry(sessionFileRegistry)
|
||||||
|
, m_skillsManager(skillsManager)
|
||||||
, m_document(new ChatDocument(this))
|
, m_document(new ChatDocument(this))
|
||||||
, m_chatWidget(new ChatWidget(engine, sessionFileRegistry))
|
, m_chatWidget(new ChatWidget(engine, sessionFileRegistry, skillsManager))
|
||||||
{
|
{
|
||||||
setWidget(m_chatWidget);
|
setWidget(m_chatWidget);
|
||||||
setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
|
setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
|
||||||
@@ -67,7 +71,7 @@ QWidget *ChatEditor::toolBar()
|
|||||||
|
|
||||||
Core::IEditor *ChatEditor::duplicate()
|
Core::IEditor *ChatEditor::duplicate()
|
||||||
{
|
{
|
||||||
return new ChatEditor(m_engine, m_sessionFileRegistry);
|
return new ChatEditor(m_engine, m_sessionFileRegistry, m_skillsManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
|
|
||||||
class QQmlEngine;
|
class QQmlEngine;
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
class ChatDocument;
|
class ChatDocument;
|
||||||
@@ -20,7 +24,10 @@ class ChatEditor : public Core::IEditor
|
|||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
ChatEditor(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
ChatEditor(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager);
|
||||||
~ChatEditor() override;
|
~ChatEditor() override;
|
||||||
|
|
||||||
Core::IDocument *document() const override;
|
Core::IDocument *document() const override;
|
||||||
@@ -32,6 +39,7 @@ public:
|
|||||||
private:
|
private:
|
||||||
QQmlEngine *m_engine;
|
QQmlEngine *m_engine;
|
||||||
SessionFileRegistry *m_sessionFileRegistry;
|
SessionFileRegistry *m_sessionFileRegistry;
|
||||||
|
Skills::SkillsManager *m_skillsManager;
|
||||||
ChatDocument *m_document;
|
ChatDocument *m_document;
|
||||||
ChatWidget *m_chatWidget;
|
ChatWidget *m_chatWidget;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,12 +9,16 @@
|
|||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ChatEditorFactory::ChatEditorFactory(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
ChatEditorFactory::ChatEditorFactory(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager)
|
||||||
{
|
{
|
||||||
setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID);
|
setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID);
|
||||||
setDisplayName(Tr::tr("QodeAssist Chat"));
|
setDisplayName(Tr::tr("QodeAssist Chat"));
|
||||||
setEditorCreator(
|
setEditorCreator([engine, sessionFileRegistry, skillsManager] {
|
||||||
[engine, sessionFileRegistry] { return new ChatEditor(engine, sessionFileRegistry); });
|
return new ChatEditor(engine, sessionFileRegistry, skillsManager);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
|
|
||||||
class QQmlEngine;
|
class QQmlEngine;
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
class SessionFileRegistry;
|
class SessionFileRegistry;
|
||||||
@@ -14,7 +18,10 @@ class SessionFileRegistry;
|
|||||||
class ChatEditorFactory : public Core::IEditorFactory
|
class ChatEditorFactory : public Core::IEditorFactory
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
ChatEditorFactory(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
ChatEditorFactory(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -8,9 +8,12 @@
|
|||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ChatOutputPane::ChatOutputPane(
|
ChatOutputPane::ChatOutputPane(
|
||||||
QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, QObject *parent)
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager,
|
||||||
|
QObject *parent)
|
||||||
: Core::IOutputPane(parent)
|
: Core::IOutputPane(parent)
|
||||||
, m_chatWidget{new ChatWidget{engine, sessionFileRegistry}}
|
, m_chatWidget{new ChatWidget{engine, sessionFileRegistry, skillsManager}}
|
||||||
{
|
{
|
||||||
setId("QodeAssistChat");
|
setId("QodeAssistChat");
|
||||||
setDisplayName(Tr::tr("QodeAssist Chat"));
|
setDisplayName(Tr::tr("QodeAssist Chat"));
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
#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 SessionFileRegistry;
|
||||||
@@ -18,6 +22,7 @@ public:
|
|||||||
explicit ChatOutputPane(
|
explicit ChatOutputPane(
|
||||||
QQmlEngine *engine,
|
QQmlEngine *engine,
|
||||||
SessionFileRegistry *sessionFileRegistry,
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager,
|
||||||
QObject *parent = nullptr);
|
QObject *parent = nullptr);
|
||||||
~ChatOutputPane() override;
|
~ChatOutputPane() override;
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,17 @@
|
|||||||
#include "ChatView/ChatWidget.hpp"
|
#include "ChatView/ChatWidget.hpp"
|
||||||
#include "ChatView/SessionFileRegistry.hpp"
|
#include "ChatView/SessionFileRegistry.hpp"
|
||||||
#include "QodeAssistConstants.hpp"
|
#include "QodeAssistConstants.hpp"
|
||||||
|
#include "sources/skills/SkillsManager.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
NavigationPanel::NavigationPanel(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
NavigationPanel::NavigationPanel(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager)
|
||||||
: m_engine{engine}
|
: m_engine{engine}
|
||||||
, m_sessionFileRegistry{sessionFileRegistry}
|
, m_sessionFileRegistry{sessionFileRegistry}
|
||||||
|
, m_skillsManager{skillsManager}
|
||||||
{
|
{
|
||||||
setDisplayName(tr("QodeAssist Chat"));
|
setDisplayName(tr("QodeAssist Chat"));
|
||||||
setPriority(500);
|
setPriority(500);
|
||||||
@@ -23,7 +28,7 @@ NavigationPanel::~NavigationPanel() {}
|
|||||||
|
|
||||||
Core::NavigationView NavigationPanel::createWidget()
|
Core::NavigationView NavigationPanel::createWidget()
|
||||||
{
|
{
|
||||||
return {.widget = new ChatWidget{m_engine, m_sessionFileRegistry}};
|
return {.widget = new ChatWidget{m_engine, m_sessionFileRegistry, m_skillsManager}};
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
|
|
||||||
class QQmlEngine;
|
class QQmlEngine;
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
class SessionFileRegistry;
|
class SessionFileRegistry;
|
||||||
@@ -17,7 +21,10 @@ class NavigationPanel : public Core::INavigationWidgetFactory
|
|||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
explicit NavigationPanel(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
explicit NavigationPanel(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
Skills::SkillsManager *skillsManager);
|
||||||
~NavigationPanel();
|
~NavigationPanel();
|
||||||
|
|
||||||
Core::NavigationView createWidget() override;
|
Core::NavigationView createWidget() override;
|
||||||
@@ -25,6 +32,7 @@ public:
|
|||||||
private:
|
private:
|
||||||
QPointer<QQmlEngine> m_engine;
|
QPointer<QQmlEngine> m_engine;
|
||||||
QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
||||||
|
QPointer<Skills::SkillsManager> m_skillsManager;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -42,6 +42,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"
|
||||||
@@ -162,14 +164,28 @@ public:
|
|||||||
|
|
||||||
m_engine = new QQmlEngine{this};
|
m_engine = new QQmlEngine{this};
|
||||||
m_sessionFileRegistry = new Chat::SessionFileRegistry{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{m_engine, m_sessionFileRegistry};
|
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_engine, m_sessionFileRegistry};
|
m_navigationPanel = new Chat::NavigationPanel{
|
||||||
|
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||||
}
|
}
|
||||||
m_chatEditorFactory = new Chat::ChatEditorFactory{m_engine, m_sessionFileRegistry};
|
m_chatEditorFactory = new Chat::ChatEditorFactory{
|
||||||
|
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||||
|
|
||||||
Settings::setupProjectPanel();
|
Settings::setupProjectPanel();
|
||||||
ConfigurationManager::instance().init();
|
ConfigurationManager::instance().init();
|
||||||
@@ -324,7 +340,7 @@ private:
|
|||||||
void openChatInWindow()
|
void openChatInWindow()
|
||||||
{
|
{
|
||||||
if (!m_chatView)
|
if (!m_chatView)
|
||||||
m_chatView.reset(new Chat::ChatView{m_engine, m_sessionFileRegistry});
|
m_chatView.reset(new Chat::ChatView{m_engine, m_sessionFileRegistry, m_skillsManager});
|
||||||
|
|
||||||
if (!m_chatView->isVisible())
|
if (!m_chatView->isVisible())
|
||||||
m_chatView->show();
|
m_chatView->show();
|
||||||
@@ -339,7 +355,8 @@ private:
|
|||||||
void setChatInBottomPaneEnabled(bool enabled)
|
void setChatInBottomPaneEnabled(bool enabled)
|
||||||
{
|
{
|
||||||
if (enabled && !m_chatOutputPane)
|
if (enabled && !m_chatOutputPane)
|
||||||
m_chatOutputPane = new Chat::ChatOutputPane{m_engine, m_sessionFileRegistry};
|
m_chatOutputPane = new Chat::ChatOutputPane{
|
||||||
|
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||||
else if (!enabled && m_chatOutputPane)
|
else if (!enabled && m_chatOutputPane)
|
||||||
delete m_chatOutputPane;
|
delete m_chatOutputPane;
|
||||||
|
|
||||||
@@ -350,7 +367,8 @@ private:
|
|||||||
void setChatInSidebarEnabled(bool enabled)
|
void setChatInSidebarEnabled(bool enabled)
|
||||||
{
|
{
|
||||||
if (enabled && !m_navigationPanel)
|
if (enabled && !m_navigationPanel)
|
||||||
m_navigationPanel = new Chat::NavigationPanel{m_engine, m_sessionFileRegistry};
|
m_navigationPanel = new Chat::NavigationPanel{
|
||||||
|
m_engine, m_sessionFileRegistry, m_skillsManager};
|
||||||
else if (!enabled && m_navigationPanel)
|
else if (!enabled && m_navigationPanel)
|
||||||
delete m_navigationPanel;
|
delete m_navigationPanel;
|
||||||
|
|
||||||
@@ -430,6 +448,7 @@ private:
|
|||||||
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<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})
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -102,12 +102,18 @@ const char CA_ENABLE_TERMINAL_COMMAND_TOOL[] = "QodeAssist.caEnableTerminalComma
|
|||||||
const char CA_ENABLE_TODO_TOOL[] = "QodeAssist.caEnableTodoToolV2";
|
const char CA_ENABLE_TODO_TOOL[] = "QodeAssist.caEnableTodoToolV2";
|
||||||
const char CA_ENABLE_READ_ORIGINAL_HISTORY_TOOL[]
|
const char CA_ENABLE_READ_ORIGINAL_HISTORY_TOOL[]
|
||||||
= "QodeAssist.caEnableReadOriginalHistoryTool";
|
= "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";
|
||||||
@@ -124,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";
|
||||||
|
|||||||
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
|
||||||
@@ -119,6 +119,14 @@ ToolsSettings::ToolsSettings()
|
|||||||
"summary currently in context. Has no effect if the chat was never compressed."));
|
"summary currently in context. Has no effect if the chat was never compressed."));
|
||||||
enableReadOriginalHistoryTool.setDefaultValue(true);
|
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(
|
||||||
@@ -186,7 +194,8 @@ ToolsSettings::ToolsSettings()
|
|||||||
enableGetIssuesListTool,
|
enableGetIssuesListTool,
|
||||||
enableTerminalCommandTool,
|
enableTerminalCommandTool,
|
||||||
enableTodoTool,
|
enableTodoTool,
|
||||||
enableReadOriginalHistoryTool}},
|
enableReadOriginalHistoryTool,
|
||||||
|
enableSkillTool}},
|
||||||
Space{8},
|
Space{8},
|
||||||
Group{
|
Group{
|
||||||
title(Tr::tr("Tool Settings")),
|
title(Tr::tr("Tool Settings")),
|
||||||
@@ -237,6 +246,7 @@ void ToolsSettings::resetSettingsToDefaults()
|
|||||||
resetAspect(enableTerminalCommandTool);
|
resetAspect(enableTerminalCommandTool);
|
||||||
resetAspect(enableTodoTool);
|
resetAspect(enableTodoTool);
|
||||||
resetAspect(enableReadOriginalHistoryTool);
|
resetAspect(enableReadOriginalHistoryTool);
|
||||||
|
resetAspect(enableSkillTool);
|
||||||
resetAspect(allowedTerminalCommandsLinux);
|
resetAspect(allowedTerminalCommandsLinux);
|
||||||
resetAspect(allowedTerminalCommandsMacOS);
|
resetAspect(allowedTerminalCommandsMacOS);
|
||||||
resetAspect(allowedTerminalCommandsWindows);
|
resetAspect(allowedTerminalCommandsWindows);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public:
|
|||||||
Utils::BoolAspect enableTerminalCommandTool{this};
|
Utils::BoolAspect enableTerminalCommandTool{this};
|
||||||
Utils::BoolAspect enableTodoTool{this};
|
Utils::BoolAspect enableTodoTool{this};
|
||||||
Utils::BoolAspect enableReadOriginalHistoryTool{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};
|
||||||
|
|||||||
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
|
||||||
84
tools/SkillTool.cpp
Normal file
84
tools/SkillTool.cpp
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Copyright (C) 2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "SkillTool.hpp"
|
||||||
|
|
||||||
|
#include <LLMQore/ToolExceptions.hpp>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QtConcurrent>
|
||||||
|
|
||||||
|
#include "sources/skills/AgentSkill.hpp"
|
||||||
|
#include "sources/skills/SkillsManager.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Tools {
|
||||||
|
|
||||||
|
SkillTool::SkillTool(Skills::SkillsManager *skillsManager, QObject *parent)
|
||||||
|
: BaseTool(parent)
|
||||||
|
, m_skillsManager(skillsManager)
|
||||||
|
{}
|
||||||
|
|
||||||
|
QString SkillTool::id() const
|
||||||
|
{
|
||||||
|
return "load_skill";
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SkillTool::displayName() const
|
||||||
|
{
|
||||||
|
return "Loading skill";
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SkillTool::description() const
|
||||||
|
{
|
||||||
|
return "Load the full instructions of a skill by name. The Available Skills catalog in "
|
||||||
|
"the system prompt lists each skill's name and a short description. When a request "
|
||||||
|
"matches a skill, call this tool with that skill's name to load its complete "
|
||||||
|
"instructions, then follow them.";
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject SkillTool::parametersSchema() const
|
||||||
|
{
|
||||||
|
QJsonObject properties;
|
||||||
|
properties["name"] = QJsonObject{
|
||||||
|
{"type", "string"},
|
||||||
|
{"description",
|
||||||
|
"Exact name of the skill to load, as shown in the Available Skills catalog."}};
|
||||||
|
|
||||||
|
QJsonObject definition;
|
||||||
|
definition["type"] = "object";
|
||||||
|
definition["properties"] = properties;
|
||||||
|
definition["required"] = QJsonArray{"name"};
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFuture<LLMQore::ToolResult> SkillTool::executeAsync(const QJsonObject &input)
|
||||||
|
{
|
||||||
|
const QString name = input["name"].toString().trimmed();
|
||||||
|
|
||||||
|
const std::optional<Skills::AgentSkill> found
|
||||||
|
= m_skillsManager ? m_skillsManager->findByName(name) : std::nullopt;
|
||||||
|
|
||||||
|
return QtConcurrent::run([name, found]() -> LLMQore::ToolResult {
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
throw LLMQore::ToolInvalidArgument(
|
||||||
|
"'name' parameter is required and cannot be empty");
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
throw LLMQore::ToolRuntimeError(
|
||||||
|
QString("Unknown skill: '%1'. Use a skill name from the Available Skills "
|
||||||
|
"catalog in the system prompt.")
|
||||||
|
.arg(name));
|
||||||
|
}
|
||||||
|
if (found->body.isEmpty()) {
|
||||||
|
throw LLMQore::ToolRuntimeError(
|
||||||
|
QString("Skill '%1' has no instructions.").arg(found->name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return LLMQore::ToolResult::text(
|
||||||
|
QString("Skill: %1\n\n%2").arg(found->name, found->body));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Tools
|
||||||
35
tools/SkillTool.hpp
Normal file
35
tools/SkillTool.hpp
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright (C) 2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <LLMQore/BaseTool.hpp>
|
||||||
|
#include <QFuture>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Tools {
|
||||||
|
|
||||||
|
class SkillTool : public ::LLMQore::BaseTool
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit SkillTool(Skills::SkillsManager *skillsManager, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString id() const override;
|
||||||
|
QString displayName() const override;
|
||||||
|
QString description() const override;
|
||||||
|
QJsonObject parametersSchema() const override;
|
||||||
|
QFuture<LLMQore::ToolResult> executeAsync(const QJsonObject &input) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QPointer<Skills::SkillsManager> m_skillsManager;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Tools
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
#include "ProjectSearchTool.hpp"
|
#include "ProjectSearchTool.hpp"
|
||||||
#include "ReadFileTool.hpp"
|
#include "ReadFileTool.hpp"
|
||||||
#include "ReadOriginalHistoryTool.hpp"
|
#include "ReadOriginalHistoryTool.hpp"
|
||||||
|
#include "SkillTool.hpp"
|
||||||
#include "TodoTool.hpp"
|
#include "TodoTool.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Tools {
|
namespace QodeAssist::Tools {
|
||||||
@@ -66,4 +67,26 @@ void registerQodeAssistTools(::LLMQore::ToolsManager *manager)
|
|||||||
manager, s.enableReadOriginalHistoryTool, "read_original_history");
|
manager, s.enableReadOriginalHistoryTool, "read_original_history");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void registerSkillTool(
|
||||||
|
::LLMQore::ToolsManager *manager, Skills::SkillsManager *skillsManager)
|
||||||
|
{
|
||||||
|
Utils::BoolAspect &aspect = Settings::toolsSettings().enableSkillTool;
|
||||||
|
const QString toolId = QStringLiteral("load_skill");
|
||||||
|
|
||||||
|
auto sync = [manager, toolId, &aspect, skillsManager]() {
|
||||||
|
const bool wanted = aspect.volatileValue();
|
||||||
|
const bool present = manager->tool(toolId) != nullptr;
|
||||||
|
if (wanted && !present) {
|
||||||
|
manager->addTool(new SkillTool(skillsManager, manager));
|
||||||
|
} else if (!wanted && present) {
|
||||||
|
manager->removeTool(toolId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sync();
|
||||||
|
|
||||||
|
QObject::connect(&aspect, &Utils::BoolAspect::volatileValueChanged, manager, sync);
|
||||||
|
QObject::connect(&aspect, &Utils::BaseAspect::changed, manager, sync);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace QodeAssist::Tools
|
} // namespace QodeAssist::Tools
|
||||||
|
|||||||
@@ -7,8 +7,15 @@ namespace LLMQore {
|
|||||||
class ToolsManager;
|
class ToolsManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Skills {
|
||||||
|
class SkillsManager;
|
||||||
|
}
|
||||||
|
|
||||||
namespace QodeAssist::Tools {
|
namespace QodeAssist::Tools {
|
||||||
|
|
||||||
void registerQodeAssistTools(::LLMQore::ToolsManager *manager);
|
void registerQodeAssistTools(::LLMQore::ToolsManager *manager);
|
||||||
|
|
||||||
|
void registerSkillTool(
|
||||||
|
::LLMQore::ToolsManager *manager, Skills::SkillsManager *skillsManager);
|
||||||
|
|
||||||
} // namespace QodeAssist::Tools
|
} // namespace QodeAssist::Tools
|
||||||
|
|||||||
Reference in New Issue
Block a user