From 10b924d78a1c520b40a447f941d5cd50e65929c9 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Tue, 30 Sep 2025 23:19:46 +0200 Subject: [PATCH] Feat: Add Claude tools support to plugin (#231) * feat: Add settings for handle using tools in chat * feat: Add Claude tools support * fix: Add ai ignore to read project files list tool * fix: Add ai ignore to read project file by name tool * fix: Add ai ignore to read current opened files tool --- CMakeLists.txt | 2 + ChatView/ClientInterface.cpp | 4 +- LLMClientInterface.cpp | 4 +- QuickRefactorHandler.cpp | 2 +- llmcore/CMakeLists.txt | 1 + llmcore/ContentBlocks.hpp | 140 +++++++++++++++++ llmcore/Provider.cpp | 5 + llmcore/Provider.hpp | 4 +- providers/ClaudeMessage.cpp | 162 +++++++++++++++++++ providers/ClaudeMessage.hpp | 63 ++++++++ providers/ClaudeProvider.cpp | 236 +++++++++++++++++++++++----- providers/ClaudeProvider.hpp | 23 +++ settings/ChatAssistantSettings.cpp | 64 +++++--- settings/ChatAssistantSettings.hpp | 1 + settings/SettingsConstants.hpp | 1 + tools/ListProjectFilesTool.cpp | 19 ++- tools/ListProjectFilesTool.hpp | 5 +- tools/ReadProjectFileByNameTool.cpp | 37 ++++- tools/ReadProjectFileByNameTool.hpp | 4 +- tools/ReadVisibleFilesTool.cpp | 16 ++ tools/ReadVisibleFilesTool.hpp | 6 +- tools/ToolsManager.cpp | 158 +++++++++++++++++++ tools/ToolsManager.hpp | 75 +++++++++ 23 files changed, 948 insertions(+), 84 deletions(-) create mode 100644 llmcore/ContentBlocks.hpp create mode 100644 providers/ClaudeMessage.cpp create mode 100644 providers/ClaudeMessage.hpp create mode 100644 tools/ToolsManager.cpp create mode 100644 tools/ToolsManager.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 56f41a6..215e42e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -117,6 +117,8 @@ add_qtc_plugin(QodeAssist tools/ReadVisibleFilesTool.hpp tools/ReadVisibleFilesTool.cpp tools/ToolHandler.hpp tools/ToolHandler.cpp tools/ListProjectFilesTool.hpp tools/ListProjectFilesTool.cpp + tools/ToolsManager.hpp tools/ToolsManager.cpp + providers/ClaudeMessage.hpp providers/ClaudeMessage.cpp ) get_target_property(QtCreatorCorePath QtCreator::Core LOCATION) diff --git a/ChatView/ClientInterface.cpp b/ChatView/ClientInterface.cpp index 233553d..0340d7e 100644 --- a/ChatView/ClientInterface.cpp +++ b/ChatView/ClientInterface.cpp @@ -153,8 +153,8 @@ void ClientInterface::cancelRequest() { for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { const RequestContext &ctx = it.value(); - if (ctx.provider && ctx.provider->httpClient()) { - ctx.provider->httpClient()->cancelRequest(it.key()); + if (ctx.provider) { + ctx.provider->cancelRequest(it.key()); } } diff --git a/LLMClientInterface.cpp b/LLMClientInterface.cpp index b8795f9..38c8677 100644 --- a/LLMClientInterface.cpp +++ b/LLMClientInterface.cpp @@ -117,8 +117,8 @@ void LLMClientInterface::handleCancelRequest(const QJsonObject &request) { for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { const RequestContext &ctx = it.value(); - if (ctx.provider && ctx.provider->httpClient()) { - ctx.provider->httpClient()->cancelRequest(it.key()); + if (ctx.provider) { + ctx.provider->cancelRequest(it.key()); } } diff --git a/QuickRefactorHandler.cpp b/QuickRefactorHandler.cpp index ae8cda0..faaf8b5 100644 --- a/QuickRefactorHandler.cpp +++ b/QuickRefactorHandler.cpp @@ -281,7 +281,7 @@ void QuickRefactorHandler::cancelRequest() for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { if (it.key() == id) { const RequestContext &ctx = it.value(); - ctx.provider->httpClient()->cancelRequest(id); + ctx.provider->cancelRequest(id); m_activeRequests.erase(it); break; } diff --git a/llmcore/CMakeLists.txt b/llmcore/CMakeLists.txt index 7b6d847..0a3d0f2 100644 --- a/llmcore/CMakeLists.txt +++ b/llmcore/CMakeLists.txt @@ -19,6 +19,7 @@ add_library(LLMCore STATIC SSEBuffer.hpp SSEBuffer.cpp BaseTool.hpp BaseTool.cpp + ContentBlocks.hpp ) target_link_libraries(LLMCore diff --git a/llmcore/ContentBlocks.hpp b/llmcore/ContentBlocks.hpp new file mode 100644 index 0000000..e8ab71b --- /dev/null +++ b/llmcore/ContentBlocks.hpp @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace QodeAssist::LLMCore { + +enum class MessageState { Building, Complete, RequiresToolExecution, Final }; + +enum class ProviderFormat { Claude, OpenAI }; + +class ContentBlock : public QObject +{ + Q_OBJECT +public: + explicit ContentBlock(QObject *parent = nullptr) + : QObject(parent) + {} + virtual ~ContentBlock() = default; + virtual QString type() const = 0; + virtual QJsonValue toJson(ProviderFormat format) const = 0; +}; + +class TextContent : public ContentBlock +{ + Q_OBJECT +public: + explicit TextContent(const QString &text = QString()) + : ContentBlock() + , m_text(text) + {} + + QString type() const override { return "text"; } + QString text() const { return m_text; } + void appendText(const QString &text) { m_text += text; } + void setText(const QString &text) { m_text = text; } + + QJsonValue toJson(ProviderFormat format) const override + { + Q_UNUSED(format); + return QJsonObject{{"type", "text"}, {"text", m_text}}; + } + +private: + QString m_text; +}; + +class ToolUseContent : public ContentBlock +{ + Q_OBJECT +public: + ToolUseContent(const QString &id, const QString &name, const QJsonObject &input = QJsonObject()) + : ContentBlock() + , m_id(id) + , m_name(name) + , m_input(input) + {} + + QString type() const override { return "tool_use"; } + QString id() const { return m_id; } + QString name() const { return m_name; } + QJsonObject input() const { return m_input; } + void setInput(const QJsonObject &input) { m_input = input; } + + QJsonValue toJson(ProviderFormat format) const override + { + if (format == ProviderFormat::Claude) { + return QJsonObject{ + {"type", "tool_use"}, {"id", m_id}, {"name", m_name}, {"input", m_input}}; + } else { // OpenAI + QJsonDocument doc(m_input); + return QJsonObject{ + {"id", m_id}, + {"type", "function"}, + {"function", + QJsonObject{ + {"name", m_name}, + {"arguments", QString::fromUtf8(doc.toJson(QJsonDocument::Compact))}}}}; + } + } + +private: + QString m_id; + QString m_name; + QJsonObject m_input; +}; + +class ToolResultContent : public ContentBlock +{ + Q_OBJECT +public: + ToolResultContent(const QString &toolUseId, const QString &result) + : ContentBlock() + , m_toolUseId(toolUseId) + , m_result(result) + {} + + QString type() const override { return "tool_result"; } + QString toolUseId() const { return m_toolUseId; } + QString result() const { return m_result; } + + QJsonValue toJson(ProviderFormat format) const override + { + if (format == ProviderFormat::Claude) { + return QJsonObject{ + {"type", "tool_result"}, {"tool_use_id", m_toolUseId}, {"content", m_result}}; + } else { // OpenAI + return QJsonObject{{"role", "tool"}, {"tool_call_id", m_toolUseId}, {"content", m_result}}; + } + } + +private: + QString m_toolUseId; + QString m_result; +}; + +} // namespace QodeAssist::LLMCore diff --git a/llmcore/Provider.cpp b/llmcore/Provider.cpp index 897875e..dbae853 100644 --- a/llmcore/Provider.cpp +++ b/llmcore/Provider.cpp @@ -12,6 +12,11 @@ Provider::Provider(QObject *parent) connect(m_httpClient, &HttpClient::requestFinished, this, &Provider::onRequestFinished); } +void Provider::cancelRequest(const RequestID &requestId) +{ + m_httpClient->cancelRequest(requestId); +} + HttpClient *Provider::httpClient() const { return m_httpClient; diff --git a/llmcore/Provider.hpp b/llmcore/Provider.hpp index 8e11ab5..d37107f 100644 --- a/llmcore/Provider.hpp +++ b/llmcore/Provider.hpp @@ -63,7 +63,9 @@ public: virtual void sendRequest(const RequestID &requestId, const QUrl &url, const QJsonObject &payload) = 0; - virtual bool supportsTools() { return false; }; + virtual bool supportsTools() const { return false; }; + + virtual void cancelRequest(const RequestID &requestId); HttpClient *httpClient() const; diff --git a/providers/ClaudeMessage.cpp b/providers/ClaudeMessage.cpp new file mode 100644 index 0000000..0f338b0 --- /dev/null +++ b/providers/ClaudeMessage.cpp @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#include "ClaudeMessage.hpp" +#include "logger/Logger.hpp" + +#include +#include + +namespace QodeAssist { + +ClaudeMessage::ClaudeMessage(QObject *parent) + : QObject(parent) +{} + +void ClaudeMessage::handleContentBlockStart( + int index, const QString &blockType, const QJsonObject &data) +{ + LOG_MESSAGE(QString("ClaudeMessage: handleContentBlockStart index=%1, blockType=%2") + .arg(index) + .arg(blockType)); + + if (blockType == "text") { + addCurrentContent(); + + } else if (blockType == "tool_use") { + QString toolId = data["id"].toString(); + QString toolName = data["name"].toString(); + QJsonObject toolInput = data["input"].toObject(); + + addCurrentContent(toolId, toolName, toolInput); + m_pendingToolInputs[index] = ""; + } +} + +void ClaudeMessage::handleContentBlockDelta( + int index, const QString &deltaType, const QJsonObject &delta) +{ + if (index >= m_currentBlocks.size()) { + return; + } + + if (deltaType == "text_delta") { + if (auto textContent = qobject_cast(m_currentBlocks[index])) { + textContent->appendText(delta["text"].toString()); + } + + } else if (deltaType == "input_json_delta") { + QString partialJson = delta["partial_json"].toString(); + if (m_pendingToolInputs.contains(index)) { + m_pendingToolInputs[index] += partialJson; + } + } +} + +void ClaudeMessage::handleContentBlockStop(int index) +{ + if (m_pendingToolInputs.contains(index)) { + QString jsonInput = m_pendingToolInputs[index]; + QJsonObject inputObject; + + if (!jsonInput.isEmpty()) { + QJsonDocument doc = QJsonDocument::fromJson(jsonInput.toUtf8()); + if (doc.isObject()) { + inputObject = doc.object(); + } + } + + if (index < m_currentBlocks.size()) { + if (auto toolContent = qobject_cast(m_currentBlocks[index])) { + toolContent->setInput(inputObject); + } + } + + m_pendingToolInputs.remove(index); + } +} + +void ClaudeMessage::handleStopReason(const QString &stopReason) +{ + m_stopReason = stopReason; + updateStateFromStopReason(); +} + +QJsonObject ClaudeMessage::toProviderFormat() const +{ + QJsonObject message; + message["role"] = "assistant"; + + QJsonArray content; + for (auto block : m_currentBlocks) { + content.append(block->toJson(LLMCore::ProviderFormat::Claude)); + } + + message["content"] = content; + return message; +} + +QJsonArray ClaudeMessage::createToolResultsContent(const QHash &toolResults) const +{ + QJsonArray results; + + for (auto toolContent : getCurrentToolUseContent()) { + if (toolResults.contains(toolContent->id())) { + auto toolResult = std::make_unique( + toolContent->id(), toolResults[toolContent->id()]); + results.append(toolResult->toJson(LLMCore::ProviderFormat::Claude)); + } + } + + return results; +} + +QList ClaudeMessage::getCurrentToolUseContent() const +{ + QList toolBlocks; + for (auto block : m_currentBlocks) { + if (auto toolContent = qobject_cast(block)) { + toolBlocks.append(toolContent); + } + } + return toolBlocks; +} + +void ClaudeMessage::startNewContinuation() +{ + LOG_MESSAGE(QString("ClaudeMessage: Starting new continuation")); + + m_currentBlocks.clear(); + m_pendingToolInputs.clear(); + m_stopReason.clear(); + m_state = LLMCore::MessageState::Building; +} + +void ClaudeMessage::updateStateFromStopReason() +{ + if (m_stopReason == "tool_use" && !getCurrentToolUseContent().empty()) { + m_state = LLMCore::MessageState::RequiresToolExecution; + } else if (m_stopReason == "end_turn") { + m_state = LLMCore::MessageState::Final; + } else { + m_state = LLMCore::MessageState::Complete; + } +} + +} // namespace QodeAssist diff --git a/providers/ClaudeMessage.hpp b/providers/ClaudeMessage.hpp new file mode 100644 index 0000000..4cf84e7 --- /dev/null +++ b/providers/ClaudeMessage.hpp @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#pragma once + +#include + +namespace QodeAssist { + +class ClaudeMessage : public QObject +{ + Q_OBJECT +public: + explicit ClaudeMessage(QObject *parent = nullptr); + + void handleContentBlockStart(int index, const QString &blockType, const QJsonObject &data); + void handleContentBlockDelta(int index, const QString &deltaType, const QJsonObject &delta); + void handleContentBlockStop(int index); + void handleStopReason(const QString &stopReason); + + QJsonObject toProviderFormat() const; + QJsonArray createToolResultsContent(const QHash &toolResults) const; + + LLMCore::MessageState state() const { return m_state; } + QList getCurrentToolUseContent() const; + + void startNewContinuation(); + +private: + QString m_stopReason; + LLMCore::MessageState m_state = LLMCore::MessageState::Building; + QList m_currentBlocks; + QHash m_pendingToolInputs; + + void updateStateFromStopReason(); + + template + T *addCurrentContent(Args &&...args) + { + T *content = new T(std::forward(args)...); + content->setParent(this); + m_currentBlocks.append(content); + return content; + } +}; + +} // namespace QodeAssist diff --git a/providers/ClaudeProvider.cpp b/providers/ClaudeProvider.cpp index 6993b5f..a7e6657 100644 --- a/providers/ClaudeProvider.cpp +++ b/providers/ClaudeProvider.cpp @@ -34,6 +34,17 @@ namespace QodeAssist::Providers { +ClaudeProvider::ClaudeProvider(QObject *parent) + : LLMCore::Provider(parent) + , m_toolsManager(new Tools::ToolsManager(this)) +{ + connect( + m_toolsManager, + &Tools::ToolsManager::toolExecutionComplete, + this, + &ClaudeProvider::onToolExecutionComplete); +} + QString ClaudeProvider::name() const { return "Claude"; @@ -86,6 +97,15 @@ void ClaudeProvider::prepareRequest( } else { applyModelParams(Settings::chatAssistantSettings()); } + + if (supportsTools() && type == LLMCore::RequestType::Chat + && Settings::chatAssistantSettings().useTools()) { + auto toolsDefinitions = m_toolsManager->getToolsDefinitions(Tools::ToolSchemaFormat::Claude); + if (!toolsDefinitions.isEmpty()) { + request["tools"] = toolsDefinitions; + LOG_MESSAGE(QString("Added %1 tools to Claude request").arg(toolsDefinitions.size())); + } + } } QList ClaudeProvider::getInstalledModels(const QString &baseUrl) @@ -146,7 +166,8 @@ QList ClaudeProvider::validateRequest(const QJsonObject &request, LLMCo {"top_p", {}}, {"top_k", {}}, {"stop", QJsonArray{}}, - {"stream", {}}}; + {"stream", {}}, + {"tools", {}}}; return LLMCore::ValidationUtils::validateRequestFields(request, templateReq); } @@ -174,8 +195,12 @@ LLMCore::ProviderID ClaudeProvider::providerID() const void ClaudeProvider::sendRequest( const LLMCore::RequestID &requestId, const QUrl &url, const QJsonObject &payload) { - m_dataBuffers[requestId].clear(); + if (!m_messages.contains(requestId)) { + m_dataBuffers[requestId].clear(); + } + m_requestUrls[requestId] = url; + m_originalRequests[requestId] = payload; QNetworkRequest networkRequest(url); prepareNetworkRequest(networkRequest); @@ -188,52 +213,30 @@ void ClaudeProvider::sendRequest( emit httpClient()->sendRequest(request); } +bool ClaudeProvider::supportsTools() const +{ + return true; +} + +void ClaudeProvider::cancelRequest(const LLMCore::RequestID &requestId) +{ + LOG_MESSAGE(QString("ClaudeProvider: Cancelling request %1").arg(requestId)); + LLMCore::Provider::cancelRequest(requestId); + cleanupRequest(requestId); +} + void ClaudeProvider::onDataReceived( const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data) { LLMCore::DataBuffers &buffers = m_dataBuffers[requestId]; QStringList lines = buffers.rawStreamBuffer.processData(data); - QString tempResponse; - bool isComplete = false; - for (const QString &line : lines) { QJsonObject responseObj = parseEventLine(line); if (responseObj.isEmpty()) continue; - QString eventType = responseObj["type"].toString(); - - if (eventType == "message_start") { - QString messageId = responseObj["message"].toObject()["id"].toString(); - LOG_MESSAGE(QString("Claude message started: %1").arg(messageId)); - - } else if (eventType == "content_block_delta") { - QJsonObject delta = responseObj["delta"].toObject(); - if (delta["type"].toString() == "text_delta") { - tempResponse += delta["text"].toString(); - } - - } else if (eventType == "message_delta") { - QJsonObject delta = responseObj["delta"].toObject(); - if (delta.contains("stop_reason")) { - isComplete = true; - QJsonObject usage = responseObj["usage"].toObject(); - LOG_MESSAGE(QString("Tokens: input=%1, output=%2") - .arg(usage["input_tokens"].toInt()) - .arg(usage["output_tokens"].toInt())); - } - } - } - - if (!tempResponse.isEmpty()) { - buffers.responseContent += tempResponse; - emit partialResponseReceived(requestId, tempResponse); - } - - if (isComplete) { - emit fullResponseReceived(requestId, buffers.responseContent); - m_dataBuffers.remove(requestId); + processStreamEvent(requestId, responseObj); } } @@ -243,17 +246,164 @@ void ClaudeProvider::onRequestFinished( if (!success) { LOG_MESSAGE(QString("ClaudeProvider request %1 failed: %2").arg(requestId, error)); emit requestFailed(requestId, error); - } else { - if (m_dataBuffers.contains(requestId)) { - const LLMCore::DataBuffers &buffers = m_dataBuffers[requestId]; - if (!buffers.responseContent.isEmpty()) { - emit fullResponseReceived(requestId, buffers.responseContent); - } + cleanupRequest(requestId); + return; + } + + if (m_messages.contains(requestId)) { + ClaudeMessage *message = m_messages[requestId]; + if (message->state() == LLMCore::MessageState::RequiresToolExecution) { + LOG_MESSAGE(QString("Waiting for tools to complete for %1").arg(requestId)); + m_dataBuffers.remove(requestId); + return; } } + if (m_dataBuffers.contains(requestId)) { + const LLMCore::DataBuffers &buffers = m_dataBuffers[requestId]; + if (!buffers.responseContent.isEmpty()) { + LOG_MESSAGE(QString("Emitting full response for %1").arg(requestId)); + emit fullResponseReceived(requestId, buffers.responseContent); + } + } + + cleanupRequest(requestId); +} + +void ClaudeProvider::onToolExecutionComplete( + const QString &requestId, const QHash &toolResults) +{ + if (!m_messages.contains(requestId) || !m_requestUrls.contains(requestId)) { + LOG_MESSAGE(QString("ERROR: Missing data for continuation request %1").arg(requestId)); + cleanupRequest(requestId); + return; + } + + LOG_MESSAGE(QString("Tool execution complete for Claude request %1").arg(requestId)); + + ClaudeMessage *message = m_messages[requestId]; + QJsonObject continuationRequest = m_originalRequests[requestId]; + QJsonArray messages = continuationRequest["messages"].toArray(); + + messages.append(message->toProviderFormat()); + + QJsonObject userMessage; + userMessage["role"] = "user"; + userMessage["content"] = message->createToolResultsContent(toolResults); + messages.append(userMessage); + + continuationRequest["messages"] = messages; + + LOG_MESSAGE(QString("Sending continuation request for %1 with %2 tool results") + .arg(requestId) + .arg(toolResults.size())); + + sendRequest(requestId, m_requestUrls[requestId], continuationRequest); +} + +void ClaudeProvider::processStreamEvent(const QString &requestId, const QJsonObject &event) +{ + QString eventType = event["type"].toString(); + + LOG_MESSAGE(QString("Processing Claude event: type=%1").arg(eventType)); + + if (eventType == "message_stop") { + return; + } + + ClaudeMessage *message = m_messages.value(requestId); + if (!message) { + if (eventType == "message_start") { + message = new ClaudeMessage(this); + m_messages[requestId] = message; + LOG_MESSAGE(QString("Created NEW ClaudeMessage for request %1").arg(requestId)); + } else { + return; + } + } + + if (eventType == "message_start") { + message->startNewContinuation(); + LOG_MESSAGE(QString("Starting NEW continuation for request %1").arg(requestId)); + + } else if (eventType == "content_block_start") { + int index = event["index"].toInt(); + QJsonObject contentBlock = event["content_block"].toObject(); + QString blockType = contentBlock["type"].toString(); + + LOG_MESSAGE( + QString("Adding new content block: type=%1, index=%2").arg(blockType).arg(index)); + + message->handleContentBlockStart(index, blockType, contentBlock); + + } else if (eventType == "content_block_delta") { + int index = event["index"].toInt(); + QJsonObject delta = event["delta"].toObject(); + QString deltaType = delta["type"].toString(); + + message->handleContentBlockDelta(index, deltaType, delta); + + if (deltaType == "text_delta") { + QString text = delta["text"].toString(); + LLMCore::DataBuffers &buffers = m_dataBuffers[requestId]; + buffers.responseContent += text; + emit partialResponseReceived(requestId, text); + } + + } else if (eventType == "content_block_stop") { + int index = event["index"].toInt(); + message->handleContentBlockStop(index); + + } else if (eventType == "message_delta") { + QJsonObject delta = event["delta"].toObject(); + if (delta.contains("stop_reason")) { + QString stopReason = delta["stop_reason"].toString(); + message->handleStopReason(stopReason); + handleMessageComplete(requestId); + } + } +} + +void ClaudeProvider::handleMessageComplete(const QString &requestId) +{ + if (!m_messages.contains(requestId)) + return; + + ClaudeMessage *message = m_messages[requestId]; + + if (message->state() == LLMCore::MessageState::RequiresToolExecution) { + LOG_MESSAGE(QString("Claude message requires tool execution for %1").arg(requestId)); + + auto toolUseContent = message->getCurrentToolUseContent(); + + if (toolUseContent.isEmpty()) { + LOG_MESSAGE(QString("No tools to execute for %1").arg(requestId)); + return; + } + + for (auto toolContent : toolUseContent) { + m_toolsManager->executeToolCall( + requestId, toolContent->id(), toolContent->name(), toolContent->input()); + } + + } else { + LOG_MESSAGE(QString("Claude message marked as complete for %1").arg(requestId)); + } +} + +void ClaudeProvider::cleanupRequest(const LLMCore::RequestID &requestId) +{ + LOG_MESSAGE(QString("Cleaning up Claude request %1").arg(requestId)); + + if (m_messages.contains(requestId)) { + ClaudeMessage *message = m_messages.take(requestId); + message->deleteLater(); + } + m_dataBuffers.remove(requestId); m_requestUrls.remove(requestId); + m_originalRequests.remove(requestId); + m_toolsManager->cleanupRequest(requestId); } } // namespace QodeAssist::Providers diff --git a/providers/ClaudeProvider.hpp b/providers/ClaudeProvider.hpp index 4a9b4ed..9ac65e5 100644 --- a/providers/ClaudeProvider.hpp +++ b/providers/ClaudeProvider.hpp @@ -21,11 +21,17 @@ #include +#include "ClaudeMessage.hpp" +#include "tools/ToolsManager.hpp" + namespace QodeAssist::Providers { class ClaudeProvider : public LLMCore::Provider { + Q_OBJECT public: + explicit ClaudeProvider(QObject *parent = nullptr); + QString name() const override; QString url() const override; QString completionEndpoint() const override; @@ -45,6 +51,9 @@ public: void sendRequest( const LLMCore::RequestID &requestId, const QUrl &url, const QJsonObject &payload) override; + bool supportsTools() const override; + void cancelRequest(const LLMCore::RequestID &requestId) override; + public slots: void onDataReceived( const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data) override; @@ -52,6 +61,20 @@ public slots: const QodeAssist::LLMCore::RequestID &requestId, bool success, const QString &error) override; + +private slots: + void onToolExecutionComplete( + const QString &requestId, const QHash &toolResults); + +private: + void processStreamEvent(const QString &requestId, const QJsonObject &event); + void handleMessageComplete(const QString &requestId); + void cleanupRequest(const LLMCore::RequestID &requestId); + + QHash m_messages; + QHash m_requestUrls; + QHash m_originalRequests; + Tools::ToolsManager *m_toolsManager; }; } // namespace QodeAssist::Providers diff --git a/settings/ChatAssistantSettings.cpp b/settings/ChatAssistantSettings.cpp index 9604342..d67718e 100644 --- a/settings/ChatAssistantSettings.cpp +++ b/settings/ChatAssistantSettings.cpp @@ -68,6 +68,15 @@ ChatAssistantSettings::ChatAssistantSettings() enableChatInNavigationPanel.setLabelText(Tr::tr("Enable chat in navigation panel")); enableChatInNavigationPanel.setDefaultValue(false); + useTools.setSettingsKey(Constants::CA_USE_TOOLS); + useTools.setLabelText(Tr::tr("Enable tools")); + useTools.setToolTip( + Tr::tr( + "Enable tool use capabilities for the assistant(OpenAI function calling, Claude tools " + "and etc) " + "if plugin and provider support")); + useTools.setDefaultValue(true); + // General Parameters Settings temperature.setSettingsKey(Constants::CA_TEMPERATURE); temperature.setLabelText(Tr::tr("Temperature:")); @@ -242,31 +251,35 @@ ChatAssistantSettings::ChatAssistantSettings() chatViewSettingsGrid.addRow({textFormat}); chatViewSettingsGrid.addRow({chatRenderer}); - return Column{Row{Stretch{1}, resetToDefaults}, - Space{8}, - Group{title(Tr::tr("Chat Settings")), - Column{Row{chatTokensThreshold, Stretch{1}}, - linkOpenFiles, - autosave, - enableChatInBottomToolBar, - enableChatInNavigationPanel}}, - Space{8}, - Group{ - title(Tr::tr("General Parameters")), - Row{genGrid, Stretch{1}}, - }, - Space{8}, - Group{title(Tr::tr("Advanced Parameters")), - Column{Row{advancedGrid, Stretch{1}}}}, - Space{8}, - Group{title(Tr::tr("Context Settings")), - Column{ - Row{useSystemPrompt, Stretch{1}}, - systemPrompt, - }}, - Group{title(Tr::tr("Ollama Settings")), Column{Row{ollamaGrid, Stretch{1}}}}, - Group{title(Tr::tr("Chat Settings")), Row{chatViewSettingsGrid, Stretch{1}}}, - Stretch{1}}; + return Column{ + Row{Stretch{1}, resetToDefaults}, + Space{8}, + Group{ + title(Tr::tr("Chat Settings")), + Column{ + Row{chatTokensThreshold, Stretch{1}}, + linkOpenFiles, + autosave, + enableChatInBottomToolBar, + enableChatInNavigationPanel, + useTools}}, + Space{8}, + Group{ + title(Tr::tr("General Parameters")), + Row{genGrid, Stretch{1}}, + }, + Space{8}, + Group{title(Tr::tr("Advanced Parameters")), Column{Row{advancedGrid, Stretch{1}}}}, + Space{8}, + Group{ + title(Tr::tr("Context Settings")), + Column{ + Row{useSystemPrompt, Stretch{1}}, + systemPrompt, + }}, + Group{title(Tr::tr("Ollama Settings")), Column{Row{ollamaGrid, Stretch{1}}}}, + Group{title(Tr::tr("Chat Settings")), Row{chatViewSettingsGrid, Stretch{1}}}, + Stretch{1}}; }); } @@ -311,6 +324,7 @@ void ChatAssistantSettings::resetSettingsToDefaults() resetAspect(codeFontSize); resetAspect(textFormat); resetAspect(chatRenderer); + resetAspect(useTools); } } diff --git a/settings/ChatAssistantSettings.hpp b/settings/ChatAssistantSettings.hpp index 984cf8d..710ad9b 100644 --- a/settings/ChatAssistantSettings.hpp +++ b/settings/ChatAssistantSettings.hpp @@ -38,6 +38,7 @@ public: Utils::BoolAspect autosave{this}; Utils::BoolAspect enableChatInBottomToolBar{this}; Utils::BoolAspect enableChatInNavigationPanel{this}; + Utils::BoolAspect useTools{this}; // General Parameters Settings Utils::DoubleAspect temperature{this}; diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index aaff040..be135e6 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -84,6 +84,7 @@ const char CC_CUSTOM_LANGUAGES[] = "QodeAssist.ccCustomLanguages"; const char CA_ENABLE_CHAT_IN_BOTTOM_TOOLBAR[] = "QodeAssist.caEnableChatInBottomToolbar"; const char CA_ENABLE_CHAT_IN_NAVIGATION_PANEL[] = "QodeAssist.caEnableChatInNavigationPanel"; +const char CA_USE_TOOLS[] = "QodeAssist.caUseTools"; const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions"; const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId"; diff --git a/tools/ListProjectFilesTool.cpp b/tools/ListProjectFilesTool.cpp index 0f085fe..733d7bd 100644 --- a/tools/ListProjectFilesTool.cpp +++ b/tools/ListProjectFilesTool.cpp @@ -32,6 +32,8 @@ namespace QodeAssist::Tools { ListProjectFilesTool::ListProjectFilesTool(QObject *parent) : BaseTool(parent) + , m_ignoreManager(new Context::IgnoreManager(this)) + {} QString ListProjectFilesTool::name() const @@ -92,14 +94,27 @@ QFuture ListProjectFilesTool::executeAsync(const QJsonObject &input) } QStringList fileList; - QString projectPath = project->projectDirectory().toString(); + QString projectPath = project->projectDirectory().toUrlishString(); for (const auto &filePath : projectFiles) { - QString absolutePath = filePath.toString(); + QString absolutePath = filePath.toUrlishString(); + + if (m_ignoreManager->shouldIgnore(absolutePath, project)) { + LOG_MESSAGE( + QString("Ignoring file due to .qodeassistignore: %1").arg(absolutePath)); + continue; + } + QString relativePath = QDir(projectPath).relativeFilePath(absolutePath); fileList.append(relativePath); } + if (fileList.isEmpty()) { + result += QString("Project '%1': No files after applying .qodeassistignore\n\n") + .arg(project->displayName()); + continue; + } + fileList.sort(); result += QString("Project '%1' (%2 files):\n") diff --git a/tools/ListProjectFilesTool.hpp b/tools/ListProjectFilesTool.hpp index 360e40c..6163dcc 100644 --- a/tools/ListProjectFilesTool.hpp +++ b/tools/ListProjectFilesTool.hpp @@ -19,7 +19,9 @@ #pragma once -#include "llmcore/BaseTool.hpp" +#include + +#include namespace QodeAssist::Tools { @@ -36,6 +38,7 @@ public: private: QString formatFileList(const QStringList &files) const; + Context::IgnoreManager *m_ignoreManager; }; } // namespace QodeAssist::Tools diff --git a/tools/ReadProjectFileByNameTool.cpp b/tools/ReadProjectFileByNameTool.cpp index 60e23a4..171d4c1 100644 --- a/tools/ReadProjectFileByNameTool.cpp +++ b/tools/ReadProjectFileByNameTool.cpp @@ -36,6 +36,7 @@ namespace QodeAssist::Tools { ReadProjectFileByNameTool::ReadProjectFileByNameTool(QObject *parent) : BaseTool(parent) + , m_ignoreManager(new Context::IgnoreManager(this)) {} QString ReadProjectFileByNameTool::name() const @@ -100,6 +101,14 @@ QFuture ReadProjectFileByNameTool::executeAsync(const QJsonObject &inpu throw std::runtime_error(error.toStdString()); } + auto project = ProjectExplorer::ProjectManager::projectForFile( + Utils::FilePath::fromString(filePath)); + if (project && m_ignoreManager->shouldIgnore(filePath, project)) { + QString error + = QString("Error: File '%1' is excluded by .qodeassistignore").arg(filename); + throw std::runtime_error(error.toStdString()); + } + QString content = readFileContent(filePath); if (content.isNull()) { QString error = QString("Error: Could not read file '%1'").arg(filePath); @@ -126,22 +135,40 @@ QString ReadProjectFileByNameTool::findFileInProject(const QString &fileName) co Utils::FilePaths projectFiles = project->files(ProjectExplorer::Project::SourceFiles); for (const auto &projectFile : std::as_const(projectFiles)) { - QFileInfo fileInfo(projectFile.path()); + QString absolutePath = projectFile.path(); + + if (m_ignoreManager->shouldIgnore(absolutePath, project)) { + continue; + } + + QFileInfo fileInfo(absolutePath); if (fileInfo.fileName() == fileName) { - return projectFile.path(); + return absolutePath; } } for (const auto &projectFile : std::as_const(projectFiles)) { + QString absolutePath = projectFile.path(); + + if (m_ignoreManager->shouldIgnore(absolutePath, project)) { + continue; + } + if (projectFile.endsWith(fileName)) { - return projectFile.path(); + return absolutePath; } } for (const auto &projectFile : std::as_const(projectFiles)) { - QFileInfo fileInfo(projectFile.path()); + QString absolutePath = projectFile.path(); + + if (m_ignoreManager->shouldIgnore(absolutePath, project)) { + continue; + } + + QFileInfo fileInfo(absolutePath); if (fileInfo.fileName().contains(fileName, Qt::CaseInsensitive)) { - return projectFile.path(); + return absolutePath; } } } diff --git a/tools/ReadProjectFileByNameTool.hpp b/tools/ReadProjectFileByNameTool.hpp index 5c591e5..c308dbe 100644 --- a/tools/ReadProjectFileByNameTool.hpp +++ b/tools/ReadProjectFileByNameTool.hpp @@ -19,7 +19,8 @@ #pragma once -#include "llmcore/BaseTool.hpp" +#include +#include namespace QodeAssist::Tools { @@ -37,6 +38,7 @@ public: private: QString findFileInProject(const QString &fileName) const; QString readFileContent(const QString &filePath) const; + Context::IgnoreManager *m_ignoreManager; }; } // namespace QodeAssist::Tools diff --git a/tools/ReadVisibleFilesTool.cpp b/tools/ReadVisibleFilesTool.cpp index 4a6bf55..42e9bea 100644 --- a/tools/ReadVisibleFilesTool.cpp +++ b/tools/ReadVisibleFilesTool.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,7 @@ namespace QodeAssist::Tools { ReadVisibleFilesTool::ReadVisibleFilesTool(QObject *parent) : BaseTool(parent) + , m_ignoreManager(new Context::IgnoreManager(this)) {} QString ReadVisibleFilesTool::name() const @@ -84,6 +86,15 @@ QFuture ReadVisibleFilesTool::executeAsync(const QJsonObject &input) } QString filePath = editor->document()->filePath().toFSPathString(); + + auto project = ProjectExplorer::ProjectManager::projectForFile( + editor->document()->filePath()); + if (project && m_ignoreManager->shouldIgnore(filePath, project)) { + LOG_MESSAGE( + QString("Ignoring visible file due to .qodeassistignore: %1").arg(filePath)); + continue; + } + QByteArray contentBytes = editor->document()->contents(); QString fileContent = QString::fromUtf8(contentBytes); @@ -98,6 +109,11 @@ QFuture ReadVisibleFilesTool::executeAsync(const QJsonObject &input) results.append(fileResult); } + if (results.isEmpty()) { + QString error = "Error: All visible files are excluded by .qodeassistignore"; + throw std::runtime_error(error.toStdString()); + } + return results.join("\n\n" + QString(80, '=') + "\n\n"); }); } diff --git a/tools/ReadVisibleFilesTool.hpp b/tools/ReadVisibleFilesTool.hpp index f471c0b..84f6671 100644 --- a/tools/ReadVisibleFilesTool.hpp +++ b/tools/ReadVisibleFilesTool.hpp @@ -19,7 +19,8 @@ #pragma once -#include "llmcore/BaseTool.hpp" +#include +#include namespace QodeAssist::Tools { @@ -33,6 +34,9 @@ public: QString description() const override; QJsonObject getDefinition(LLMCore::ToolSchemaFormat format) const override; QFuture executeAsync(const QJsonObject &input = QJsonObject()) override; + +private: + Context::IgnoreManager *m_ignoreManager; }; } // namespace QodeAssist::Tools diff --git a/tools/ToolsManager.cpp b/tools/ToolsManager.cpp new file mode 100644 index 0000000..b664da3 --- /dev/null +++ b/tools/ToolsManager.cpp @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#include "ToolsManager.hpp" +#include "logger/Logger.hpp" + +namespace QodeAssist::Tools { + +ToolsManager::ToolsManager(QObject *parent) + : QObject(parent) + , m_toolsFactory(new ToolsFactory(this)) + , m_toolHandler(new ToolHandler(this)) +{ + connect( + m_toolHandler, + &ToolHandler::toolCompleted, + this, + [this](const QString &requestId, const QString &toolId, const QString &result) { + onToolFinished(requestId, toolId, result, true); + }); + + connect( + m_toolHandler, + &ToolHandler::toolFailed, + this, + [this](const QString &requestId, const QString &toolId, const QString &error) { + onToolFinished(requestId, toolId, error, false); + }); +} + +void ToolsManager::executeToolCall( + const QString &requestId, + const QString &toolId, + const QString &toolName, + const QJsonObject &input) +{ + LOG_MESSAGE(QString("ToolsManager: Executing tool %1 (ID: %2) for request %3") + .arg(toolName, toolId, requestId)); + + if (!m_pendingTools.contains(requestId)) { + m_pendingTools[requestId] = QHash(); + } + + if (m_pendingTools[requestId].contains(toolId)) { + LOG_MESSAGE(QString("Tool %1 already in progress for request %2").arg(toolId, requestId)); + return; + } + + auto tool = m_toolsFactory->getToolByName(toolName); + if (!tool) { + LOG_MESSAGE(QString("ToolsManager: Tool not found: %1").arg(toolName)); + return; + } + + PendingTool pendingTool{toolId, toolName, input, "", false}; + m_pendingTools[requestId][toolId] = pendingTool; + + m_toolHandler->executeToolAsync(requestId, toolId, tool, input); + LOG_MESSAGE(QString("ToolsManager: Started async execution of %1").arg(toolName)); +} + +QJsonArray ToolsManager::getToolsDefinitions(ToolSchemaFormat format) const +{ + if (!m_toolsFactory) { + return QJsonArray(); + } + + LLMCore::ToolSchemaFormat coreFormat = (format == ToolSchemaFormat::OpenAI) + ? LLMCore::ToolSchemaFormat::OpenAI + : LLMCore::ToolSchemaFormat::Claude; + + return m_toolsFactory->getToolsDefinitions(coreFormat); +} + +void ToolsManager::cleanupRequest(const QString &requestId) +{ + m_pendingTools.remove(requestId); + m_toolHandler->cleanupRequest(requestId); + LOG_MESSAGE(QString("ToolsManager: Cleaned up request %1").arg(requestId)); +} + +void ToolsManager::onToolFinished( + const QString &requestId, const QString &toolId, const QString &result, bool success) +{ + if (!m_pendingTools.contains(requestId) || !m_pendingTools[requestId].contains(toolId)) { + LOG_MESSAGE(QString("ToolsManager: Tool result for unknown tool %1 in request %2") + .arg(toolId, requestId)); + return; + } + + PendingTool &tool = m_pendingTools[requestId][toolId]; + tool.result = success ? result : QString("Error: %1").arg(result); + tool.complete = true; + + LOG_MESSAGE(QString("ToolsManager: Tool %1 %2 for request %3") + .arg(toolId) + .arg(success ? QString("completed") : QString("failed")) + .arg(requestId)); + + if (isExecutionComplete(requestId)) { + QHash results = getToolResults(requestId); + LOG_MESSAGE(QString("ToolsManager: All tools complete for request %1, emitting results") + .arg(requestId)); + emit toolExecutionComplete(requestId, results); + } else { + LOG_MESSAGE(QString("ToolsManager: Tools still pending for request %1").arg(requestId)); + } +} + +bool ToolsManager::isExecutionComplete(const QString &requestId) const +{ + if (!m_pendingTools.contains(requestId)) { + return true; + } + + const auto &tools = m_pendingTools[requestId]; + for (auto it = tools.begin(); it != tools.end(); ++it) { + if (!it.value().complete) { + return false; + } + } + + return true; +} + +QHash ToolsManager::getToolResults(const QString &requestId) const +{ + QHash results; + + if (m_pendingTools.contains(requestId)) { + const auto &tools = m_pendingTools[requestId]; + for (auto it = tools.begin(); it != tools.end(); ++it) { + if (it.value().complete) { + results[it.key()] = it.value().result; + } + } + } + + return results; +} + +} // namespace QodeAssist::Tools diff --git a/tools/ToolsManager.hpp b/tools/ToolsManager.hpp new file mode 100644 index 0000000..3ebd2e4 --- /dev/null +++ b/tools/ToolsManager.hpp @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include "ToolHandler.hpp" +#include "ToolsFactory.hpp" + +namespace QodeAssist::Tools { + +enum class ToolSchemaFormat { OpenAI, Claude }; + +struct PendingTool +{ + QString id; + QString name; + QJsonObject input; + QString result; + bool complete = false; +}; + +class ToolsManager : public QObject +{ + Q_OBJECT + +public: + explicit ToolsManager(QObject *parent = nullptr); + + void executeToolCall( + const QString &requestId, + const QString &toolId, + const QString &toolName, + const QJsonObject &input); + + QJsonArray getToolsDefinitions(ToolSchemaFormat format) const; + void cleanupRequest(const QString &requestId); + +signals: + void toolExecutionComplete(const QString &requestId, const QHash &toolResults); + +private slots: + void onToolFinished( + const QString &requestId, const QString &toolId, const QString &result, bool success); + +private: + ToolsFactory *m_toolsFactory; + ToolHandler *m_toolHandler; + QHash> m_pendingTools; + + bool isExecutionComplete(const QString &requestId) const; + QHash getToolResults(const QString &requestId) const; +}; + +} // namespace QodeAssist::Tools