diff --git a/CMakeLists.txt b/CMakeLists.txt index f1956b8..c9190d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,10 +2,6 @@ cmake_minimum_required(VERSION 3.16) project(QodeAssist) -option(QODEASSIST_EXPERIMENTAL - "Enable experimental features" OFF) -message(STATUS "QodeAssist experimental features: ${QODEASSIST_EXPERIMENTAL}") - set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) @@ -42,7 +38,6 @@ add_definitions( add_subdirectory(sources) add_subdirectory(logger) -add_subdirectory(pluginllmcore) add_subdirectory(settings) add_subdirectory(UIControls) add_subdirectory(ChatView) @@ -69,7 +64,6 @@ add_qtc_plugin(QodeAssist QtCreator::Utils QtCreator::CPlusPlus LLMQore - PluginLLMCore ProvidersConfig Agents Skills @@ -83,42 +77,6 @@ add_qtc_plugin(QodeAssist QodeAssisttr.h LLMClientInterface.hpp LLMClientInterface.cpp RefactorContextHelper.hpp - templates/Templates.hpp - templates/CodeLlamaFim.hpp - templates/Ollama.hpp - templates/Claude.hpp - templates/OpenAI.hpp - templates/MistralAI.hpp - templates/StarCoder2Fim.hpp - templates/Qwen25CoderFIM.hpp - templates/OpenAICompatible.hpp - templates/Llama3.hpp - templates/ChatML.hpp - templates/Alpaca.hpp - templates/Llama2.hpp - templates/CodeLlamaQMLFim.hpp - templates/GoogleAI.hpp - templates/LlamaCppFim.hpp - templates/Qwen3CoderFIM.hpp - templates/OpenAIResponses.hpp - providers/Providers.hpp - providers/ProviderUrlUtils.hpp - providers/OllamaProvider.hpp providers/OllamaProvider.cpp - providers/OllamaCompatProvider.hpp providers/OllamaCompatProvider.cpp - providers/ClaudeProvider.hpp providers/ClaudeProvider.cpp - providers/OpenAIProvider.hpp providers/OpenAIProvider.cpp - providers/MistralAIProvider.hpp providers/MistralAIProvider.cpp - providers/LMStudioProvider.hpp providers/LMStudioProvider.cpp - providers/LMStudioResponsesProvider.hpp providers/LMStudioResponsesProvider.cpp - providers/OpenAICompatProvider.hpp providers/OpenAICompatProvider.cpp - providers/OpenRouterAIProvider.hpp providers/OpenRouterAIProvider.cpp - providers/GoogleAIProvider.hpp providers/GoogleAIProvider.cpp - providers/LlamaCppProvider.hpp providers/LlamaCppProvider.cpp - providers/CodestralProvider.hpp providers/CodestralProvider.cpp - providers/OpenAIResponsesProvider.hpp providers/OpenAIResponsesProvider.cpp - providers/QwenProvider.hpp providers/QwenProvider.cpp - providers/QwenResponsesProvider.hpp providers/QwenResponsesProvider.cpp - providers/DeepSeekProvider.hpp providers/DeepSeekProvider.cpp QodeAssist.qrc LSPCompletion.hpp LLMSuggestion.hpp LLMSuggestion.cpp @@ -130,17 +88,12 @@ add_qtc_plugin(QodeAssist chat/ChatDocument.hpp chat/ChatDocument.cpp chat/ChatEditor.hpp chat/ChatEditor.cpp chat/ChatEditorFactory.hpp chat/ChatEditorFactory.cpp - ConfigurationManager.hpp ConfigurationManager.cpp CodeHandler.hpp CodeHandler.cpp UpdateStatusWidget.hpp UpdateStatusWidget.cpp widgets/CompletionProgressHandler.hpp widgets/CompletionProgressHandler.cpp widgets/CompletionErrorHandler.hpp widgets/CompletionErrorHandler.cpp - widgets/CompletionHintWidget.hpp widgets/CompletionHintWidget.cpp - widgets/CompletionHintHandler.hpp widgets/CompletionHintHandler.cpp widgets/ProgressWidget.hpp widgets/ProgressWidget.cpp widgets/ErrorWidget.hpp widgets/ErrorWidget.cpp - widgets/EditorChatButton.hpp widgets/EditorChatButton.cpp - widgets/EditorChatButtonHandler.hpp widgets/EditorChatButtonHandler.cpp widgets/QuickRefactorDialog.hpp widgets/QuickRefactorDialog.cpp widgets/CustomInstructionsManager.hpp widgets/CustomInstructionsManager.cpp widgets/AddCustomInstructionDialog.hpp widgets/AddCustomInstructionDialog.cpp @@ -170,10 +123,7 @@ add_qtc_plugin(QodeAssist settings/McpClientsListAspect.hpp settings/McpClientsListAspect.cpp ) -if(QODEASSIST_EXPERIMENTAL) - target_compile_definitions(QodeAssist PRIVATE QODEASSIST_EXPERIMENTAL) - target_link_libraries(QodeAssist PRIVATE QodeAssistAgentPipelines) -endif() +target_link_libraries(QodeAssist PRIVATE QodeAssistAgentPipelines Session) get_target_property(QtCreatorCorePath QtCreator::Core LOCATION) find_program(QtCreatorExecutable diff --git a/ChatView/AgentRoleController.cpp b/ChatView/AgentRoleController.cpp deleted file mode 100644 index af21f53..0000000 --- a/ChatView/AgentRoleController.cpp +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "AgentRoleController.hpp" - -#include - -#include "AgentRole.hpp" -#include "ChatAssistantSettings.hpp" -#include "GeneralSettings.hpp" - -namespace QodeAssist::Chat { - -AgentRoleController::AgentRoleController(QObject *parent) - : QObject(parent) -{ - connect( - &Settings::chatAssistantSettings().systemPrompt, - &Utils::BaseAspect::changed, - this, - &AgentRoleController::baseSystemPromptChanged); - - loadAvailableRoles(); -} - -QStringList AgentRoleController::availableRoles() const -{ - return m_availableRoles; -} - -QString AgentRoleController::currentRole() const -{ - return m_currentRole; -} - -QString AgentRoleController::baseSystemPrompt() const -{ - return Settings::chatAssistantSettings().systemPrompt(); -} - -QString AgentRoleController::currentRoleDescription() const -{ - const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId(); - if (lastRoleId.isEmpty()) - return Settings::AgentRolesManager::getNoRole().description; - - const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId); - if (role.id.isEmpty()) - return Settings::AgentRolesManager::getNoRole().description; - - return role.description; -} - -QString AgentRoleController::currentRoleSystemPrompt() const -{ - const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId(); - if (lastRoleId.isEmpty()) - return QString(); - - const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId); - if (role.id.isEmpty()) - return QString(); - - return role.systemPrompt; -} - -void AgentRoleController::loadAvailableRoles() -{ - const QList roles = Settings::AgentRolesManager::loadAllRoles(); - - m_availableRoles.clear(); - m_availableRoles.append(Settings::AgentRolesManager::getNoRole().name); - - for (const auto &role : roles) - m_availableRoles.append(role.name); - - const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId(); - m_currentRole = Settings::AgentRolesManager::getNoRole().name; - - if (!lastRoleId.isEmpty()) { - for (const auto &role : roles) { - if (role.id == lastRoleId) { - m_currentRole = role.name; - break; - } - } - } - - emit availableRolesChanged(); - emit currentRoleChanged(); -} - -void AgentRoleController::applyRole(const QString &roleName) -{ - auto &settings = Settings::chatAssistantSettings(); - - if (roleName == Settings::AgentRolesManager::getNoRole().name) { - settings.lastUsedRoleId.setValue(""); - settings.writeSettings(); - m_currentRole = roleName; - emit currentRoleChanged(); - return; - } - - const QList roles = Settings::AgentRolesManager::loadAllRoles(); - - for (const auto &role : roles) { - if (role.name == roleName) { - settings.lastUsedRoleId.setValue(role.id); - settings.writeSettings(); - m_currentRole = role.name; - emit currentRoleChanged(); - break; - } - } -} - -void AgentRoleController::openSettings() -{ - Settings::showSettings(Utils::Id("QodeAssist.AgentRoles")); -} - -} // namespace QodeAssist::Chat diff --git a/ChatView/AgentRoleController.hpp b/ChatView/AgentRoleController.hpp deleted file mode 100644 index 7eefe67..0000000 --- a/ChatView/AgentRoleController.hpp +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Chat { - -class AgentRoleController : public QObject -{ - Q_OBJECT - -public: - explicit AgentRoleController(QObject *parent = nullptr); - - QStringList availableRoles() const; - QString currentRole() const; - QString baseSystemPrompt() const; - QString currentRoleDescription() const; - QString currentRoleSystemPrompt() const; - - void loadAvailableRoles(); - void applyRole(const QString &roleName); - void openSettings(); - -signals: - void availableRolesChanged(); - void currentRoleChanged(); - void baseSystemPromptChanged(); - -private: - QStringList m_availableRoles; - QString m_currentRole; -}; - -} // namespace QodeAssist::Chat diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index b913035..3045938 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -22,7 +22,6 @@ qt_add_qml_module(QodeAssistChatView qml/controls/BottomBar.qml qml/controls/FileMentionPopup.qml qml/controls/FileEditsActionBar.qml - qml/controls/ContextViewer.qml qml/controls/SkillCommandPopup.qml qml/controls/Toast.qml qml/controls/TopBar.qml @@ -47,8 +46,6 @@ qt_add_qml_module(QodeAssistChatView icons/chat-pause-icon.svg icons/warning-icon.svg icons/new-chat-icon.svg - icons/rules-icon.svg - icons/context-icon.svg icons/open-in-editor.svg icons/open-in-window.svg icons/apply-changes-button.svg @@ -75,8 +72,7 @@ qt_add_qml_module(QodeAssistChatView FileItem.hpp FileItem.cpp ChatFileManager.hpp ChatFileManager.cpp ChatCompressor.hpp ChatCompressor.cpp - AgentRoleController.hpp AgentRoleController.cpp - ChatConfigurationController.hpp ChatConfigurationController.cpp + ChatAgentController.hpp ChatAgentController.cpp FileEditController.hpp FileEditController.cpp InputTokenCounter.hpp InputTokenCounter.cpp ChatHistoryStore.hpp ChatHistoryStore.cpp @@ -92,13 +88,14 @@ target_link_libraries(QodeAssistChatView Qt::Network QtCreator::Core QtCreator::Utils - PluginLLMCore QodeAssistSettings Context QodeAssistUIControlsplugin QodeAssistLogger LLMQore Skills + Agents + Session ) target_include_directories(QodeAssistChatView diff --git a/ChatView/ChatAgentController.cpp b/ChatView/ChatAgentController.cpp new file mode 100644 index 0000000..ac11f38 --- /dev/null +++ b/ChatView/ChatAgentController.cpp @@ -0,0 +1,107 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ChatAgentController.hpp" + +#include + +#include + +#include +#include +#include + +namespace QodeAssist::Chat { + +namespace { +const char kChatAgentKey[] = "QodeAssist.chatActiveAgent"; +} + +ChatAgentController::ChatAgentController(QObject *parent) + : QObject(parent) +{ + if (auto *settings = Core::ICore::settings()) + m_currentAgent = settings->value(kChatAgentKey).toString(); +} + +void ChatAgentController::setAgentFactory(AgentFactory *factory) +{ + m_agentFactory = factory; + if (factory) + connect( + factory, + &AgentFactory::agentsChanged, + this, + &ChatAgentController::reload, + Qt::UniqueConnection); + reload(); +} + +QStringList ChatAgentController::availableAgents() const +{ + return m_availableAgents; +} + +QString ChatAgentController::currentAgent() const +{ + return m_currentAgent; +} + +void ChatAgentController::setCurrentAgent(const QString &name) +{ + if (name == m_currentAgent || !m_availableAgents.contains(name)) + return; + + applyCurrentAgent(name); +} + +void ChatAgentController::reload() +{ + if (!m_agentFactory) + return; + + const QStringList all = m_agentFactory->configNames(); + const QStringList roster = Settings::PipelinesConfig::load().rosters.chatAssistant; + + QStringList filtered; + for (const QString &name : roster) { + if (all.contains(name)) + filtered.append(name); + } + + if (filtered != m_availableAgents) { + m_availableAgents = filtered; + emit availableAgentsChanged(); + } + ensureValidCurrent(); +} + +void ChatAgentController::ensureValidCurrent() +{ + if (m_availableAgents.contains(m_currentAgent)) + return; + + const QString next = m_availableAgents.isEmpty() ? QString() : m_availableAgents.first(); + if (next == m_currentAgent) + return; + + applyCurrentAgent(next); +} + +void ChatAgentController::applyCurrentAgent(const QString &name) +{ + m_currentAgent = name; + if (auto *settings = Core::ICore::settings()) + settings->setValue(kChatAgentKey, m_currentAgent); + emit currentAgentChanged(); +} + +bool ChatAgentController::currentSupportsTools() const +{ + if (!m_agentFactory || m_currentAgent.isEmpty()) + return false; + const AgentConfig *config = m_agentFactory->configByName(m_currentAgent); + return config && config->enableTools; +} + +} // namespace QodeAssist::Chat diff --git a/ChatView/ChatAgentController.hpp b/ChatView/ChatAgentController.hpp new file mode 100644 index 0000000..b83dc55 --- /dev/null +++ b/ChatView/ChatAgentController.hpp @@ -0,0 +1,47 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +namespace QodeAssist { +class AgentFactory; +} + +namespace QodeAssist::Chat { + +class ChatAgentController : public QObject +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(ChatAgentController) + +public: + explicit ChatAgentController(QObject *parent = nullptr); + + void setAgentFactory(AgentFactory *factory); + + [[nodiscard]] QStringList availableAgents() const; + [[nodiscard]] QString currentAgent() const; + void setCurrentAgent(const QString &name); + + [[nodiscard]] bool currentSupportsTools() const; + +signals: + void availableAgentsChanged(); + void currentAgentChanged(); + +private: + void reload(); + void ensureValidCurrent(); + void applyCurrentAgent(const QString &name); + + QPointer m_agentFactory; + QStringList m_availableAgents; + QString m_currentAgent; +}; + +} // namespace QodeAssist::Chat diff --git a/ChatView/ChatCompressor.cpp b/ChatView/ChatCompressor.cpp index e655b33..40f50cd 100644 --- a/ChatView/ChatCompressor.cpp +++ b/ChatView/ChatCompressor.cpp @@ -4,19 +4,31 @@ #include "ChatCompressor.hpp" +#include + #include -#include "ChatModel.hpp" +#include + +#include +#include + #include "GeneralSettings.hpp" -#include "PromptTemplateManager.hpp" -#include "ProvidersManager.hpp" #include "logger/Logger.hpp" +#include +#include +#include +#include +#include +#include + #include #include #include #include #include #include +#include #include namespace QodeAssist::Chat { @@ -25,7 +37,18 @@ ChatCompressor::ChatCompressor(QObject *parent) : QObject(parent) {} -void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *chatModel) +void ChatCompressor::setSessionManager(SessionManager *sessionManager) +{ + m_sessionManager = sessionManager; +} + +void ChatCompressor::setActiveAgent(const QString &agentName) +{ + m_activeAgent = agentName; +} + +void ChatCompressor::startCompression( + const QString &chatFilePath, ConversationHistory *sourceHistory) { if (m_isCompressing) { emit compressionFailed(tr("Compression already in progress")); @@ -37,49 +60,84 @@ void ChatCompressor::startCompression(const QString &chatFilePath, ChatModel *ch return; } - if (!chatModel || chatModel->rowCount() == 0) { + if (!sourceHistory || sourceHistory->isEmpty()) { emit compressionFailed(tr("Chat is empty, nothing to compress")); return; } - auto providerName = Settings::generalSettings().caProvider(); - m_provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName); - - if (!m_provider) { - emit compressionFailed(tr("No provider available")); + if (!m_sessionManager) { + emit compressionFailed(tr("Chat session manager is not available")); return; } - auto templateName = Settings::generalSettings().caTemplate(); - auto promptTemplate = PluginLLMCore::PromptTemplateManager::instance().getChatTemplateByName( - templateName); - - if (!promptTemplate) { - emit compressionFailed(tr("No template available")); + QString sessionError; + Session *session = m_sessionManager->acquire(m_activeAgent, &sessionError); + if (!session) { + emit compressionFailed( + sessionError.isEmpty() ? tr("No chat agent selected") : sessionError); return; } + auto *client = session->client(); + if (!client) { + m_sessionManager->removeSession(session); + emit compressionFailed(tr("Chat agent has no live client")); + return; + } + + auto *project = ProjectExplorer::ProjectManager::startupProject(); + Templates::ContextRenderer::Bindings bindings; + bindings.projectDir = project ? project->projectDirectory().toFSPathString() : QString(); + bindings.configDir = AgentFactory::userConfigDir(); + session->setContextBindings(bindings); + m_isCompressing = true; - m_chatModel = chatModel; m_originalChatPath = chatFilePath; - m_accumulatedSummary.clear(); + m_session = session; emit compressionStarted(); - connectProviderSignals(); + QStringList transcriptParts; + for (const auto &msg : sourceHistory->messages()) { + if (msg.role() != Message::Role::User && msg.role() != Message::Role::Assistant) + continue; + const QString text = msg.text(); + if (text.trimmed().isEmpty()) + continue; - QJsonObject payload{ - {"model", Settings::generalSettings().caModel()}, {"stream", true}}; + const QString role = msg.role() == Message::Role::User ? QStringLiteral("User") + : QStringLiteral("Assistant"); + transcriptParts.append(QStringLiteral("%1: %2").arg(role, text)); + } - buildRequestPayload(payload, promptTemplate); + if (transcriptParts.isEmpty()) { + handleCompressionError(tr("Chat is empty, nothing to compress")); + return; + } - const QString customEndpoint = Settings::generalSettings().caCustomEndpoint(); - const QString endpoint = !customEndpoint.isEmpty() ? customEndpoint - : promptTemplate->endpoint(); - m_provider->client()->setTransferTimeout( + const QString transcript = transcriptParts.join(QStringLiteral("\n\n")); + + connect( + session, &Session::finished, this, + [this](const LLMQore::RequestID &id, const QString &) { onCompressionFinished(id); }); + connect( + session, &Session::failed, this, + [this](const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error) { + onCompressionFailed(id, error.message); + }); + + client->setTransferTimeout( static_cast(Settings::generalSettings().requestTimeout() * 1000)); - m_currentRequestId = m_provider->sendRequest( - QUrl(Settings::generalSettings().caUrl()), payload, endpoint); + + std::vector> blocks; + blocks.push_back(std::make_unique(transcript)); + + m_currentRequestId = session->send(std::move(blocks)); + if (m_currentRequestId.isEmpty()) { + handleCompressionError(tr("Failed to start compression request: %1") + .arg(session->lastError().message)); + return; + } LOG_MESSAGE(QString("Starting compression request: %1").arg(m_currentRequestId)); } @@ -94,44 +152,38 @@ void ChatCompressor::cancelCompression() return; LOG_MESSAGE("Cancelling compression request"); - - if (m_provider && !m_currentRequestId.isEmpty()) - m_provider->cancelRequest(m_currentRequestId); - cleanupState(); emit compressionFailed(tr("Compression cancelled")); } -void ChatCompressor::onPartialResponseReceived(const QString &requestId, const QString &partialText) +void ChatCompressor::onCompressionFinished(const QString &requestId) { if (!m_isCompressing || requestId != m_currentRequestId) return; - m_accumulatedSummary += partialText; -} + QString summary; + if (m_session) { + if (auto *history = m_session->history(); history && !history->isEmpty()) + summary = history->messages().back().text(); + } -void ChatCompressor::onFullResponseReceived(const QString &requestId, const QString &fullText) -{ - Q_UNUSED(fullText) + LOG_MESSAGE(QString("Received summary, length: %1 characters").arg(summary.length())); - if (!m_isCompressing || requestId != m_currentRequestId) - return; + const QString compressedPath = createCompressedChatPath(m_originalChatPath); + const QString sourcePath = m_originalChatPath; - LOG_MESSAGE( - QString("Received summary, length: %1 characters").arg(m_accumulatedSummary.length())); + cleanupState(); - QString compressedPath = createCompressedChatPath(m_originalChatPath); - if (!createCompressedChatFile(m_originalChatPath, compressedPath, m_accumulatedSummary)) { - handleCompressionError(tr("Failed to save compressed chat")); + if (!createCompressedChatFile(sourcePath, compressedPath, summary)) { + emit compressionFailed(tr("Failed to save compressed chat")); return; } LOG_MESSAGE(QString("Compression completed: %1").arg(compressedPath)); - cleanupState(); emit compressionCompleted(compressedPath); } -void ChatCompressor::onRequestFailed(const QString &requestId, const QString &error) +void ChatCompressor::onCompressionFailed(const QString &requestId, const QString &error) { if (!m_isCompressing || requestId != m_currentRequestId) return; @@ -154,53 +206,6 @@ QString ChatCompressor::createCompressedChatPath(const QString &originalPath) co .arg(fileInfo.absolutePath(), fileInfo.completeBaseName(), hash, fileInfo.suffix()); } -QString ChatCompressor::buildCompressionPrompt() const -{ - return QStringLiteral( - "Please create a comprehensive summary of our entire conversation above. " - "The summary should:\n" - "1. Preserve all important context, decisions, and key information\n" - "2. Maintain technical details, code snippets, file references, and specific examples\n" - "3. Keep the chronological flow of the discussion\n" - "4. Be significantly shorter than the original (aim for 30-40% of original length)\n" - "5. Be written in clear, structured format\n" - "6. Use markdown formatting for better readability\n\n" - "Create the summary now:"); -} - -void ChatCompressor::buildRequestPayload( - QJsonObject &payload, PluginLLMCore::PromptTemplate *promptTemplate) -{ - PluginLLMCore::ContextData context; - - context.systemPrompt = QStringLiteral( - "You are a helpful assistant that creates concise summaries of conversations. " - "Your summaries preserve key information, technical details, and the flow of discussion."); - - QVector messages; - for (const auto &msg : m_chatModel->getChatHistory()) { - if (msg.role == ChatModel::ChatRole::Tool - || msg.role == ChatModel::ChatRole::FileEdit - || msg.role == ChatModel::ChatRole::Thinking) - continue; - - PluginLLMCore::Message apiMessage; - apiMessage.role = (msg.role == ChatModel::ChatRole::User) ? "user" : "assistant"; - apiMessage.content = msg.content; - messages.append(apiMessage); - } - - PluginLLMCore::Message compressionRequest; - compressionRequest.role = "user"; - compressionRequest.content = buildCompressionPrompt(); - messages.append(compressionRequest); - - context.history = messages; - - m_provider->prepareRequest( - payload, promptTemplate, context, PluginLLMCore::RequestType::Chat, false, false); -} - bool ChatCompressor::createCompressedChatFile( const QString &sourcePath, const QString &destPath, const QString &summary) { @@ -224,11 +229,11 @@ bool ChatCompressor::createCompressedChatFile( QJsonObject summaryMessage; summaryMessage["role"] = "assistant"; - summaryMessage["content"] = QString("# Chat Summary\n\n%1").arg(summary); summaryMessage["id"] = QUuid::createUuid().toString(QUuid::WithoutBraces); - summaryMessage["isRedacted"] = false; - summaryMessage["attachments"] = QJsonArray(); - summaryMessage["images"] = QJsonArray(); + QJsonObject textBlock; + textBlock["type"] = "text"; + textBlock["text"] = QString("# Chat Summary\n\n%1").arg(summary); + summaryMessage["blocks"] = QJsonArray{textBlock}; root["messages"] = QJsonArray{summaryMessage}; root["compressedFrom"] = sourcePath; @@ -247,49 +252,17 @@ bool ChatCompressor::createCompressedChatFile( return true; } -void ChatCompressor::connectProviderSignals() -{ - auto *c = m_provider->client(); - - m_connections.append(connect( - c, - &::LLMQore::BaseClient::chunkReceived, - this, - &ChatCompressor::onPartialResponseReceived, - Qt::UniqueConnection)); - - m_connections.append(connect( - c, - &::LLMQore::BaseClient::requestCompleted, - this, - &ChatCompressor::onFullResponseReceived, - Qt::UniqueConnection)); - - m_connections.append(connect( - c, - &::LLMQore::BaseClient::requestFailed, - this, - &ChatCompressor::onRequestFailed, - Qt::UniqueConnection)); -} - -void ChatCompressor::disconnectAllSignals() -{ - for (const auto &connection : std::as_const(m_connections)) - disconnect(connection); - m_connections.clear(); -} - void ChatCompressor::cleanupState() { - disconnectAllSignals(); + Session *session = m_session; m_isCompressing = false; m_currentRequestId.clear(); m_originalChatPath.clear(); - m_accumulatedSummary.clear(); - m_chatModel = nullptr; - m_provider = nullptr; + m_session = nullptr; + + if (session && m_sessionManager) + m_sessionManager->release(session); } } // namespace QodeAssist::Chat diff --git a/ChatView/ChatCompressor.hpp b/ChatView/ChatCompressor.hpp index d9587f9..bedfda1 100644 --- a/ChatView/ChatCompressor.hpp +++ b/ChatView/ChatCompressor.hpp @@ -4,20 +4,19 @@ #pragma once -#include #include #include +#include #include -namespace QodeAssist::PluginLLMCore { -class Provider; -class PromptTemplate; -} // namespace QodeAssist::PluginLLMCore +namespace QodeAssist { +class SessionManager; +class Session; +class ConversationHistory; +} namespace QodeAssist::Chat { -class ChatModel; - class ChatCompressor : public QObject { Q_OBJECT @@ -25,7 +24,10 @@ class ChatCompressor : public QObject public: explicit ChatCompressor(QObject *parent = nullptr); - void startCompression(const QString &chatFilePath, ChatModel *chatModel); + void setSessionManager(SessionManager *sessionManager); + void setActiveAgent(const QString &agentName); + + void startCompression(const QString &chatFilePath, ConversationHistory *sourceHistory); bool isCompressing() const; void cancelCompression(); @@ -35,30 +37,22 @@ signals: void compressionCompleted(const QString &compressedChatPath); void compressionFailed(const QString &error); -private slots: - void onPartialResponseReceived(const QString &requestId, const QString &partialText); - void onFullResponseReceived(const QString &requestId, const QString &fullText); - void onRequestFailed(const QString &requestId, const QString &error); - private: + void onCompressionFinished(const QString &requestId); + void onCompressionFailed(const QString &requestId, const QString &error); + QString createCompressedChatPath(const QString &originalPath) const; - QString buildCompressionPrompt() const; bool createCompressedChatFile( const QString &sourcePath, const QString &destPath, const QString &summary); - void connectProviderSignals(); - void disconnectAllSignals(); void cleanupState(); void handleCompressionError(const QString &error); - void buildRequestPayload(QJsonObject &payload, PluginLLMCore::PromptTemplate *promptTemplate); bool m_isCompressing = false; QString m_currentRequestId; QString m_originalChatPath; - QString m_accumulatedSummary; - PluginLLMCore::Provider *m_provider = nullptr; - ChatModel *m_chatModel = nullptr; - - QList m_connections; + QPointer m_sessionManager; + QString m_activeAgent; + QPointer m_session; }; } // namespace QodeAssist::Chat diff --git a/ChatView/ChatConfigurationController.cpp b/ChatView/ChatConfigurationController.cpp deleted file mode 100644 index fd4673c..0000000 --- a/ChatView/ChatConfigurationController.cpp +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "ChatConfigurationController.hpp" - -#include - -#include "ConfigurationManager.hpp" -#include "GeneralSettings.hpp" - -namespace QodeAssist::Chat { - -ChatConfigurationController::ChatConfigurationController(QObject *parent) - : QObject(parent) -{ - auto &settings = Settings::generalSettings(); - connect( - &settings.caProvider, - &Utils::BaseAspect::changed, - this, - &ChatConfigurationController::updateCurrentConfiguration); - connect( - &settings.caModel, - &Utils::BaseAspect::changed, - this, - &ChatConfigurationController::updateCurrentConfiguration); - - loadAvailableConfigurations(); -} - -QStringList ChatConfigurationController::availableConfigurations() const -{ - return m_availableConfigurations; -} - -QString ChatConfigurationController::currentConfiguration() const -{ - return m_currentConfiguration; -} - -void ChatConfigurationController::updateCurrentConfiguration() -{ - auto &settings = Settings::generalSettings(); - m_currentConfiguration - = QString("%1 - %2").arg(settings.caProvider.value(), settings.caModel.value()); - emit currentConfigurationChanged(); -} - -void ChatConfigurationController::loadAvailableConfigurations() -{ - auto &manager = Settings::ConfigurationManager::instance(); - manager.loadConfigurations(Settings::ConfigurationType::Chat); - - QVector configs = manager.configurations( - Settings::ConfigurationType::Chat); - - m_availableConfigurations.clear(); - m_availableConfigurations.append(QObject::tr("Current Settings")); - - for (const Settings::AIConfiguration &config : configs) { - m_availableConfigurations.append(config.name); - } - - updateCurrentConfiguration(); - - emit availableConfigurationsChanged(); -} - -void ChatConfigurationController::applyConfiguration(const QString &configName) -{ - if (configName == QObject::tr("Current Settings")) { - return; - } - - auto &manager = Settings::ConfigurationManager::instance(); - QVector configs = manager.configurations( - Settings::ConfigurationType::Chat); - - for (const Settings::AIConfiguration &config : configs) { - if (config.name == configName) { - auto &settings = Settings::generalSettings(); - - settings.caProvider.setValue(config.provider); - settings.caModel.setValue(config.model); - settings.caTemplate.setValue(config.templateName); - settings.caUrl.setValue(config.url); - settings.caCustomEndpoint.setValue(config.customEndpoint); - - settings.writeSettings(); - - m_currentConfiguration = QString("%1 - %2").arg(config.provider, config.model); - emit currentConfigurationChanged(); - - break; - } - } -} - -} // namespace QodeAssist::Chat diff --git a/ChatView/ChatConfigurationController.hpp b/ChatView/ChatConfigurationController.hpp deleted file mode 100644 index bb4ec1e..0000000 --- a/ChatView/ChatConfigurationController.hpp +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Chat { - -class ChatConfigurationController : public QObject -{ - Q_OBJECT - -public: - explicit ChatConfigurationController(QObject *parent = nullptr); - - QStringList availableConfigurations() const; - QString currentConfiguration() const; - - void loadAvailableConfigurations(); - void applyConfiguration(const QString &configName); - -signals: - void availableConfigurationsChanged(); - void currentConfigurationChanged(); - -private: - void updateCurrentConfiguration(); - - QStringList m_availableConfigurations; - QString m_currentConfiguration; -}; - -} // namespace QodeAssist::Chat diff --git a/ChatView/ChatHistoryStore.cpp b/ChatView/ChatHistoryStore.cpp index 6c5332c..c23909e 100644 --- a/ChatView/ChatHistoryStore.cpp +++ b/ChatView/ChatHistoryStore.cpp @@ -16,15 +16,20 @@ #include #include -#include "ChatModel.hpp" +#include +#include +#include + +#include + #include "Logger.hpp" #include "ProjectSettings.hpp" namespace QodeAssist::Chat { -ChatHistoryStore::ChatHistoryStore(ChatModel *chatModel, QObject *parent) +ChatHistoryStore::ChatHistoryStore(ConversationHistory *history, QObject *parent) : QObject(parent) - , m_chatModel(chatModel) + , m_history(history) {} QString ChatHistoryStore::historyDir() const @@ -52,17 +57,23 @@ QString ChatHistoryStore::suggestedFileName() const { QString shortMessage; - if (m_chatModel->rowCount() > 0) { - QString firstMessage - = m_chatModel->data(m_chatModel->index(0), ChatModel::Content).toString(); - shortMessage = firstMessage.split('\n').first().simplified().left(30); + if (m_history) { + for (const auto &message : m_history->messages()) { + if (message.role() != Message::Role::User) + continue; - if (shortMessage.isEmpty()) { - QVariantList images - = m_chatModel->data(m_chatModel->index(0), ChatModel::Images).toList(); - if (!images.isEmpty()) { - shortMessage = "image_chat"; + const QString text = message.text(); + if (!text.trimmed().isEmpty()) { + shortMessage = text.split('\n').first().simplified().left(30); + } else { + for (const auto &block : message.blocks()) { + if (dynamic_cast(block.get())) { + shortMessage = "image_chat"; + break; + } + } } + break; } } @@ -107,12 +118,12 @@ QString ChatHistoryStore::autosaveFilePath( SerializationResult ChatHistoryStore::save(const QString &filePath) const { - return ChatSerializer::saveToFile(m_chatModel, filePath); + return ChatSerializer::saveToFile(m_history, filePath); } SerializationResult ChatHistoryStore::load(const QString &filePath) const { - return ChatSerializer::loadFromFile(m_chatModel, filePath); + return ChatSerializer::loadFromFile(m_history, filePath); } void ChatHistoryStore::showSaveDialog() diff --git a/ChatView/ChatHistoryStore.hpp b/ChatView/ChatHistoryStore.hpp index 0de31ed..8e00a92 100644 --- a/ChatView/ChatHistoryStore.hpp +++ b/ChatView/ChatHistoryStore.hpp @@ -9,16 +9,18 @@ #include "ChatSerializer.hpp" -namespace QodeAssist::Chat { +namespace QodeAssist { +class ConversationHistory; +} -class ChatModel; +namespace QodeAssist::Chat { class ChatHistoryStore : public QObject { Q_OBJECT public: - explicit ChatHistoryStore(ChatModel *chatModel, QObject *parent = nullptr); + explicit ChatHistoryStore(ConversationHistory *history, QObject *parent = nullptr); QString historyDir() const; QString suggestedFileName() const; @@ -42,7 +44,7 @@ signals: private: QString generateChatFileName(const QString &shortMessage, const QString &dir) const; - ChatModel *m_chatModel; + ConversationHistory *m_history; }; } // namespace QodeAssist::Chat diff --git a/ChatView/ChatModel.cpp b/ChatView/ChatModel.cpp index d5973fa..fe50776 100644 --- a/ChatView/ChatModel.cpp +++ b/ChatView/ChatModel.cpp @@ -3,115 +3,159 @@ // Additional attribution terms under GPLv3 §7(b) apply — see LICENSE #include "ChatModel.hpp" -#include -#include + #include #include #include -#include +#include #include -#include -#include "Logger.hpp" +#include + +#include +#include +#include + #include "context/ChangesManager.h" namespace QodeAssist::Chat { +namespace { + +const QString kFileEditMarker = QStringLiteral("QODEASSIST_FILE_EDIT:"); + +QString changesStatusToString(Context::ChangesManager::FileEditStatus status) +{ + switch (status) { + case Context::ChangesManager::Pending: return QStringLiteral("pending"); + case Context::ChangesManager::Applied: return QStringLiteral("applied"); + case Context::ChangesManager::Rejected: return QStringLiteral("rejected"); + case Context::ChangesManager::Archived: return QStringLiteral("archived"); + } + return QStringLiteral("pending"); +} + +QString parseEditId(const QString &markerContent) +{ + const int pos = markerContent.indexOf(kFileEditMarker); + if (pos < 0) + return {}; + const QString jsonStr = markerContent.mid(pos + kFileEditMarker.length()); + const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); + if (!doc.isObject()) + return {}; + return doc.object().value(QStringLiteral("edit_id")).toString(); +} + +QString collectText(const Message &m) +{ + QString text; + for (const auto &block : m.blocks()) { + if (auto *t = dynamic_cast(block.get())) { + if (!text.isEmpty()) + text += QLatin1Char('\n'); + text += t->text(); + } + } + return text; +} + +bool messageIsToolResultsOnly(const Message &m) +{ + bool hasToolResult = false; + for (const auto &block : m.blocks()) { + if (dynamic_cast(block.get())) + hasToolResult = true; + else + return false; + } + return hasToolResult; +} + +} // namespace + ChatModel::ChatModel(QObject *parent) : QAbstractListModel(parent) { - connect(&Context::ChangesManager::instance(), - &Context::ChangesManager::fileEditApplied, - this, - &ChatModel::onFileEditApplied); - - connect(&Context::ChangesManager::instance(), - &Context::ChangesManager::fileEditRejected, - this, - &ChatModel::onFileEditRejected); - - connect(&Context::ChangesManager::instance(), - &Context::ChangesManager::fileEditArchived, - this, - &ChatModel::onFileEditArchived); + auto &changes = Context::ChangesManager::instance(); + connect( + &changes, &Context::ChangesManager::fileEditApplied, + this, &ChatModel::onFileEditStatusChanged); + connect( + &changes, &Context::ChangesManager::fileEditRejected, + this, &ChatModel::onFileEditStatusChanged); + connect( + &changes, &Context::ChangesManager::fileEditUndone, + this, &ChatModel::onFileEditStatusChanged); + connect( + &changes, &Context::ChangesManager::fileEditArchived, + this, &ChatModel::onFileEditStatusChanged); +} + +void ChatModel::setHistory(ConversationHistory *history) +{ + if (m_history == history) + return; + + if (m_history) + m_history->disconnect(this); + + m_history = history; + + if (m_history) { + connect( + m_history, &ConversationHistory::messageAdded, + this, &ChatModel::onHistoryMessageAdded); + connect( + m_history, &ConversationHistory::messageUpdated, + this, &ChatModel::onHistoryMessageUpdated); + connect(m_history, &ConversationHistory::cleared, this, &ChatModel::onHistoryCleared); + connect(m_history, &ConversationHistory::reset, this, &ChatModel::onHistoryReset); + } + + beginResetModel(); + rebuildAll(); + endResetModel(); + emit sessionUsageChanged(); } int ChatModel::rowCount(const QModelIndex &parent) const { - return m_messages.size(); + if (parent.isValid()) + return 0; + return m_rows.size(); } QVariant ChatModel::data(const QModelIndex &index, int role) const { - if (!index.isValid() || index.row() >= m_messages.size()) + if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) return QVariant(); - const Message &message = m_messages[index.row()]; + const Row &row = m_rows[index.row()]; switch (static_cast(role)) { case Roles::RoleType: - return QVariant::fromValue(message.role); - case Roles::Content: { - return message.content; - } - case Roles::Attachments: { - QVariantList attachmentsList; - for (const auto &attachment : message.attachments) { - QVariantMap attachmentMap; - attachmentMap["fileName"] = attachment.filename; - attachmentMap["storedPath"] = attachment.content; - - if (!m_chatFilePath.isEmpty()) { - QFileInfo fileInfo(m_chatFilePath); - QString baseName = fileInfo.completeBaseName(); - QString dirPath = fileInfo.absolutePath(); - QString contentFolder = QDir(dirPath).filePath(baseName + "_content"); - QString fullPath = QDir(contentFolder).filePath(attachment.content); - attachmentMap["filePath"] = fullPath; - } else { - attachmentMap["filePath"] = QString(); - } - - attachmentsList.append(attachmentMap); - } - return attachmentsList; - } - case Roles::IsRedacted: { - return message.isRedacted; - } + return QVariant::fromValue(row.kind); + case Roles::Content: + if (row.kind == ChatRole::FileEdit) + return overlayFileEditStatus(row.content, row.editId); + return row.content; + case Roles::Attachments: + return buildAttachmentList(row.attachments); + case Roles::Images: + return buildImageList(row.images); + case Roles::IsRedacted: + return row.isRedacted; case Roles::PromptTokens: - return message.promptTokens; + return m_usageByMessageId.value(row.messageId).prompt; case Roles::CompletionTokens: - return message.completionTokens; + return m_usageByMessageId.value(row.messageId).completion; case Roles::CachedPromptTokens: - return message.cachedPromptTokens; + return m_usageByMessageId.value(row.messageId).cached; case Roles::ReasoningTokens: - return message.reasoningTokens; - case Roles::TotalTokens: - return message.promptTokens + message.completionTokens; - case Roles::Images: { - QVariantList imagesList; - for (const auto &image : message.images) { - QVariantMap imageMap; - imageMap["fileName"] = image.fileName; - imageMap["storedPath"] = image.storedPath; - imageMap["mediaType"] = image.mediaType; - - if (!m_chatFilePath.isEmpty()) { - QFileInfo fileInfo(m_chatFilePath); - QString baseName = fileInfo.completeBaseName(); - QString dirPath = fileInfo.absolutePath(); - QString contentFolder = QDir(dirPath).filePath(baseName + "_content"); - QString fullPath = QDir(contentFolder).filePath(image.storedPath); - imageMap["imageUrl"] = QUrl::fromLocalFile(fullPath).toString(); - imageMap["filePath"] = fullPath; - } else { - imageMap["imageUrl"] = QString(); - imageMap["filePath"] = QString(); - } - - imagesList.append(imageMap); - } - return imagesList; + return m_usageByMessageId.value(row.messageId).reasoning; + case Roles::TotalTokens: { + const Usage u = m_usageByMessageId.value(row.messageId); + return u.prompt + u.completion; } default: return QVariant(); @@ -134,87 +178,311 @@ QHash ChatModel::roleNames() const return roles; } -void ChatModel::addMessage( - const QString &content, - ChatRole role, - const QString &id, - const QList &attachments, - const QList &images, - bool isRedacted, - const QString &signature) +QVariantList ChatModel::buildAttachmentList(const QVector &attachments) const { - if (!m_messages.isEmpty() && !id.isEmpty() && m_messages.last().id == id - && m_messages.last().role == role) { - Message &lastMessage = m_messages.last(); - lastMessage.content = content; - lastMessage.attachments = attachments; - lastMessage.images = images; - lastMessage.isRedacted = isRedacted; - lastMessage.signature = signature; - emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1)); - } else { - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message newMessage{role, content, id}; - newMessage.attachments = attachments; - newMessage.images = images; - newMessage.isRedacted = isRedacted; - newMessage.signature = signature; - m_messages.append(newMessage); - endInsertRows(); - - if (m_loadingFromHistory && role == ChatRole::FileEdit) { - const QString marker = "QODEASSIST_FILE_EDIT:"; - if (content.contains(marker)) { - int markerPos = content.indexOf(marker); - int jsonStart = markerPos + marker.length(); - - if (jsonStart < content.length()) { - QString jsonStr = content.mid(jsonStart); - QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); - - if (doc.isObject()) { - QJsonObject editData = doc.object(); - QString editId = editData.value("edit_id").toString(); - QString filePath = editData.value("file").toString(); - QString oldContent = editData.value("old_content").toString(); - QString newContent = editData.value("new_content").toString(); - QString originalStatus = editData.value("status").toString(); - - if (!editId.isEmpty() && !filePath.isEmpty()) { - Context::ChangesManager::instance().addFileEdit( - editId, filePath, oldContent, newContent, false, true); - - editData["status"] = "archived"; - editData["status_message"] = "Loaded from chat history"; - - QString updatedContent = marker - + QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact)); - m_messages.last().content = updatedContent; - - emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1)); - - LOG_MESSAGE(QString("Registered historical file edit: %1 (original status: %2, now: archived)") - .arg(editId, originalStatus)); - } - } + QVariantList list; + for (const auto &attachment : attachments) { + QVariantMap map; + map["fileName"] = attachment.fileName; + map["storedPath"] = attachment.storedPath; + if (!m_chatFilePath.isEmpty()) { + QFileInfo fileInfo(m_chatFilePath); + const QString contentFolder + = QDir(fileInfo.absolutePath()).filePath(fileInfo.completeBaseName() + "_content"); + map["filePath"] = QDir(contentFolder).filePath(attachment.storedPath); + } else { + map["filePath"] = QString(); + } + list.append(map); + } + return list; +} + +QVariantList ChatModel::buildImageList(const QVector &images) const +{ + QVariantList list; + for (const auto &image : images) { + QVariantMap map; + map["fileName"] = image.fileName; + map["storedPath"] = image.storedPath; + map["mediaType"] = image.mediaType; + if (!m_chatFilePath.isEmpty()) { + QFileInfo fileInfo(m_chatFilePath); + const QString contentFolder + = QDir(fileInfo.absolutePath()).filePath(fileInfo.completeBaseName() + "_content"); + const QString fullPath = QDir(contentFolder).filePath(image.storedPath); + map["imageUrl"] = QUrl::fromLocalFile(fullPath).toString(); + map["filePath"] = fullPath; + } else { + map["imageUrl"] = QString(); + map["filePath"] = QString(); + } + list.append(map); + } + return list; +} + +QString ChatModel::overlayFileEditStatus(const QString &content, const QString &editId) const +{ + const int pos = content.indexOf(kFileEditMarker); + if (pos < 0) + return content; + + const QString jsonStr = content.mid(pos + kFileEditMarker.length()); + const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); + if (!doc.isObject()) + return content; + + QJsonObject obj = doc.object(); + if (!editId.isEmpty()) { + const auto edit = Context::ChangesManager::instance().getFileEdit(editId); + if (!edit.editId.isEmpty()) { + obj["status"] = changesStatusToString(edit.status); + if (!edit.statusMessage.isEmpty()) + obj["status_message"] = edit.statusMessage; + } + } + return kFileEditMarker + + QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)); +} + +QHash ChatModel::buildToolResultMap() const +{ + QHash results; + if (!m_history) + return results; + for (const auto &m : m_history->messages()) { + for (const auto &block : m.blocks()) { + if (auto *tr = dynamic_cast(block.get())) + results.insert(tr->toolUseId(), tr->result()); + } + } + return results; +} + +void ChatModel::appendRowsForMessage( + int messageIndex, const QHash &toolResults, QVector &out) const +{ + if (!m_history || messageIndex < 0 || messageIndex >= m_history->size()) + return; + + const Message &m = m_history->messages()[static_cast(messageIndex)]; + const QString id = m.id(); + + switch (m.role()) { + case Message::Role::System: { + const QString text = collectText(m); + if (!text.trimmed().isEmpty()) { + Row row; + row.kind = ChatRole::System; + row.messageIndex = messageIndex; + row.messageId = id; + row.content = text; + out.append(std::move(row)); + } + break; + } + case Message::Role::User: { + QString text; + QVector attachments; + QVector images; + bool hasDisplayable = false; + for (const auto &block : m.blocks()) { + if (auto *t = dynamic_cast(block.get())) { + if (!text.isEmpty()) + text += QLatin1Char('\n'); + text += t->text(); + hasDisplayable = true; + } else if (auto *sa = dynamic_cast(block.get())) { + attachments.append({sa->fileName(), sa->storedPath()}); + hasDisplayable = true; + } else if (auto *si = dynamic_cast(block.get())) { + images.append({si->fileName(), si->storedPath(), si->mediaType()}); + hasDisplayable = true; + } + } + if (hasDisplayable) { + Row row; + row.kind = ChatRole::User; + row.messageIndex = messageIndex; + row.messageId = id; + row.content = text; + row.attachments = std::move(attachments); + row.images = std::move(images); + out.append(std::move(row)); + } + break; + } + case Message::Role::Assistant: { + for (const auto &block : m.blocks()) { + if (auto *th = dynamic_cast(block.get())) { + QString content = th->thinking(); + if (!th->signature().isEmpty()) + content += QStringLiteral("\n[Signature: ") + th->signature().left(40) + + QStringLiteral("...]"); + Row row; + row.kind = ChatRole::Thinking; + row.messageIndex = messageIndex; + row.messageId = id; + row.content = content; + out.append(std::move(row)); + } else if ( + auto *rth = dynamic_cast(block.get())) { + QString content = QStringLiteral("[Thinking content redacted by safety systems]"); + if (!rth->signature().isEmpty()) + content += QStringLiteral("\n[Signature: ") + rth->signature().left(40) + + QStringLiteral("...]"); + Row row; + row.kind = ChatRole::Thinking; + row.messageIndex = messageIndex; + row.messageId = id; + row.content = content; + row.isRedacted = true; + out.append(std::move(row)); + } else if (auto *t = dynamic_cast(block.get())) { + if (!t->text().trimmed().isEmpty()) { + Row row; + row.kind = ChatRole::Assistant; + row.messageIndex = messageIndex; + row.messageId = id; + row.content = t->text(); + out.append(std::move(row)); + } + } else if (auto *tu = dynamic_cast(block.get())) { + const QString result = toolResults.value(tu->id()); + Row toolRow; + toolRow.kind = ChatRole::Tool; + toolRow.messageIndex = messageIndex; + toolRow.messageId = id; + toolRow.content = tu->name() + QLatin1Char('\n') + result; + out.append(std::move(toolRow)); + + if (result.contains(kFileEditMarker)) { + Row editRow; + editRow.kind = ChatRole::FileEdit; + editRow.messageIndex = messageIndex; + editRow.messageId = id; + editRow.content = result; + editRow.editId = parseEditId(result); + out.append(std::move(editRow)); } } } + break; + } } } -QVector ChatModel::getChatHistory() const +void ChatModel::rebuildAll() { - return m_messages; + m_rows.clear(); + if (!m_history) + return; + const QHash toolResults = buildToolResultMap(); + for (int mi = 0; mi < m_history->size(); ++mi) + appendRowsForMessage(mi, toolResults, m_rows); +} + +int ChatModel::firstRowForMessage(int messageIndex) const +{ + for (int i = 0; i < m_rows.size(); ++i) { + if (m_rows[i].messageIndex >= messageIndex) + return i; + } + return m_rows.size(); +} + +int ChatModel::startMessageIndexFor(int messageIndex) const +{ + if (!m_history || messageIndex < 0 || messageIndex >= m_history->size()) + return messageIndex; + + const auto &msgs = m_history->messages(); + const Message &m = msgs[static_cast(messageIndex)]; + if (m.role() == Message::Role::User && messageIsToolResultsOnly(m)) { + for (int j = messageIndex - 1; j >= 0; --j) { + if (msgs[static_cast(j)].role() == Message::Role::Assistant) + return j; + } + } + return messageIndex; +} + +void ChatModel::reprojectTail(int startMessageIndex) +{ + if (!m_history) + return; + + const int oldStart = firstRowForMessage(startMessageIndex); + const QHash toolResults = buildToolResultMap(); + + QVector newTail; + for (int mi = startMessageIndex; mi < m_history->size(); ++mi) + appendRowsForMessage(mi, toolResults, newTail); + + const int oldCount = m_rows.size() - oldStart; + const int newCount = newTail.size(); + const int common = qMin(oldCount, newCount); + + for (int i = 0; i < common; ++i) + m_rows[oldStart + i] = newTail[i]; + if (common > 0) + emit dataChanged(index(oldStart), index(oldStart + common - 1)); + + if (newCount > oldCount) { + beginInsertRows(QModelIndex(), oldStart + oldCount, oldStart + newCount - 1); + for (int i = oldCount; i < newCount; ++i) + m_rows.append(newTail[i]); + endInsertRows(); + } else if (newCount < oldCount) { + beginRemoveRows(QModelIndex(), oldStart + newCount, oldStart + oldCount - 1); + m_rows.remove(oldStart + newCount, oldCount - newCount); + endRemoveRows(); + } +} + +void ChatModel::onHistoryMessageAdded(int index) +{ + reprojectTail(startMessageIndexFor(index)); +} + +void ChatModel::onHistoryMessageUpdated(int index) +{ + reprojectTail(startMessageIndexFor(index)); +} + +void ChatModel::onHistoryCleared() +{ + beginResetModel(); + m_rows.clear(); + m_usageByMessageId.clear(); + endResetModel(); + emit modelReseted(); + emit sessionUsageChanged(); +} + +void ChatModel::onHistoryReset() +{ + beginResetModel(); + rebuildAll(); + endResetModel(); + emit sessionUsageChanged(); +} + +void ChatModel::onFileEditStatusChanged(const QString &editId) +{ + for (int i = 0; i < m_rows.size(); ++i) { + if (m_rows[i].kind == ChatRole::FileEdit && m_rows[i].editId == editId) + emit dataChanged(index(i), index(i), {Roles::Content}); + } } void ChatModel::clear() { - beginResetModel(); - m_messages.clear(); - endResetModel(); - emit modelReseted(); - emit sessionUsageChanged(); + if (m_history) + m_history->clear(); + else + onHistoryCleared(); } QList ChatModel::processMessageContent(const QString &content) const @@ -277,73 +545,21 @@ QList ChatModel::processMessageContent(const QString &content) cons return parts; } -QJsonArray ChatModel::prepareMessagesForRequest(const QString &systemPrompt) const -{ - QJsonArray messages; - messages.append(QJsonObject{{"role", "system"}, {"content", systemPrompt}}); - - for (const auto &message : m_messages) { - QString role; - switch (message.role) { - case ChatRole::User: - role = "user"; - break; - case ChatRole::Assistant: - role = "assistant"; - break; - case ChatRole::Tool: - case ChatRole::FileEdit: - continue; - default: - continue; - } - - QString content - = message.attachments.isEmpty() - ? message.content - : message.content + "\n\nAttached files list:" - + std::accumulate( - message.attachments.begin(), - message.attachments.end(), - QString(), - [](QString acc, const Context::ContentFile &attachment) { - return acc - + QString("\nname: %1\nfile content:\n%2") - .arg(attachment.filename, attachment.content); - }); - - messages.append(QJsonObject{{"role", role}, {"content", content}}); - } - - return messages; -} - -QString ChatModel::lastMessageId() const -{ - return !m_messages.isEmpty() ? m_messages.last().id : ""; -} - void ChatModel::resetModelTo(int index) { - if (index < 0 || index >= m_messages.size()) + if (!m_history || index < 0 || index >= m_rows.size()) return; - - if (index < m_messages.size()) { - beginRemoveRows(QModelIndex(), index, m_messages.size() - 1); - m_messages.remove(index, m_messages.size() - index); - endRemoveRows(); - emit sessionUsageChanged(); - } + m_history->resetTo(m_rows[index].messageIndex); } QVariantList ChatModel::userMessagePreviews(int maxLength) const { QVariantList result; const int limit = maxLength > 4 ? maxLength : 80; - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role != ChatRole::User) + for (int i = 0; i < m_rows.size(); ++i) { + if (m_rows[i].kind != ChatRole::User) continue; - QString preview = m_messages[i].content; + QString preview = m_rows[i].content; preview.replace(QLatin1Char('\n'), QLatin1Char(' ')); preview.replace(QLatin1Char('\r'), QLatin1Char(' ')); preview.replace(QLatin1Char('\t'), QLatin1Char(' ')); @@ -358,221 +574,6 @@ QVariantList ChatModel::userMessagePreviews(int maxLength) const return result; } -void ChatModel::addToolExecutionStatus( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QJsonObject &toolArguments) -{ - QString content = toolName; - - LOG_MESSAGE(QString("Adding tool execution status: requestId=%1, toolId=%2, toolName=%3") - .arg(requestId, toolId, toolName)); - - if (!m_messages.isEmpty() && !toolId.isEmpty() && m_messages.last().id == toolId - && m_messages.last().role == ChatRole::Tool) { - Message &lastMessage = m_messages.last(); - lastMessage.content = content; - lastMessage.toolName = toolName; - lastMessage.toolArguments = toolArguments; - LOG_MESSAGE(QString("Updated existing tool message at index %1").arg(m_messages.size() - 1)); - emit dataChanged(index(m_messages.size() - 1), index(m_messages.size() - 1)); - } else { - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message newMessage{ChatRole::Tool, content, toolId}; - newMessage.toolName = toolName; - newMessage.toolArguments = toolArguments; - m_messages.append(newMessage); - endInsertRows(); - LOG_MESSAGE(QString("Created new tool message at index %1 with toolId=%2") - .arg(m_messages.size() - 1) - .arg(toolId)); - } -} - -void ChatModel::dropTrailingAssistantMessage(const QString &requestId) -{ - if (m_messages.isEmpty()) - return; - - const Message &last = m_messages.last(); - if (last.role != ChatRole::Assistant || last.id != requestId) - return; - - const int idx = m_messages.size() - 1; - beginRemoveRows(QModelIndex(), idx, idx); - m_messages.removeLast(); - endRemoveRows(); - LOG_MESSAGE(QString("Dropped leaked pre-tool assistant message at index %1").arg(idx)); -} - -void ChatModel::setToolMessageData( - const QString &toolId, - const QString &toolName, - const QJsonObject &toolArguments, - const QString &toolResult) -{ - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role == ChatRole::Tool && m_messages[i].id == toolId) { - m_messages[i].toolName = toolName; - m_messages[i].toolArguments = toolArguments; - m_messages[i].toolResult = toolResult; - return; - } - } -} - -void ChatModel::updateToolResult( - const QString &requestId, const QString &toolId, const QString &toolName, const QString &result) -{ - if (m_messages.isEmpty() || toolId.isEmpty()) { - LOG_MESSAGE(QString("Cannot update tool result: messages empty=%1, toolId empty=%2") - .arg(m_messages.isEmpty()) - .arg(toolId.isEmpty())); - return; - } - - LOG_MESSAGE( - QString("Updating tool result: requestId=%1, toolId=%2, toolName=%3, result length=%4") - .arg(requestId, toolId, toolName) - .arg(result.length())); - - bool toolMessageFound = false; - for (int i = m_messages.size() - 1; i >= 0; --i) { - if (m_messages[i].id == toolId && m_messages[i].role == ChatRole::Tool) { - m_messages[i].content = toolName + "\n" + result; - m_messages[i].toolName = toolName; - m_messages[i].toolResult = result; - emit dataChanged(index(i), index(i)); - toolMessageFound = true; - LOG_MESSAGE(QString("Updated tool result at index %1").arg(i)); - break; - } - } - - if (!toolMessageFound) { - LOG_MESSAGE(QString("WARNING: Tool message with requestId=%1 toolId=%2 not found!") - .arg(requestId, toolId)); - } - - const QString marker = "QODEASSIST_FILE_EDIT:"; - if (result.contains(marker)) { - LOG_MESSAGE(QString("File edit marker detected in tool result")); - - int markerPos = result.indexOf(marker); - int jsonStart = markerPos + marker.length(); - - if (jsonStart < result.length()) { - QString jsonStr = result.mid(jsonStart); - - QJsonParseError parseError; - QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8(), &parseError); - - if (parseError.error != QJsonParseError::NoError) { - LOG_MESSAGE(QString("ERROR: Failed to parse file edit JSON at offset %1: %2") - .arg(parseError.offset) - .arg(parseError.errorString())); - } else if (!doc.isObject()) { - LOG_MESSAGE( - QString("ERROR: Parsed JSON is not an object, is array=%1").arg(doc.isArray())); - } else { - QJsonObject editData = doc.object(); - - QString editId = editData.value("edit_id").toString(); - - if (editId.isEmpty()) { - editId = QString("edit_%1").arg(QDateTime::currentMSecsSinceEpoch()); - } - - LOG_MESSAGE(QString("Adding FileEdit message, editId=%1").arg(editId)); - - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message fileEditMsg; - fileEditMsg.role = ChatRole::FileEdit; - fileEditMsg.content = result; - fileEditMsg.id = editId; - m_messages.append(fileEditMsg); - endInsertRows(); - - LOG_MESSAGE(QString("Added FileEdit message with editId=%1").arg(editId)); - } - } - } -} - -void ChatModel::addThinkingBlock( - const QString &requestId, const QString &thinking, const QString &signature) -{ - LOG_MESSAGE(QString("Adding thinking block: requestId=%1, thinking length=%2, signature length=%3") - .arg(requestId) - .arg(thinking.length()) - .arg(signature.length())); - - QString displayContent = thinking; - if (!signature.isEmpty()) { - displayContent += "\n[Signature: " + signature.left(40) + "...]"; - } - - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role == ChatRole::Thinking && m_messages[i].id == requestId) { - m_messages[i].content = displayContent; - m_messages[i].signature = signature; - emit dataChanged(index(i), index(i)); - LOG_MESSAGE(QString("Updated existing thinking message at index %1").arg(i)); - return; - } - } - - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message thinkingMessage; - thinkingMessage.role = ChatRole::Thinking; - thinkingMessage.content = displayContent; - thinkingMessage.id = requestId; - thinkingMessage.isRedacted = false; - thinkingMessage.signature = signature; - m_messages.append(thinkingMessage); - endInsertRows(); - LOG_MESSAGE(QString("Added thinking message at index %1 with signature length=%2") - .arg(m_messages.size() - 1).arg(signature.length())); -} - -void ChatModel::addRedactedThinkingBlock(const QString &requestId, const QString &signature) -{ - LOG_MESSAGE( - QString("Adding redacted thinking block: requestId=%1, signature length=%2") - .arg(requestId) - .arg(signature.length())); - - QString displayContent = "[Thinking content redacted by safety systems]"; - if (!signature.isEmpty()) { - displayContent += "\n[Signature: " + signature.left(40) + "...]"; - } - - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); - Message thinkingMessage; - thinkingMessage.role = ChatRole::Thinking; - thinkingMessage.content = displayContent; - thinkingMessage.id = requestId; - thinkingMessage.isRedacted = true; - thinkingMessage.signature = signature; - m_messages.append(thinkingMessage); - endInsertRows(); - LOG_MESSAGE(QString("Added redacted thinking message at index %1 with signature length=%2") - .arg(m_messages.size() - 1).arg(signature.length())); -} - -void ChatModel::updateMessageContent(const QString &messageId, const QString &newContent) -{ - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].id == messageId) { - m_messages[i].content = newContent; - emit dataChanged(index(i), index(i)); - LOG_MESSAGE(QString("Updated message content for id: %1").arg(messageId)); - break; - } - } -} - void ChatModel::setMessageUsage( const QString &messageId, int promptTokens, @@ -580,118 +581,55 @@ void ChatModel::setMessageUsage( int cachedPromptTokens, int reasoningTokens) { - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].id != messageId) - continue; - m_messages[i].promptTokens = promptTokens; - m_messages[i].completionTokens = completionTokens; - m_messages[i].cachedPromptTokens = cachedPromptTokens; - m_messages[i].reasoningTokens = reasoningTokens; - emit dataChanged( - index(i), - index(i), - {Roles::PromptTokens, - Roles::CompletionTokens, - Roles::CachedPromptTokens, - Roles::ReasoningTokens, - Roles::TotalTokens}); - emit sessionUsageChanged(); + if (messageId.isEmpty()) return; + + m_usageByMessageId[messageId] + = Usage{promptTokens, completionTokens, cachedPromptTokens, reasoningTokens}; + + for (int i = 0; i < m_rows.size(); ++i) { + if (m_rows[i].messageId == messageId) { + emit dataChanged( + index(i), + index(i), + {Roles::PromptTokens, + Roles::CompletionTokens, + Roles::CachedPromptTokens, + Roles::ReasoningTokens, + Roles::TotalTokens}); + } } + emit sessionUsageChanged(); } int ChatModel::sessionPromptTokens() const { int total = 0; - for (const auto &m : m_messages) - total += m.promptTokens; + if (m_history) { + for (const auto &m : m_history->messages()) + total += m_usageByMessageId.value(m.id()).prompt; + } return total; } int ChatModel::sessionCompletionTokens() const { int total = 0; - for (const auto &m : m_messages) - total += m.completionTokens; + if (m_history) { + for (const auto &m : m_history->messages()) + total += m_usageByMessageId.value(m.id()).completion; + } return total; } int ChatModel::sessionCachedPromptTokens() const { int total = 0; - for (const auto &m : m_messages) - total += m.cachedPromptTokens; - return total; -} - -int ChatModel::sessionTotalTokens() const -{ - return sessionPromptTokens() + sessionCompletionTokens(); -} - -void ChatModel::setLoadingFromHistory(bool loading) -{ - m_loadingFromHistory = loading; - LOG_MESSAGE(QString("ChatModel loading from history: %1").arg(loading ? "true" : "false")); -} - -bool ChatModel::isLoadingFromHistory() const -{ - return m_loadingFromHistory; -} - -void ChatModel::onFileEditApplied(const QString &editId) -{ - updateFileEditStatus(editId, "applied", "Successfully applied"); -} - -void ChatModel::onFileEditRejected(const QString &editId) -{ - updateFileEditStatus(editId, "rejected", "Rejected by user"); -} - -void ChatModel::onFileEditArchived(const QString &editId) -{ - updateFileEditStatus(editId, "archived", "Archived (from previous conversation turn)"); -} - -void ChatModel::updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage) -{ - const QString marker = "QODEASSIST_FILE_EDIT:"; - - for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role == ChatRole::FileEdit && m_messages[i].id == editId) { - const QString &content = m_messages[i].content; - - if (content.contains(marker)) { - int markerPos = content.indexOf(marker); - int jsonStart = markerPos + marker.length(); - - if (jsonStart < content.length()) { - QString jsonStr = content.mid(jsonStart); - QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); - - if (doc.isObject()) { - QJsonObject editData = doc.object(); - - editData["status"] = status; - editData["status_message"] = statusMessage; - - QString updatedContent = marker - + QString::fromUtf8(QJsonDocument(editData).toJson(QJsonDocument::Compact)); - - m_messages[i].content = updatedContent; - - emit dataChanged(index(i), index(i)); - - LOG_MESSAGE(QString("Updated FileEdit message status: editId=%1, status=%2") - .arg(editId, status)); - break; - } - } - } - } + if (m_history) { + for (const auto &m : m_history->messages()) + total += m_usageByMessageId.value(m.id()).cached; } + return total; } void ChatModel::setChatFilePath(const QString &filePath) diff --git a/ChatView/ChatModel.hpp b/ChatView/ChatModel.hpp index 0da2fac..dae0db0 100644 --- a/ChatView/ChatModel.hpp +++ b/ChatView/ChatModel.hpp @@ -8,11 +8,16 @@ #include "MessagePart.hpp" #include +#include #include #include +#include +#include #include -#include "context/ContentFile.hpp" +namespace QodeAssist { +class ConversationHistory; +} namespace QodeAssist::Chat { @@ -22,7 +27,6 @@ class ChatModel : public QAbstractListModel Q_PROPERTY(int sessionPromptTokens READ sessionPromptTokens NOTIFY sessionUsageChanged FINAL) Q_PROPERTY(int sessionCompletionTokens READ sessionCompletionTokens NOTIFY sessionUsageChanged FINAL) Q_PROPERTY(int sessionCachedPromptTokens READ sessionCachedPromptTokens NOTIFY sessionUsageChanged FINAL) - Q_PROPERTY(int sessionTotalTokens READ sessionTotalTokens NOTIFY sessionUsageChanged FINAL) QML_ELEMENT public: @@ -43,81 +47,19 @@ public: }; Q_ENUM(Roles) - struct ImageAttachment - { - QString fileName; // Original filename - QString storedPath; // Path to stored image file (relative to chat folder) - QString mediaType; // MIME type - }; - - struct Message - { - ChatRole role; - QString content; - QString id; - bool isRedacted = false; - QString signature = QString(); - - QList attachments; - QList images; - - QString toolName; - QJsonObject toolArguments; - QString toolResult; - - int promptTokens = 0; - int completionTokens = 0; - int cachedPromptTokens = 0; - int reasoningTokens = 0; - }; - explicit ChatModel(QObject *parent = nullptr); + void setHistory(ConversationHistory *history); + int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - Q_INVOKABLE void addMessage( - const QString &content, - ChatRole role, - const QString &id, - const QList &attachments = {}, - const QList &images = {}, - bool isRedacted = false, - const QString &signature = QString()); Q_INVOKABLE void clear(); Q_INVOKABLE QList processMessageContent(const QString &content) const; - - QVector getChatHistory() const; - QJsonArray prepareMessagesForRequest(const QString &systemPrompt) const; - - QString currentModel() const; - QString lastMessageId() const; - Q_INVOKABLE void resetModelTo(int index); Q_INVOKABLE QVariantList userMessagePreviews(int maxLength = 80) const; - void addToolExecutionStatus( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QJsonObject &toolArguments); - void dropTrailingAssistantMessage(const QString &requestId); - void setToolMessageData( - const QString &toolId, - const QString &toolName, - const QJsonObject &toolArguments, - const QString &toolResult); - void updateToolResult( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QString &result); - void addThinkingBlock( - const QString &requestId, const QString &thinking, const QString &signature); - void addRedactedThinkingBlock(const QString &requestId, const QString &signature); - void updateMessageContent(const QString &messageId, const QString &newContent); - void setMessageUsage( const QString &messageId, int promptTokens, @@ -128,11 +70,7 @@ public: int sessionPromptTokens() const; int sessionCompletionTokens() const; int sessionCachedPromptTokens() const; - int sessionTotalTokens() const; - - void setLoadingFromHistory(bool loading); - bool isLoadingFromHistory() const; - + void setChatFilePath(const QString &filePath); QString chatFilePath() const; @@ -141,18 +79,60 @@ signals: void sessionUsageChanged(); private slots: - void onFileEditApplied(const QString &editId); - void onFileEditRejected(const QString &editId); - void onFileEditArchived(const QString &editId); + void onHistoryMessageAdded(int index); + void onHistoryMessageUpdated(int index); + void onHistoryCleared(); + void onHistoryReset(); + void onFileEditStatusChanged(const QString &editId); private: - void updateFileEditStatus(const QString &editId, const QString &status, const QString &statusMessage); - - QVector m_messages; - bool m_loadingFromHistory = false; + struct AttachmentRef + { + QString fileName; + QString storedPath; + }; + struct ImageRef + { + QString fileName; + QString storedPath; + QString mediaType; + }; + struct Row + { + ChatRole kind = ChatRole::Assistant; + int messageIndex = -1; + QString messageId; + QString content; + bool isRedacted = false; + QString editId; + QVector attachments; + QVector images; + }; + struct Usage + { + int prompt = 0; + int completion = 0; + int cached = 0; + int reasoning = 0; + }; + + void rebuildAll(); + void reprojectTail(int startMessageIndex); + int startMessageIndexFor(int messageIndex) const; + int firstRowForMessage(int messageIndex) const; + QHash buildToolResultMap() const; + void appendRowsForMessage( + int messageIndex, const QHash &toolResults, QVector &out) const; + QString overlayFileEditStatus(const QString &content, const QString &editId) const; + QVariantList buildAttachmentList(const QVector &attachments) const; + QVariantList buildImageList(const QVector &images) const; + + QPointer m_history; + QVector m_rows; + QHash m_usageByMessageId; QString m_chatFilePath; }; } // namespace QodeAssist::Chat -Q_DECLARE_METATYPE(QodeAssist::Chat::ChatModel::Message) + Q_DECLARE_METATYPE(QodeAssist::Chat::MessagePart) diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index d040ec2..67ff711 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -28,9 +28,14 @@ #include "QodeAssistConstants.hpp" -#include "AgentRoleController.hpp" +#include +#include +#include +#include +#include + +#include "ChatAgentController.hpp" #include "ChatAssistantSettings.hpp" -#include "ChatConfigurationController.hpp" #include "ChatCompressor.hpp" #include "ChatHistoryStore.hpp" #include "FileEditController.hpp" @@ -38,10 +43,8 @@ #include "InputTokenCounter.hpp" #include "SettingsConstants.hpp" #include "Logger.hpp" -#include "ProvidersManager.hpp" #include "SessionFileRegistry.hpp" #include "context/ContextManager.hpp" -#include "pluginllmcore/RulesLoader.hpp" #include "ProjectSettings.hpp" #include "SkillsSettings.hpp" #include "sources/skills/SkillsManager.hpp" @@ -73,19 +76,20 @@ QKeySequence sendMessageKeySequence() ChatRootView::ChatRootView(QQuickItem *parent) : QQuickItem(parent) + , m_history(new QodeAssist::ConversationHistory(this)) , m_chatModel(new ChatModel(this)) - , m_promptProvider(PluginLLMCore::PromptTemplateManager::instance()) - , m_clientInterface(new ClientInterface(m_chatModel, &m_promptProvider, this)) + , m_clientInterface(new ClientInterface(m_chatModel, this)) , m_fileManager(new ChatFileManager(this)) , m_isRequestInProgress(false) , m_chatCompressor(new ChatCompressor(this)) - , m_agentRoleController(new AgentRoleController(this)) - , m_configurationController(new ChatConfigurationController(this)) - , m_fileEditController(new FileEditController(m_chatModel, this)) - , m_tokenCounter( - new InputTokenCounter(m_chatModel, m_clientInterface->contextManager(), this)) - , m_historyStore(new ChatHistoryStore(m_chatModel, this)) + , m_agentController(new ChatAgentController(this)) + , m_fileEditController(new FileEditController(this)) + , m_tokenCounter(new InputTokenCounter(m_history, m_clientInterface->contextManager(), this)) + , m_historyStore(new ChatHistoryStore(m_history, this)) { + m_chatModel->setHistory(m_history); + m_clientInterface->setHistory(m_history); + m_isSyncOpenFiles = Settings::chatAssistantSettings().linkOpenFiles(); connect( &Settings::chatAssistantSettings().linkOpenFiles, @@ -109,22 +113,6 @@ ChatRootView::ChatRootView(QQuickItem *parent) }, Qt::QueuedConnection); - auto &settings = Settings::generalSettings(); - - connect( - &settings.caModel, &Utils::BaseAspect::changed, this, &ChatRootView::currentTemplateChanged); - - connect( - m_configurationController, - &ChatConfigurationController::availableConfigurationsChanged, - this, - &ChatRootView::availableConfigurationsChanged); - connect( - m_configurationController, - &ChatConfigurationController::currentConfigurationChanged, - this, - &ChatRootView::currentConfigurationChanged); - connect( m_clientInterface, &ClientInterface::messageReceivedCompletely, @@ -171,20 +159,20 @@ ChatRootView::ChatRootView(QQuickItem *parent) this, &ChatRootView::inputTokensCountChanged); connect( - m_agentRoleController, - &AgentRoleController::availableRolesChanged, + m_agentController, + &ChatAgentController::availableAgentsChanged, this, - &ChatRootView::availableAgentRolesChanged); + &ChatRootView::availableChatAgentsChanged); connect( - m_agentRoleController, - &AgentRoleController::currentRoleChanged, + m_agentController, + &ChatAgentController::currentAgentChanged, this, - &ChatRootView::currentAgentRoleChanged); + &ChatRootView::currentChatAgentChanged); connect( - m_agentRoleController, - &AgentRoleController::baseSystemPromptChanged, + m_agentController, + &ChatAgentController::currentAgentChanged, this, - &ChatRootView::baseSystemPromptChanged); + &ChatRootView::useToolsChanged); auto editors = Core::EditorManager::instance(); @@ -266,14 +254,6 @@ ChatRootView::ChatRootView(QQuickItem *parent) connect( m_historyStore, &ChatHistoryStore::loadRequested, this, &ChatRootView::loadHistory); - refreshRules(); - - connect( - ProjectExplorer::ProjectManager::instance(), - &ProjectExplorer::ProjectManager::startupProjectChanged, - this, - &ChatRootView::refreshRules); - connect( ProjectExplorer::ProjectManager::instance(), &ProjectExplorer::ProjectManager::projectAdded, @@ -286,24 +266,6 @@ ChatRootView::ChatRootView(QQuickItem *parent) this, &ChatRootView::openFilesChanged); - connect( - &Settings::chatAssistantSettings().enableChatTools, - &Utils::BaseAspect::changed, - this, - &ChatRootView::useToolsChanged); - - connect( - &Settings::chatAssistantSettings().enableThinkingMode, - &Utils::BaseAspect::changed, - this, - &ChatRootView::useThinkingChanged); - - connect( - &Settings::generalSettings().caProvider, - &Utils::BaseAspect::changed, - this, - &ChatRootView::isThinkingSupportChanged); - connect(m_fileManager, &ChatFileManager::fileOperationFailed, this, [this](const QString &error) { m_lastErrorMessage = error; emit lastErrorMessageChanged(); @@ -324,7 +286,7 @@ ChatRootView::ChatRootView(QQuickItem *parent) if (m_pendingSend.active) { PendingSend p = m_pendingSend; m_pendingSend = {}; - dispatchSend(p.message, p.attachments, p.linkedFiles, p.useTools, p.useThinking); + dispatchSend(p.message, p.attachments, p.linkedFiles); } }); @@ -337,7 +299,7 @@ ChatRootView::ChatRootView(QQuickItem *parent) if (m_pendingSend.active) { PendingSend p = m_pendingSend; m_pendingSend = {}; - dispatchSend(p.message, p.attachments, p.linkedFiles, p.useTools, p.useThinking); + dispatchSend(p.message, p.attachments, p.linkedFiles); } }); } @@ -373,6 +335,48 @@ Skills::SkillsManager *ChatRootView::skillsManager() const return m_skillsManager; } +AgentFactory *ChatRootView::agentFactory() const +{ + if (!m_agentFactory) { + if (auto *engine = qmlEngine(this)) { + m_agentFactory = qobject_cast( + engine->rootContext()->contextProperty("agentFactory").value()); + } + } + return m_agentFactory; +} + +SessionManager *ChatRootView::sessionManager() const +{ + if (!m_sessionManager) { + if (auto *engine = qmlEngine(this)) { + m_sessionManager = qobject_cast( + engine->rootContext()->contextProperty("sessionManager").value()); + } + } + return m_sessionManager; +} + +void ChatRootView::loadAvailableChatAgents() +{ + m_agentController->setAgentFactory(agentFactory()); +} + +QStringList ChatRootView::availableChatAgents() const +{ + return m_agentController->availableAgents(); +} + +QString ChatRootView::currentChatAgent() const +{ + return m_agentController->currentAgent(); +} + +void ChatRootView::setCurrentChatAgent(const QString &name) +{ + m_agentController->setCurrentAgent(name); +} + QVariantList ChatRootView::searchSkills(const QString &query) const { QVariantList results; @@ -380,7 +384,7 @@ QVariantList ChatRootView::searchSkills(const QString &query) const if (!manager || !Settings::skillsSettings().enableSkills()) return results; - auto *project = PluginLLMCore::RulesLoader::getActiveProject(); + auto *project = ProjectExplorer::ProjectManager::startupProject(); QStringList projectSkillDirs; if (project) { Settings::ProjectSettings projectSettings(project); @@ -416,21 +420,17 @@ void ChatRootView::sendMessage(const QString &message) { const QStringList attachments = m_attachmentFiles; const QStringList linkedFiles = m_linkedFiles; - const bool tools = useTools(); - const bool thinking = useThinking(); - if (deferSendForAutoCompress(message, attachments, linkedFiles, tools, thinking)) + if (deferSendForAutoCompress(message, attachments, linkedFiles)) return; - dispatchSend(message, attachments, linkedFiles, tools, thinking); + dispatchSend(message, attachments, linkedFiles); } bool ChatRootView::deferSendForAutoCompress( const QString &message, const QStringList &attachments, - const QStringList &linkedFiles, - bool useToolsArg, - bool useThinkingArg) + const QStringList &linkedFiles) { auto &settings = Settings::chatAssistantSettings(); if (!settings.autoCompress()) @@ -441,6 +441,9 @@ bool ChatRootView::deferSendForAutoCompress( if (inputTokens < threshold) return false; + if (configuredCompressionAgent().isEmpty()) + return false; + if (m_recentFilePath.isEmpty()) { QString filePath = getAutosaveFilePath(message, attachments); if (filePath.isEmpty()) @@ -456,7 +459,7 @@ bool ChatRootView::deferSendForAutoCompress( .arg(inputTokens) .arg(threshold)); - m_pendingSend = {message, attachments, linkedFiles, useToolsArg, useThinkingArg, true}; + m_pendingSend = {message, attachments, linkedFiles, true}; compressCurrentChat(); return true; } @@ -464,9 +467,7 @@ bool ChatRootView::deferSendForAutoCompress( void ChatRootView::dispatchSend( const QString &message, const QStringList &attachments, - const QStringList &linkedFiles, - bool useToolsArg, - bool useThinkingArg) + const QStringList &linkedFiles) { if (m_recentFilePath.isEmpty()) { QString filePath = getAutosaveFilePath(message, attachments); @@ -481,8 +482,13 @@ void ChatRootView::dispatchSend( m_tokenCounter->recordSent(); + if (currentChatAgent().isEmpty()) + loadAvailableChatAgents(); + m_clientInterface->setSkillsManager(skillsManager()); - m_clientInterface->sendMessage(message, attachments, linkedFiles, useToolsArg, useThinkingArg); + m_clientInterface->setSessionManager(sessionManager()); + m_clientInterface->setActiveAgent(currentChatAgent()); + m_clientInterface->sendMessage(message, attachments, linkedFiles); m_fileManager->clearIntermediateStorage(); clearAttachmentFiles(); @@ -527,12 +533,6 @@ void ChatRootView::clearMessages() clearLinkedFiles(); } -QString ChatRootView::currentTemplate() const -{ - auto &settings = Settings::generalSettings(); - return settings.caModel(); -} - void ChatRootView::saveHistory(const QString &filePath) { if (filePath != m_recentFilePath) { @@ -821,25 +821,6 @@ void ChatRootView::openChatHistoryFolder() m_historyStore->openHistoryFolder(); } -void ChatRootView::openRulesFolder() -{ - auto project = ProjectExplorer::ProjectManager::startupProject(); - if (!project) { - return; - } - - QString projectPath = project->projectDirectory().toFSPathString(); - QString rulesPath = QDir(projectPath).filePath(".qodeassist/rules"); - - QDir dir(rulesPath); - if (!dir.exists()) { - dir.mkpath("."); - } - - QUrl url = QUrl::fromLocalFile(dir.absolutePath()); - QDesktopServices::openUrl(url); -} - void ChatRootView::openSettings() { QMetaObject::invokeMethod( @@ -890,13 +871,12 @@ QString ChatRootView::chatTitle() const QString ChatRootView::computeChatTitle() const { - if (!m_chatModel) + if (!m_history) return {}; - const auto history = m_chatModel->getChatHistory(); - for (const auto &msg : history) { - if (msg.role != ChatModel::User) + for (const auto &msg : m_history->messages()) { + if (msg.role() != Message::Role::User) continue; - const QString content = msg.content.trimmed(); + const QString content = msg.text().trimmed(); if (content.isEmpty()) continue; const QString firstLine = content.section(QChar('\n'), 0, 0).trimmed(); @@ -1064,11 +1044,7 @@ void ChatRootView::setRecentFilePath(const QString &filePath) bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath) { - auto project = ProjectExplorer::ProjectManager::projectForFile(filePath); - if (project - && m_clientInterface->contextManager() - ->ignoreManager() - ->shouldIgnore(filePath.toFSPathString(), project)) { + if (m_clientInterface->contextManager()->shouldIgnore(filePath.toFSPathString())) { LOG_MESSAGE(QString("Ignoring file for attachment due to .qodeassistignore: %1") .arg(filePath.toFSPathString())); return true; @@ -1120,71 +1096,9 @@ QString ChatRootView::lastErrorMessage() const return m_lastErrorMessage; } -QVariantList ChatRootView::activeRules() const -{ - return m_activeRules; -} - -int ChatRootView::activeRulesCount() const -{ - return m_activeRules.size(); -} - -QString ChatRootView::getRuleContent(int index) -{ - if (index < 0 || index >= m_activeRules.size()) - return QString(); - - return PluginLLMCore::RulesLoader::loadRuleFileContent( - m_activeRules[index].toMap()["filePath"].toString()); -} - -void ChatRootView::refreshRules() -{ - m_activeRules.clear(); - - auto project = PluginLLMCore::RulesLoader::getActiveProject(); - if (!project) { - emit activeRulesChanged(); - emit activeRulesCountChanged(); - return; - } - - auto ruleFiles - = PluginLLMCore::RulesLoader::getRuleFilesForProject(project, PluginLLMCore::RulesContext::Chat); - - for (const auto &ruleFile : ruleFiles) { - QVariantMap ruleMap; - ruleMap["filePath"] = ruleFile.filePath; - ruleMap["fileName"] = ruleFile.fileName; - ruleMap["category"] = ruleFile.category; - m_activeRules.append(ruleMap); - } - - emit activeRulesChanged(); - emit activeRulesCountChanged(); -} - bool ChatRootView::useTools() const { - return Settings::chatAssistantSettings().enableChatTools(); -} - -void ChatRootView::setUseTools(bool enabled) -{ - Settings::chatAssistantSettings().enableChatTools.setValue(enabled); - Settings::chatAssistantSettings().writeSettings(); -} - -bool ChatRootView::useThinking() const -{ - return Settings::chatAssistantSettings().enableThinkingMode(); -} - -void ChatRootView::setUseThinking(bool enabled) -{ - Settings::chatAssistantSettings().enableThinkingMode.setValue(enabled); - Settings::chatAssistantSettings().writeSettings(); + return m_agentController->currentSupportsTools(); } void ChatRootView::applyFileEdit(const QString &editId) @@ -1217,11 +1131,6 @@ void ChatRootView::undoAllFileEditsForCurrentMessage() m_fileEditController->undoAllForCurrentMessage(); } -void ChatRootView::updateCurrentMessageEditsStats() -{ - m_fileEditController->updateStats(); -} - int ChatRootView::currentMessageTotalEdits() const { return m_fileEditController->totalEdits(); @@ -1247,14 +1156,6 @@ QString ChatRootView::lastInfoMessage() const return m_lastInfoMessage; } -bool ChatRootView::isThinkingSupport() const -{ - auto providerName = Settings::generalSettings().caProvider(); - auto provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName); - - return provider && provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::Thinking); -} - bool ChatRootView::hasImageAttachments(const QStringList &attachments) const { for (const QString &filePath : attachments) { @@ -1273,64 +1174,17 @@ bool ChatRootView::isImageFile(const QString &filePath) const return imageExtensions.contains(fileInfo.suffix().toLower()); } -void ChatRootView::loadAvailableConfigurations() +QString ChatRootView::configuredCompressionAgent() const { - m_configurationController->loadAvailableConfigurations(); + const QString configured = Settings::PipelinesConfig::load().rosters.chatCompression; + if (!configured.isEmpty() && agentFactory() && agentFactory()->configByName(configured)) + return configured; + return {}; } -void ChatRootView::applyConfiguration(const QString &configName) +bool ChatRootView::canCompress() const { - m_configurationController->applyConfiguration(configName); -} - -QStringList ChatRootView::availableConfigurations() const -{ - return m_configurationController->availableConfigurations(); -} - -QString ChatRootView::currentConfiguration() const -{ - return m_configurationController->currentConfiguration(); -} - -void ChatRootView::loadAvailableAgentRoles() -{ - m_agentRoleController->loadAvailableRoles(); -} - -void ChatRootView::applyAgentRole(const QString &roleName) -{ - m_agentRoleController->applyRole(roleName); -} - -QStringList ChatRootView::availableAgentRoles() const -{ - return m_agentRoleController->availableRoles(); -} - -QString ChatRootView::currentAgentRole() const -{ - return m_agentRoleController->currentRole(); -} - -QString ChatRootView::baseSystemPrompt() const -{ - return m_agentRoleController->baseSystemPrompt(); -} - -QString ChatRootView::currentAgentRoleDescription() const -{ - return m_agentRoleController->currentRoleDescription(); -} - -QString ChatRootView::currentAgentRoleSystemPrompt() const -{ - return m_agentRoleController->currentRoleSystemPrompt(); -} - -void ChatRootView::openAgentRolesSettings() -{ - m_agentRoleController->openSettings(); + return !configuredCompressionAgent().isEmpty(); } void ChatRootView::compressCurrentChat() @@ -1347,9 +1201,15 @@ void ChatRootView::compressCurrentChat() return; } + const QString compressionAgent = configuredCompressionAgent(); + if (compressionAgent.isEmpty()) + return; + autosave(); - m_chatCompressor->startCompression(m_recentFilePath, m_chatModel); + m_chatCompressor->setSessionManager(sessionManager()); + m_chatCompressor->setActiveAgent(compressionAgent); + m_chatCompressor->startCompression(m_recentFilePath, m_history); } void ChatRootView::cancelCompression() diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index 7403872..cc68f09 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -11,18 +11,22 @@ #include "ChatFileManager.hpp" #include "ChatModel.hpp" #include "ClientInterface.hpp" -#include "pluginllmcore/PromptProviderChat.hpp" #include namespace QodeAssist::Skills { class SkillsManager; } +namespace QodeAssist { +class AgentFactory; +class SessionManager; +class ConversationHistory; +} + namespace QodeAssist::Chat { class ChatCompressor; -class AgentRoleController; -class ChatConfigurationController; +class ChatAgentController; class FileEditController; class InputTokenCounter; class ChatHistoryStore; @@ -32,7 +36,6 @@ class ChatRootView : public QQuickItem { Q_OBJECT Q_PROPERTY(QodeAssist::Chat::ChatModel *chatModel READ chatModel NOTIFY chatModelChanged FINAL) - Q_PROPERTY(QString currentTemplate READ currentTemplate NOTIFY currentTemplateChanged FINAL) Q_PROPERTY(bool isSyncOpenFiles READ isSyncOpenFiles NOTIFY isSyncOpenFilesChanged FINAL) Q_PROPERTY(QStringList attachmentFiles READ attachmentFiles NOTIFY attachmentFilesChanged FINAL) Q_PROPERTY(QStringList linkedFiles READ linkedFiles NOTIFY linkedFilesChanged FINAL) @@ -46,25 +49,17 @@ class ChatRootView : public QQuickItem Q_PROPERTY(bool isRequestInProgress READ isRequestInProgress NOTIFY isRequestInProgressChanged FINAL) Q_PROPERTY(QString lastErrorMessage READ lastErrorMessage NOTIFY lastErrorMessageChanged FINAL) Q_PROPERTY(QString lastInfoMessage READ lastInfoMessage NOTIFY lastInfoMessageChanged FINAL) - Q_PROPERTY(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL) - Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL) - Q_PROPERTY(bool useTools READ useTools WRITE setUseTools NOTIFY useToolsChanged FINAL) - Q_PROPERTY(bool useThinking READ useThinking WRITE setUseThinking NOTIFY useThinkingChanged FINAL) + Q_PROPERTY(bool useTools READ useTools NOTIFY useToolsChanged FINAL) Q_PROPERTY(QString sendShortcutText READ sendShortcutText NOTIFY sendShortcutTextChanged FINAL) Q_PROPERTY(int currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL) Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL) Q_PROPERTY(int currentMessagePendingEdits READ currentMessagePendingEdits NOTIFY currentMessageEditsStatsChanged FINAL) Q_PROPERTY(int currentMessageRejectedEdits READ currentMessageRejectedEdits NOTIFY currentMessageEditsStatsChanged FINAL) - Q_PROPERTY(bool isThinkingSupport READ isThinkingSupport NOTIFY isThinkingSupportChanged FINAL) - Q_PROPERTY(QStringList availableConfigurations READ availableConfigurations NOTIFY availableConfigurationsChanged FINAL) - Q_PROPERTY(QString currentConfiguration READ currentConfiguration NOTIFY currentConfigurationChanged FINAL) - Q_PROPERTY(QStringList availableAgentRoles READ availableAgentRoles NOTIFY availableAgentRolesChanged FINAL) - Q_PROPERTY(QString currentAgentRole READ currentAgentRole NOTIFY currentAgentRoleChanged FINAL) - Q_PROPERTY(QString baseSystemPrompt READ baseSystemPrompt NOTIFY baseSystemPromptChanged FINAL) - Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL) - Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL) + Q_PROPERTY(QStringList availableChatAgents READ availableChatAgents NOTIFY availableChatAgentsChanged FINAL) + Q_PROPERTY(QString currentChatAgent READ currentChatAgent WRITE setCurrentChatAgent NOTIFY currentChatAgentChanged FINAL) Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged FINAL) + Q_PROPERTY(bool canCompress READ canCompress NOTIFY availableChatAgentsChanged FINAL) Q_PROPERTY(bool isInEditor READ isInEditor NOTIFY isInEditorChanged FINAL) Q_PROPERTY(QString chatTitle READ chatTitle NOTIFY chatTitleChanged FINAL) @@ -75,7 +70,6 @@ public: ~ChatRootView() override; ChatModel *chatModel() const; - QString currentTemplate() const; void saveHistory(const QString &filePath); void loadHistory(const QString &filePath); @@ -104,7 +98,6 @@ public: QString sendShortcutText() const; Q_INVOKABLE void setIsSyncOpenFiles(bool state); Q_INVOKABLE void openChatHistoryFolder(); - Q_INVOKABLE void openRulesFolder(); Q_INVOKABLE void openSettings(); Q_INVOKABLE void openFileInEditor(const QString &filePath); @@ -139,18 +132,10 @@ public: void setRequestProgressStatus(bool state); QString lastErrorMessage() const; - - QVariantList activeRules() const; - int activeRulesCount() const; - Q_INVOKABLE QString getRuleContent(int index); - Q_INVOKABLE void refreshRules(); Q_INVOKABLE QVariantList searchSkills(const QString &query) const; bool useTools() const; - void setUseTools(bool enabled); - bool useThinking() const; - void setUseThinking(bool enabled); Q_INVOKABLE void applyFileEdit(const QString &editId); Q_INVOKABLE void rejectFileEdit(const QString &editId); @@ -159,25 +144,15 @@ public: Q_INVOKABLE void applyAllFileEditsForCurrentMessage(); Q_INVOKABLE void undoAllFileEditsForCurrentMessage(); - Q_INVOKABLE void updateCurrentMessageEditsStats(); - - Q_INVOKABLE void loadAvailableConfigurations(); - Q_INVOKABLE void applyConfiguration(const QString &configName); - QStringList availableConfigurations() const; - QString currentConfiguration() const; Q_INVOKABLE void compressCurrentChat(); Q_INVOKABLE void cancelCompression(); - Q_INVOKABLE void loadAvailableAgentRoles(); - Q_INVOKABLE void applyAgentRole(const QString &roleId); - Q_INVOKABLE void openAgentRolesSettings(); - QStringList availableAgentRoles() const; - QString currentAgentRole() const; - QString baseSystemPrompt() const; - QString currentAgentRoleDescription() const; - QString currentAgentRoleSystemPrompt() const; - + Q_INVOKABLE void loadAvailableChatAgents(); + QStringList availableChatAgents() const; + QString currentChatAgent() const; + void setCurrentChatAgent(const QString &name); + int currentMessageTotalEdits() const; int currentMessageAppliedEdits() const; int currentMessagePendingEdits() const; @@ -185,9 +160,8 @@ public: QString lastInfoMessage() const; - bool isThinkingSupport() const; - bool isCompressing() const; + bool canCompress() const; bool isInEditor() const; void setInEditor(bool value); @@ -206,7 +180,6 @@ public slots: signals: void chatModelChanged(); - void currentTemplateChanged(); void attachmentFilesChanged(); void linkedFilesChanged(); void inputTokensCountChanged(); @@ -223,20 +196,12 @@ signals: void lastErrorMessageChanged(); void lastInfoMessageChanged(); void sendShortcutTextChanged(); - void activeRulesChanged(); - void activeRulesCountChanged(); void useToolsChanged(); - void useThinkingChanged(); void currentMessageEditsStatsChanged(); - void isThinkingSupportChanged(); - void availableConfigurationsChanged(); - void currentConfigurationChanged(); - - void availableAgentRolesChanged(); - void currentAgentRoleChanged(); - void baseSystemPromptChanged(); + void availableChatAgentsChanged(); + void currentChatAgentChanged(); void isCompressingChanged(); void compressionCompleted(const QString &compressedChatPath); @@ -256,25 +221,23 @@ private: bool deferSendForAutoCompress( const QString &message, const QStringList &attachments, - const QStringList &linkedFiles, - bool useTools, - bool useThinking); + const QStringList &linkedFiles); void dispatchSend( const QString &message, const QStringList &attachments, - const QStringList &linkedFiles, - bool useTools, - bool useThinking); + const QStringList &linkedFiles); + QString configuredCompressionAgent() const; bool hasImageAttachments(const QStringList &attachments) const; SessionFileRegistry *sessionFileRegistry() const; Skills::SkillsManager *skillsManager() const; + AgentFactory *agentFactory() const; + SessionManager *sessionManager() const; + QodeAssist::ConversationHistory *m_history; ChatModel *m_chatModel; - PluginLLMCore::PromptProviderChat m_promptProvider; ClientInterface *m_clientInterface; ChatFileManager *m_fileManager; - QString m_currentTemplate; QString m_recentFilePath; QStringList m_attachmentFiles; QStringList m_linkedFiles; @@ -283,8 +246,6 @@ private: QString message; QStringList attachments; QStringList linkedFiles; - bool useTools = false; - bool useThinking = false; bool active = false; }; PendingSend m_pendingSend; @@ -294,13 +255,11 @@ private: QList m_currentEditors; bool m_isRequestInProgress; QString m_lastErrorMessage; - QVariantList m_activeRules; - + QString m_lastInfoMessage; ChatCompressor *m_chatCompressor; - AgentRoleController *m_agentRoleController; - ChatConfigurationController *m_configurationController; + ChatAgentController *m_agentController; FileEditController *m_fileEditController; InputTokenCounter *m_tokenCounter; ChatHistoryStore *m_historyStore; @@ -308,6 +267,8 @@ private: mutable bool m_sessionFileRegistryResolved = false; mutable QPointer m_skillsManager; mutable bool m_skillsManagerResolved = false; + mutable QPointer m_agentFactory; + mutable QPointer m_sessionManager; }; } // namespace QodeAssist::Chat diff --git a/ChatView/ChatSerializer.cpp b/ChatView/ChatSerializer.cpp index 120a4c5..75ec656 100644 --- a/ChatView/ChatSerializer.cpp +++ b/ChatView/ChatSerializer.cpp @@ -5,7 +5,8 @@ #include "ChatSerializer.hpp" #include "Logger.hpp" -#include +#include + #include #include #include @@ -13,12 +14,57 @@ #include #include +#include + +#include +#include +#include +#include + +#include "context/ChangesManager.h" + namespace QodeAssist::Chat { -const QString ChatSerializer::VERSION = "0.2"; +namespace { -SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QString &filePath) +const QString kFileEditMarker = QStringLiteral("QODEASSIST_FILE_EDIT:"); + +// Legacy (<= 0.2) per-row ChatRole values, kept only for importing old chat files. +enum class LegacyRole { System = 0, User = 1, Assistant = 2, Tool = 3, FileEdit = 4, Thinking = 5 }; + +void registerEditFromResult(const QString &result) { + const int pos = result.indexOf(kFileEditMarker); + if (pos < 0) + return; + const QString jsonStr = result.mid(pos + kFileEditMarker.length()); + const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); + if (!doc.isObject()) + return; + const QJsonObject obj = doc.object(); + const QString editId = obj.value("edit_id").toString(); + const QString filePath = obj.value("file").toString(); + if (editId.isEmpty() || filePath.isEmpty()) + return; + Context::ChangesManager::instance().addFileEdit( + editId, + filePath, + obj.value("old_content").toString(), + obj.value("new_content").toString(), + /*autoApply=*/false, + /*isFromHistory=*/true); +} + +} // namespace + +const QString ChatSerializer::VERSION = "0.3"; + +SerializationResult ChatSerializer::saveToFile( + const ConversationHistory *history, const QString &filePath) +{ + if (!history) + return {false, "No conversation history"}; + if (!ensureDirectoryExists(filePath)) { return {false, "Failed to create directory structure"}; } @@ -28,9 +74,7 @@ SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QSt return {false, QString("Failed to open file for writing: %1").arg(filePath)}; } - QJsonObject root = serializeChat(model, filePath); - QJsonDocument doc(root); - + QJsonDocument doc(serializeChat(history)); if (file.write(doc.toJson(QJsonDocument::Indented)) == -1) { return {false, QString("Failed to write to file: %1").arg(file.errorString())}; } @@ -38,8 +82,12 @@ SerializationResult ChatSerializer::saveToFile(const ChatModel *model, const QSt return {true, QString()}; } -SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString &filePath) +SerializationResult ChatSerializer::loadFromFile( + ConversationHistory *history, const QString &filePath) { + if (!history) + return {false, "No conversation history"}; + QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { return {false, QString("Failed to open file for reading: %1").arg(filePath)}; @@ -51,180 +99,140 @@ SerializationResult ChatSerializer::loadFromFile(ChatModel *model, const QString return {false, QString("JSON parse error: %1").arg(error.errorString())}; } - QJsonObject root = doc.object(); - QString version = root["version"].toString(); - + const QJsonObject root = doc.object(); + const QString version = root["version"].toString(); if (!validateVersion(version)) { return {false, QString("Unsupported version: %1").arg(version)}; } - if (!deserializeChat(model, root, filePath)) { - return {false, "Failed to deserialize chat data"}; - } - - return {true, QString()}; + if (version == VERSION) + return loadCurrent(history, root); + return loadLegacy(history, root); } -QJsonObject ChatSerializer::serializeMessage( - const ChatModel::Message &message, const QString &chatFilePath) -{ - QJsonObject messageObj; - messageObj["role"] = static_cast(message.role); - messageObj["content"] = message.content; - messageObj["id"] = message.id; - - if (message.isRedacted) { - messageObj["isRedacted"] = true; - } - - if (!message.signature.isEmpty()) { - messageObj["signature"] = message.signature; - } - - if (message.role == ChatModel::ChatRole::Tool) { - if (!message.toolName.isEmpty()) - messageObj["toolName"] = message.toolName; - if (!message.toolArguments.isEmpty()) - messageObj["toolArguments"] = message.toolArguments; - if (!message.toolResult.isEmpty()) - messageObj["toolResult"] = message.toolResult; - } - - if (!message.attachments.isEmpty()) { - QJsonArray attachmentsArray; - for (const auto &attachment : message.attachments) { - QJsonObject attachmentObj; - attachmentObj["fileName"] = attachment.filename; - attachmentObj["storedPath"] = attachment.content; - attachmentsArray.append(attachmentObj); - } - messageObj["attachments"] = attachmentsArray; - } - - if (!message.images.isEmpty()) { - QJsonArray imagesArray; - for (const auto &image : message.images) { - QJsonObject imageObj; - imageObj["fileName"] = image.fileName; - imageObj["storedPath"] = image.storedPath; - imageObj["mediaType"] = image.mediaType; - imagesArray.append(imageObj); - } - messageObj["images"] = imagesArray; - } - - if (message.promptTokens > 0 || message.completionTokens > 0) { - QJsonObject usageObj; - usageObj["promptTokens"] = message.promptTokens; - usageObj["completionTokens"] = message.completionTokens; - if (message.cachedPromptTokens > 0) - usageObj["cachedPromptTokens"] = message.cachedPromptTokens; - if (message.reasoningTokens > 0) - usageObj["reasoningTokens"] = message.reasoningTokens; - messageObj["usage"] = usageObj; - } - - return messageObj; -} - -ChatModel::Message ChatSerializer::deserializeMessage( - const QJsonObject &json, const QString &chatFilePath) -{ - ChatModel::Message message; - message.role = static_cast(json["role"].toInt()); - message.content = json["content"].toString(); - message.id = json["id"].toString(); - message.isRedacted = json["isRedacted"].toBool(false); - message.signature = json["signature"].toString(); - message.toolName = json["toolName"].toString(); - message.toolArguments = json["toolArguments"].toObject(); - message.toolResult = json["toolResult"].toString(); - - if (json.contains("attachments")) { - QJsonArray attachmentsArray = json["attachments"].toArray(); - for (const auto &attachmentValue : attachmentsArray) { - QJsonObject attachmentObj = attachmentValue.toObject(); - Context::ContentFile attachment; - attachment.filename = attachmentObj["fileName"].toString(); - attachment.content = attachmentObj["storedPath"].toString(); - message.attachments.append(attachment); - } - } - - if (json.contains("images")) { - QJsonArray imagesArray = json["images"].toArray(); - for (const auto &imageValue : imagesArray) { - QJsonObject imageObj = imageValue.toObject(); - ChatModel::ImageAttachment image; - image.fileName = imageObj["fileName"].toString(); - image.storedPath = imageObj["storedPath"].toString(); - image.mediaType = imageObj["mediaType"].toString(); - message.images.append(image); - } - } - - if (json.contains("usage")) { - const QJsonObject usageObj = json["usage"].toObject(); - message.promptTokens = usageObj["promptTokens"].toInt(); - message.completionTokens = usageObj["completionTokens"].toInt(); - message.cachedPromptTokens = usageObj["cachedPromptTokens"].toInt(); - message.reasoningTokens = usageObj["reasoningTokens"].toInt(); - } - - return message; -} - -QJsonObject ChatSerializer::serializeChat(const ChatModel *model, const QString &chatFilePath) +QJsonObject ChatSerializer::serializeChat(const ConversationHistory *history) { QJsonArray messagesArray; - for (const auto &message : model->getChatHistory()) { - messagesArray.append(serializeMessage(message, chatFilePath)); - } + for (const auto &message : history->messages()) + messagesArray.append(MessageSerializer::toJson(message)); QJsonObject root; root["version"] = VERSION; root["messages"] = messagesArray; - return root; } -bool ChatSerializer::deserializeChat( - ChatModel *model, const QJsonObject &json, const QString &chatFilePath) +SerializationResult ChatSerializer::loadCurrent(ConversationHistory *history, const QJsonObject &root) { - QJsonArray messagesArray = json["messages"].toArray(); - QVector messages; - messages.reserve(messagesArray.size()); + history->clear(); - for (const auto &messageValue : messagesArray) { - messages.append(deserializeMessage(messageValue.toObject(), chatFilePath)); + const QJsonArray messagesArray = root["messages"].toArray(); + for (const auto &value : messagesArray) { + bool ok = false; + Message message = MessageSerializer::fromJson(value.toObject(), &ok); + if (ok) + history->append(std::move(message)); } - model->clear(); + registerHistoricalFileEdits(history); + return {true, QString()}; +} - model->setLoadingFromHistory(true); +SerializationResult ChatSerializer::loadLegacy(ConversationHistory *history, const QJsonObject &root) +{ + history->clear(); - for (const auto &message : messages) { - model->addMessage( - message.content, - message.role, - message.id, - message.attachments, - message.images, - message.isRedacted, - message.signature); - if (message.role == ChatModel::ChatRole::Tool) { - model->setToolMessageData( - message.id, message.toolName, message.toolArguments, message.toolResult); + const QJsonArray arr = root["messages"].toArray(); + int i = 0; + while (i < arr.size()) { + const QJsonObject mj = arr[i].toObject(); + const auto role = static_cast(mj["role"].toInt()); + + if (role == LegacyRole::Tool) { + Message assistant(Message::Role::Assistant); + Message toolResults(Message::Role::User); + while (i < arr.size() + && static_cast(arr[i].toObject()["role"].toInt()) == LegacyRole::Tool) { + const QJsonObject tj = arr[i].toObject(); + const QString toolName = tj["toolName"].toString(); + const QString id = tj["id"].toString(); + if (!toolName.isEmpty()) { + assistant.appendBlock(std::make_unique( + id, toolName, tj["toolArguments"].toObject())); + toolResults.appendBlock(std::make_unique( + id, tj["toolResult"].toString())); + } + ++i; + } + if (!assistant.blocks().empty()) { + history->append(std::move(assistant)); + history->append(std::move(toolResults)); + } + continue; + } + + ++i; + + if (role == LegacyRole::FileEdit) + continue; // derived from the tool result in the new model + + if (role == LegacyRole::Thinking) { + const QString content = mj["content"].toString(); + const QString signature = mj["signature"].toString(); + Message assistant(Message::Role::Assistant); + if (mj["isRedacted"].toBool(false)) { + assistant.appendBlock( + std::make_unique(signature)); + } else { + const int sigPos = content.indexOf(QStringLiteral("\n[Signature:")); + const QString thinking = sigPos >= 0 ? content.left(sigPos) : content; + assistant.appendBlock( + std::make_unique(thinking, signature)); + } + history->append(std::move(assistant)); + continue; + } + + if (role == LegacyRole::User) { + Message user(Message::Role::User, mj["id"].toString()); + user.appendBlock(std::make_unique(mj["content"].toString())); + for (const auto &a : mj["attachments"].toArray()) { + const QJsonObject ao = a.toObject(); + user.appendBlock(std::make_unique( + ao["fileName"].toString(), ao["storedPath"].toString())); + } + for (const auto &im : mj["images"].toArray()) { + const QJsonObject io = im.toObject(); + user.appendBlock(std::make_unique( + io["fileName"].toString(), + io["storedPath"].toString(), + io["mediaType"].toString())); + } + history->append(std::move(user)); + } else { + const QString content = mj["content"].toString(); + if (content.trimmed().isEmpty()) + continue; + const Message::Role mapped + = role == LegacyRole::System ? Message::Role::System : Message::Role::Assistant; + Message message(mapped, mj["id"].toString()); + message.appendBlock(std::make_unique(content)); + history->append(std::move(message)); } - LOG_MESSAGE(QString("Loaded message with %1 image(s), isRedacted=%2, signature length=%3") - .arg(message.images.size()) - .arg(message.isRedacted) - .arg(message.signature.length())); } - model->setLoadingFromHistory(false); + registerHistoricalFileEdits(history); + return {true, QString()}; +} - return true; +void ChatSerializer::registerHistoricalFileEdits(const ConversationHistory *history) +{ + for (const auto &message : history->messages()) { + for (const auto &block : message.blocks()) { + if (auto *tr = dynamic_cast(block.get())) + registerEditFromResult(tr->result()); + } + } } bool ChatSerializer::ensureDirectoryExists(const QString &filePath) @@ -236,18 +244,7 @@ bool ChatSerializer::ensureDirectoryExists(const QString &filePath) bool ChatSerializer::validateVersion(const QString &version) { - if (version == VERSION) { - return true; - } - - if (version == "0.1") { - LOG_MESSAGE( - "Loading chat from old format 0.1 - images folder structure has changed from _images " - "to _content"); - return true; - } - - return false; + return version == VERSION || version == "0.2" || version == "0.1"; } QString ChatSerializer::getChatContentFolder(const QString &chatFilePath) @@ -303,10 +300,20 @@ bool ChatSerializer::saveContentToStorage( return true; } -QString ChatSerializer::loadContentFromStorage(const QString &chatFilePath, const QString &storedPath) +QString ChatSerializer::loadContentFromStorage( + const QString &chatFilePath, const QString &storedPath, StoredContentCache *cache) { - QString contentFolder = getChatContentFolder(chatFilePath); - QString fullPath = QDir(contentFolder).filePath(storedPath); + const QString contentFolder = getChatContentFolder(chatFilePath); + const QString fullPath = QDir(contentFolder).filePath(storedPath); + + const QFileInfo info(fullPath); + if (cache) { + const auto it = cache->constFind(fullPath); + if (it != cache->constEnd() && it->modified == info.lastModified() + && it->size == info.size()) { + return it->base64; + } + } QFile file(fullPath); if (!file.open(QIODevice::ReadOnly)) { @@ -314,10 +321,12 @@ QString ChatSerializer::loadContentFromStorage(const QString &chatFilePath, cons return QString(); } - QByteArray contentData = file.readAll(); - file.close(); + const QString base64 = QString::fromUtf8(file.readAll().toBase64()); - return contentData.toBase64(); + if (cache) + cache->insert(fullPath, {info.lastModified(), info.size(), base64}); + + return base64; } } // namespace QodeAssist::Chat diff --git a/ChatView/ChatSerializer.hpp b/ChatView/ChatSerializer.hpp index 82c1075..37cd528 100644 --- a/ChatView/ChatSerializer.hpp +++ b/ChatView/ChatSerializer.hpp @@ -4,11 +4,14 @@ #pragma once -#include +#include +#include #include #include -#include "ChatModel.hpp" +namespace QodeAssist { +class ConversationHistory; +} namespace QodeAssist::Chat { @@ -18,29 +21,41 @@ struct SerializationResult QString errorMessage; }; +struct StoredContentEntry +{ + QDateTime modified; + qint64 size = 0; + QString base64; +}; + +using StoredContentCache = QHash; + class ChatSerializer { public: - static SerializationResult saveToFile(const ChatModel *model, const QString &filePath); - static SerializationResult loadFromFile(ChatModel *model, const QString &filePath); - - // Public for testing purposes - static QJsonObject serializeMessage(const ChatModel::Message &message, const QString &chatFilePath); - static ChatModel::Message deserializeMessage(const QJsonObject &json, const QString &chatFilePath); - static QJsonObject serializeChat(const ChatModel *model, const QString &chatFilePath); - static bool deserializeChat(ChatModel *model, const QJsonObject &json, const QString &chatFilePath); + static SerializationResult saveToFile( + const ConversationHistory *history, const QString &filePath); + static SerializationResult loadFromFile(ConversationHistory *history, const QString &filePath); // Content management (images and text files) static QString getChatContentFolder(const QString &chatFilePath); - static bool saveContentToStorage(const QString &chatFilePath, - const QString &fileName, - const QString &base64Data, - QString &storedPath); - static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath); + static bool saveContentToStorage( + const QString &chatFilePath, + const QString &fileName, + const QString &base64Data, + QString &storedPath); + static QString loadContentFromStorage( + const QString &chatFilePath, + const QString &storedPath, + StoredContentCache *cache = nullptr); private: static const QString VERSION; - static constexpr int CURRENT_VERSION = 1; + + static QJsonObject serializeChat(const ConversationHistory *history); + static SerializationResult loadCurrent(ConversationHistory *history, const QJsonObject &root); + static SerializationResult loadLegacy(ConversationHistory *history, const QJsonObject &root); + static void registerHistoricalFileEdits(const ConversationHistory *history); static bool ensureDirectoryExists(const QString &filePath); static bool validateVersion(const QString &version); diff --git a/ChatView/ClientInterface.cpp b/ChatView/ClientInterface.cpp index 365233f..ae732e9 100644 --- a/ChatView/ClientInterface.cpp +++ b/ChatView/ClientInterface.cpp @@ -4,73 +4,103 @@ #include "ClientInterface.hpp" -#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include #include +#include +#include #include #include +#include + +#include #include #include -#include #include #include #include #include #include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include +#include +#include +#include +#include +#include +#include +#include +#include #include "tools/ReadOriginalHistoryTool.hpp" #include "tools/TodoTool.hpp" +#include "tools/ToolsRegistration.hpp" -#include "ChatAssistantSettings.hpp" #include "ChatSerializer.hpp" #include "GeneralSettings.hpp" #include "Logger.hpp" #include "ProjectSettings.hpp" -#include "ProvidersManager.hpp" #include "SkillsSettings.hpp" #include "ToolsSettings.hpp" -#include #include +#include #include namespace QodeAssist::Chat { -ClientInterface::ClientInterface( - ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent) +namespace { +struct StoredImage +{ + QString fileName; + QString storedPath; + QString mediaType; +}; +} // namespace + +ClientInterface::ClientInterface(ChatModel *chatModel, QObject *parent) : QObject(parent) - , m_promptProvider(promptProvider) , m_chatModel(chatModel) , m_contextManager(new Context::ContextManager(this)) + , m_contentCache(std::make_shared()) {} -void ClientInterface::setSkillsManager(Skills::SkillsManager *skillsManager) -{ - m_skillsManager = skillsManager; -} - ClientInterface::~ClientInterface() { cancelRequest(); } +void ClientInterface::setSkillsManager(Skills::SkillsManager *skillsManager) +{ + m_skillsManager = skillsManager; +} + +void ClientInterface::setSessionManager(SessionManager *sessionManager) +{ + m_sessionManager = sessionManager; +} + +void ClientInterface::setHistory(ConversationHistory *history) +{ + m_history = history; +} + +void ClientInterface::setActiveAgent(const QString &agentName) +{ + m_activeAgent = agentName; +} + void ClientInterface::sendMessage( const QString &message, const QList &attachments, - const QList &linkedFiles, - bool useTools, - bool useThinking) + const QList &linkedFiles) { if (message.trimmed().isEmpty() && attachments.isEmpty()) { LOG_MESSAGE("Ignoring empty chat message"); @@ -78,19 +108,16 @@ void ClientInterface::sendMessage( } cancelRequest(); - m_accumulatedResponses.clear(); Context::ChangesManager::instance().archiveAllNonArchivedEdits(); QList imageFiles; QList textFiles; - for (const QString &filePath : attachments) { - if (isImageFile(filePath)) { + if (isImageFile(filePath)) imageFiles.append(filePath); - } else { + else textFiles.append(filePath); - } } QList storedAttachments; @@ -112,24 +139,19 @@ void ClientInterface::sendMessage( .arg(textFiles.size())); } - QList imageAttachments; + QList storedImages; if (!imageFiles.isEmpty() && !m_chatFilePath.isEmpty()) { for (const QString &imagePath : imageFiles) { QString base64Data = encodeImageToBase64(imagePath); - if (base64Data.isEmpty()) { + if (base64Data.isEmpty()) continue; - } QString storedPath; QFileInfo fileInfo(imagePath); if (ChatSerializer::saveContentToStorage( m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) { - ChatModel::ImageAttachment imageAttachment; - imageAttachment.fileName = fileInfo.fileName(); - imageAttachment.storedPath = storedPath; - imageAttachment.mediaType = getMediaTypeForImage(imagePath); - imageAttachments.append(imageAttachment); - + storedImages.append( + {fileInfo.fileName(), storedPath, getMediaTypeForImage(imagePath)}); LOG_MESSAGE(QString("Stored image %1 as %2").arg(fileInfo.fileName(), storedPath)); } } @@ -138,369 +160,281 @@ void ClientInterface::sendMessage( .arg(imageFiles.size())); } - m_chatModel->addMessage(message, ChatModel::ChatRole::User, "", storedAttachments, imageAttachments); - - auto &chatAssistantSettings = Settings::chatAssistantSettings(); - - auto providerName = Settings::generalSettings().caProvider(); - auto provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName); - - if (!provider) { - LOG_MESSAGE(QString("No provider found with name: %1").arg(providerName)); + if (!m_sessionManager) { + const QString error = QStringLiteral("Chat session manager is not available"); + LOG_MESSAGE(error); + emit errorOccurred(error); + return; + } + if (!m_history) { + const QString error = QStringLiteral("Chat history is not available"); + LOG_MESSAGE(error); + emit errorOccurred(error); return; } - auto templateName = Settings::generalSettings().caTemplate(); - auto promptTemplate = m_promptProvider->getTemplateByName(templateName); - - if (!promptTemplate) { - LOG_MESSAGE(QString("No template found with name: %1").arg(templateName)); + QString sessionError; + Session *session = m_sessionManager->createSession(m_activeAgent, m_history, &sessionError); + if (!session) { + const QString error = sessionError.isEmpty() + ? QStringLiteral("No chat agent selected") + : sessionError; + LOG_MESSAGE(error); + emit errorOccurred(error); return; } - PluginLLMCore::ContextData context; - - const bool isToolsEnabled = useTools; - - if (chatAssistantSettings.useSystemPrompt()) { - QString systemPrompt = chatAssistantSettings.systemPrompt(); - - const QString lastRoleId = chatAssistantSettings.lastUsedRoleId(); - if (!lastRoleId.isEmpty()) { - const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId); - if (!role.id.isEmpty()) - systemPrompt = systemPrompt + "\n\n" + role.systemPrompt; - } - - auto project = PluginLLMCore::RulesLoader::getActiveProject(); - - if (project) { - systemPrompt += QString("\n# Active project: %1").arg(project->displayName()); - systemPrompt += QString( - "\n# Project source root: %1" - "\n# All new source files, headers, QML and CMake edits MUST be " - "created or modified under this directory. Use absolute paths " - "rooted here, or project-relative paths.") - .arg(project->projectDirectory().toUrlishString()); - - if (auto target = project->activeTarget()) { - if (auto buildConfig = target->activeBuildConfiguration()) { - systemPrompt - += QString( - "\n# Build output directory (compiler artifacts only — do NOT " - "create or edit source files here): %1") - .arg(buildConfig->buildDirectory().toUrlishString()); - } - } - - QString projectRules - = PluginLLMCore::RulesLoader::loadRulesForProject(project, PluginLLMCore::RulesContext::Chat); - - if (!projectRules.isEmpty()) { - systemPrompt += QString("\n# Project Rules\n\n") + projectRules; - } - } else { - systemPrompt += QString("\n# No active project in IDE"); - } - - if (m_skillsManager && Settings::skillsSettings().enableSkills()) { - QStringList projectSkillDirs; - if (project) { - Settings::ProjectSettings projectSettings(project); - projectSkillDirs = Settings::SkillsSettings::splitLines( - projectSettings.projectSkillDirs()); - } - m_skillsManager->configure( - project ? project->projectDirectory().toFSPathString() : QString(), - Settings::SkillsSettings::splitPaths( - Settings::skillsSettings().globalSkillRoots()), - projectSkillDirs); - - const QString alwaysOnSkills = m_skillsManager->alwaysOnBodies(); - if (!alwaysOnSkills.isEmpty()) - systemPrompt += QString("\n\n") + alwaysOnSkills; - - const QString skillsCatalog = m_skillsManager->catalogText(); - if (!skillsCatalog.isEmpty()) - systemPrompt += QString("\n\n") + skillsCatalog; - - static const QRegularExpression skillCommand( - QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)")); - QStringList invokedSkillNames; - auto skillMatch = skillCommand.globalMatch(message); - while (skillMatch.hasNext()) { - const QString skillName = skillMatch.next().captured(1); - if (invokedSkillNames.contains(skillName)) - continue; - const auto invokedSkill = m_skillsManager->findByName(skillName); - if (invokedSkill && !invokedSkill->body.isEmpty()) { - invokedSkillNames << skillName; - systemPrompt += QString("\n\n# Invoked Skill: %1\n\n%2") - .arg(invokedSkill->name, invokedSkill->body); - } - } - } - - if (!linkedFiles.isEmpty()) { - systemPrompt = getSystemPromptWithLinkedFiles(systemPrompt, linkedFiles); - } - context.systemPrompt = systemPrompt; + auto *client = session->client(); + if (!client) { + const QString error = QStringLiteral("Chat agent has no live client"); + LOG_MESSAGE(error); + m_sessionManager->removeSession(session); + emit errorOccurred(error); + return; } - const bool toolHistory = promptTemplate->supportsToolHistory(); + auto *project = ProjectExplorer::ProjectManager::startupProject(); + Templates::ContextRenderer::Bindings bindings; + bindings.projectDir = project ? project->projectDirectory().toFSPathString() : QString(); + bindings.configDir = AgentFactory::userConfigDir(); + session->setContextBindings(bindings); - QVector messages; - int toolCallMsgIdx = -1; - for (const auto &msg : m_chatModel->getChatHistory()) { - if (msg.role == ChatModel::ChatRole::Tool) { - if (!toolHistory || msg.toolName.isEmpty()) { - continue; - } + const QString chatFilePath = m_chatFilePath; + session->setContentLoader([chatFilePath, cache = m_contentCache](const QString &storedPath) { + return ChatSerializer::loadContentFromStorage(chatFilePath, storedPath, cache.get()); + }); - if (toolCallMsgIdx < 0) { - PluginLLMCore::Message assistantCall; - assistantCall.role = "assistant"; - messages.append(assistantCall); - toolCallMsgIdx = messages.size() - 1; - } - - PluginLLMCore::ToolCall call; - call.id = msg.id; - call.name = msg.toolName; - call.arguments = msg.toolArguments; - messages[toolCallMsgIdx].toolCalls.append(call); - - PluginLLMCore::Message toolResult; - toolResult.role = "tool"; - toolResult.toolCallId = msg.id; - toolResult.toolName = msg.toolName; - toolResult.content = msg.toolResult; - messages.append(toolResult); - continue; - } - - toolCallMsgIdx = -1; - - if (msg.role == ChatModel::ChatRole::FileEdit) { - continue; - } - - PluginLLMCore::Message apiMessage; - apiMessage.role = msg.role == ChatModel::ChatRole::User ? "user" : "assistant"; - apiMessage.content = msg.content; - - if (!msg.attachments.isEmpty() && !m_chatFilePath.isEmpty()) { - apiMessage.content += "\n\nAttached files:"; - for (const auto &attachment : msg.attachments) { - QString fileContent = ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.content); - if (!fileContent.isEmpty()) { - QString decodedContent = QString::fromUtf8(QByteArray::fromBase64(fileContent.toUtf8())); - apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```") - .arg(attachment.filename, decodedContent); - } - } - } - - apiMessage.isThinking = (msg.role == ChatModel::ChatRole::Thinking); - apiMessage.isRedacted = msg.isRedacted; - apiMessage.signature = msg.signature; - - if (provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::Image) - && !m_chatFilePath.isEmpty() && !msg.images.isEmpty()) { - auto apiImages = loadImagesFromStorage(msg.images); - if (!apiImages.isEmpty()) { - apiMessage.images = apiImages; - } - } - - messages.append(apiMessage); - } - - if (!imageFiles.isEmpty() - && !provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::Image)) { - LOG_MESSAGE(QString("Provider %1 doesn't support images, %2 ignored") - .arg(provider->name(), QString::number(imageFiles.size()))); - } - - context.history = messages; - - QJsonObject payload{ - {"model", Settings::generalSettings().caModel()}, {"stream", true}}; - - provider->prepareRequest( - payload, - promptTemplate, - context, - PluginLLMCore::RequestType::Chat, - useTools, - useThinking); - - provider->client()->setMaxToolContinuations( - Settings::toolsSettings().maxToolContinuations()); - - provider->client()->setTransferTimeout( + m_sessionManager->toolContributors().contribute(client->tools()); + client->toolLoop()->setMaxRounds(Settings::toolsSettings().maxToolContinuations()); + client->setTransferTimeout( static_cast(Settings::generalSettings().requestTimeout() * 1000)); - connect( - provider->client(), - &::LLMQore::BaseClient::chunkReceived, - this, - &ClientInterface::handlePartialResponse, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::requestCompleted, - this, - &ClientInterface::handleFullResponse, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::requestFinalized, - this, - &ClientInterface::handleRequestFinalized, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::requestFailed, - this, - &ClientInterface::handleRequestFailed, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::toolStarted, - this, - &ClientInterface::handleToolExecutionStarted, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::toolResultReady, - this, - &ClientInterface::handleToolExecutionCompleted, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::thinkingBlockReceived, - this, - &ClientInterface::handleThinkingBlockReceived, - Qt::UniqueConnection); + const QString chatContext = buildChatContextLayer(); + if (!chatContext.isEmpty()) + session->systemPrompt()->setLayer(QStringLiteral("chat.context"), chatContext); - const QString customEndpoint = Settings::generalSettings().caCustomEndpoint(); - const QString endpoint = !customEndpoint.isEmpty() ? customEndpoint - : promptTemplate->endpoint(); - auto requestId - = provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint); - QJsonObject request{{"id", requestId}}; + if (linkedFiles.isEmpty()) { + session->unpinContext(QStringLiteral("chat.files")); + } else { + session->pinContext( + QStringLiteral("chat.files"), + [contextManager = QPointer(m_contextManager), + linkedFiles]() -> QString { + if (!contextManager) + return {}; + const auto contentFiles = contextManager->getContentFiles(linkedFiles); + if (contentFiles.isEmpty()) + return {}; + QString out = QStringLiteral( + "Linked files (current content, refreshed every request):\n"); + for (const auto &file : contentFiles) { + out += QStringLiteral("\nFile: %1\nContent:\n%2\n") + .arg(file.filename, file.content); + } + return out; + }); + } - m_activeRequests[requestId] = {request, provider, !toolHistory}; + std::vector> blocks; + blocks.push_back(std::make_unique(message)); - emit requestStarted(requestId); - - if (provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::Tools) - && provider->toolsManager()) { - if (auto *todoTool = qobject_cast( - provider->toolsManager()->tool("todo_tool"))) { + for (const QString &skillName : invokedSkillNames(message)) { + const auto skill = m_skillsManager->findByName(skillName); + if (skill && !skill->body.isEmpty()) + blocks.push_back(std::make_unique(skill->name, skill->body)); + } + + for (const auto &attachment : storedAttachments) { + blocks.push_back( + std::make_unique(attachment.filename, attachment.content)); + } + + for (const auto &image : storedImages) { + blocks.push_back(std::make_unique( + image.fileName, image.storedPath, image.mediaType)); + } + + if (!m_chatFilePath.isEmpty()) { + if (auto *todoTool + = qobject_cast(client->tools()->tool("todo_tool"))) { todoTool->setCurrentSessionId(m_chatFilePath); } if (auto *historyTool = qobject_cast( - provider->toolsManager()->tool("read_original_history"))) { + client->tools()->tool("read_original_history"))) { historyTool->setCurrentSessionId(m_chatFilePath); } } + + connect(session, &Session::event, this, [this, session](const QodeAssist::ResponseEvent &ev) { + onSessionEvent(session, ev); + }); + connect( + session, &Session::finished, this, + [this](const LLMQore::RequestID &id, const QString &) { onSessionFinished(id); }); + connect( + session, &Session::failed, this, + [this](const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error) { + onSessionFailed(id, error); + }); + + const LLMQore::RequestID requestId = session->send(std::move(blocks)); + if (requestId.isEmpty()) { + const QString error = QStringLiteral("Failed to start chat request for agent '%1': %2") + .arg(m_activeAgent, session->lastError().message); + LOG_MESSAGE(error); + m_sessionManager->removeSession(session); + emit errorOccurred(error); + return; + } + + m_activeRequests[requestId] = {QJsonObject{{"id", requestId}}, session}; + + emit requestStarted(requestId); +} + +QString ClientInterface::requestIdForSession(Session *session) const +{ + for (auto it = m_activeRequests.cbegin(); it != m_activeRequests.cend(); ++it) { + if (it.value().session == session) + return it.key(); + } + return {}; +} + +void ClientInterface::onSessionEvent(Session *session, const QodeAssist::ResponseEvent &ev) +{ + if (ev.kind() != ResponseEvent::Kind::Usage) + return; + + const auto *usage = ev.as(); + if (!usage) + return; + + const QString requestId = requestIdForSession(session); + if (!requestId.isEmpty()) { + m_chatModel->setMessageUsage( + requestId, + usage->inputTokens, + usage->outputTokens, + usage->cachedTokens, + usage->reasoningTokens); + } + + emit messageUsageReceived( + usage->inputTokens, usage->outputTokens, usage->cachedTokens, usage->reasoningTokens); + + LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5") + .arg(requestId) + .arg(usage->inputTokens) + .arg(usage->outputTokens) + .arg(usage->cachedTokens) + .arg(usage->reasoningTokens)); +} + +void ClientInterface::onSessionFinished(const QString &requestId) +{ + auto it = m_activeRequests.find(requestId); + if (it == m_activeRequests.end()) + return; + + Session *session = it.value().session; + + QString applyError; + if (!Context::ChangesManager::instance().applyPendingEditsForRequest(requestId, &applyError)) { + LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2") + .arg(requestId, applyError)); + } + + emit messageReceivedCompletely(); + + m_activeRequests.erase(it); + + if (session && m_sessionManager) + m_sessionManager->removeSession(session); +} + +void ClientInterface::onSessionFailed(const QString &requestId, const QodeAssist::ErrorInfo &error) +{ + auto it = m_activeRequests.find(requestId); + if (it == m_activeRequests.end()) + return; + + Session *session = it.value().session; + + LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error.message)); + emit errorOccurred(error.message); + + m_activeRequests.erase(it); + + if (session && m_sessionManager) + m_sessionManager->removeSession(session); +} + +QStringList ClientInterface::invokedSkillNames(const QString &message) const +{ + QStringList names; + if (!m_skillsManager || !Settings::skillsSettings().enableSkills()) + return names; + + static const QRegularExpression skillCommand( + QStringLiteral("(?:^|\\s)/([a-z0-9][a-z0-9-]*)")); + auto skillMatch = skillCommand.globalMatch(message); + while (skillMatch.hasNext()) { + const QString skillName = skillMatch.next().captured(1); + if (!names.contains(skillName)) + names << skillName; + } + return names; +} + +QString ClientInterface::buildChatContextLayer() const +{ + QString context + = Context::EnvBlockFormatter::formatProject(Context::EnvBlockFormatter::currentProject()); + + auto *project = ProjectExplorer::ProjectManager::startupProject(); + 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()) + context += QString("\n\n") + alwaysOnSkills; + + const QString skillsCatalog = m_skillsManager->catalogText(); + if (!skillsCatalog.isEmpty()) + context += QString("\n\n") + skillsCatalog; + } + + return context; } void ClientInterface::clearMessages() { - const auto providerName = Settings::generalSettings().caProvider(); - auto *provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName); - - if (provider && !m_chatFilePath.isEmpty() - && provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::Tools) - && provider->toolsManager()) { - if (auto *todoTool = qobject_cast( - provider->toolsManager()->tool("todo_tool"))) { - todoTool->clearSession(m_chatFilePath); - } - } - - m_chatModel->clear(); + if (m_history) + m_history->clear(); } void ClientInterface::cancelRequest() { - QSet providers; - for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { - if (it.value().provider) { - providers.insert(it.value().provider); - } - } - - for (auto *provider : providers) { - disconnect(provider->client(), nullptr, this, nullptr); - } - - for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { - const RequestContext &ctx = it.value(); - if (ctx.provider) { - ctx.provider->cancelRequest(it.key()); - } - } - + const auto requests = m_activeRequests; m_activeRequests.clear(); - m_accumulatedResponses.clear(); - m_awaitingContinuation.clear(); - LOG_MESSAGE("All requests cancelled and state cleared"); -} - -void ClientInterface::handleLLMResponse(const QString &response, const QJsonObject &request) -{ - const auto message = response.trimmed(); - - if (!message.isEmpty()) { - QString messageId = request["id"].toString(); - m_chatModel->addMessage(message, ChatModel::ChatRole::Assistant, messageId); - } -} - -QString ClientInterface::getCurrentFileContext() const -{ - auto currentEditor = Core::EditorManager::currentEditor(); - if (!currentEditor) { - LOG_MESSAGE("No active editor found"); - return QString(); + for (auto it = requests.begin(); it != requests.end(); ++it) { + Session *session = it.value().session; + if (session && m_sessionManager) + m_sessionManager->removeSession(session); } - auto textDocument = qobject_cast(currentEditor->document()); - if (!textDocument) { - LOG_MESSAGE("Current document is not a text document"); - return QString(); - } - - QString fileInfo = QString("Language: %1\nFile: %2\n\n") - .arg(textDocument->mimeType(), textDocument->filePath().toFSPathString()); - - QString content = textDocument->document()->toPlainText(); - - LOG_MESSAGE(QString("Got context from file: %1").arg(textDocument->filePath().toFSPathString())); - - return QString("Current file context:\n%1\nFile content:\n%2").arg(fileInfo, content); -} - -QString ClientInterface::getSystemPromptWithLinkedFiles( - const QString &basePrompt, const QList &linkedFiles) const -{ - QString updatedPrompt = basePrompt; - - if (!linkedFiles.isEmpty()) { - updatedPrompt += "\n\nLinked files for reference:\n"; - - auto contentFiles = m_contextManager->getContentFiles(linkedFiles); - for (const auto &file : contentFiles) { - updatedPrompt += QString("\nFile: %1\nContent:\n%2\n").arg(file.filename, file.content); - } - } - - return updatedPrompt; + LOG_MESSAGE("All chat requests cancelled and state cleared"); } Context::ContextManager *ClientInterface::contextManager() const @@ -508,144 +442,6 @@ Context::ContextManager *ClientInterface::contextManager() const return m_contextManager; } -void ClientInterface::handlePartialResponse(const QString &requestId, const QString &partialText) -{ - auto it = m_activeRequests.find(requestId); - if (it == m_activeRequests.end()) - return; - - if (m_awaitingContinuation.remove(requestId)) { - m_accumulatedResponses[requestId].clear(); - LOG_MESSAGE( - QString("Cleared accumulated responses for continuation request %1").arg(requestId)); - } - - m_accumulatedResponses[requestId] += partialText; - - const RequestContext &ctx = it.value(); - handleLLMResponse(m_accumulatedResponses[requestId], ctx.originalRequest); -} - -void ClientInterface::handleFullResponse(const QString &requestId, const QString &fullText) -{ - auto it = m_activeRequests.find(requestId); - if (it == m_activeRequests.end()) - return; - - const RequestContext &ctx = it.value(); - - QString finalText = !fullText.isEmpty() ? fullText : m_accumulatedResponses[requestId]; - - QString applyError; - bool applySuccess - = Context::ChangesManager::instance().applyPendingEditsForRequest(requestId, &applyError); - - if (!applySuccess) { - LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2") - .arg(requestId, applyError)); - } - - LOG_MESSAGE( - "Message completed. Final response for message " + ctx.originalRequest["id"].toString() - + ": " + finalText); - emit messageReceivedCompletely(); - - m_activeRequests.erase(it); - m_accumulatedResponses.remove(requestId); - m_awaitingContinuation.remove(requestId); -} - -void ClientInterface::handleRequestFinalized( - const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info) -{ - if (!m_activeRequests.contains(requestId)) - return; - if (!info.usage) - return; - - const auto &u = *info.usage; - m_chatModel->setMessageUsage( - requestId, u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens); - - emit messageUsageReceived( - u.promptTokens, u.completionTokens, u.cachedPromptTokens, u.reasoningTokens); - - LOG_MESSAGE(QString("Chat usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5") - .arg(requestId) - .arg(u.promptTokens) - .arg(u.completionTokens) - .arg(u.cachedPromptTokens) - .arg(u.reasoningTokens)); -} - -void ClientInterface::handleRequestFailed(const QString &requestId, const QString &error) -{ - auto it = m_activeRequests.find(requestId); - if (it == m_activeRequests.end()) - return; - - LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error)); - emit errorOccurred(error); - - m_activeRequests.erase(it); - m_accumulatedResponses.remove(requestId); - m_awaitingContinuation.remove(requestId); -} - -void ClientInterface::handleThinkingBlockReceived( - const QString &requestId, const QString &thinking, const QString &signature) -{ - if (!m_activeRequests.contains(requestId)) { - LOG_MESSAGE(QString("Ignoring thinking block for non-chat request: %1").arg(requestId)); - return; - } - - if (m_awaitingContinuation.remove(requestId)) { - m_accumulatedResponses[requestId].clear(); - LOG_MESSAGE( - QString("Cleared accumulated responses for continuation request %1").arg(requestId)); - } - - if (thinking.isEmpty()) { - m_chatModel->addRedactedThinkingBlock(requestId, signature); - } else { - m_chatModel->addThinkingBlock(requestId, thinking, signature); - } -} - -void ClientInterface::handleToolExecutionStarted( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QJsonObject &arguments) -{ - const auto requestIt = m_activeRequests.constFind(requestId); - if (requestIt == m_activeRequests.constEnd()) { - LOG_MESSAGE(QString("Ignoring tool execution start for non-chat request: %1").arg(requestId)); - return; - } - - if (requestIt->dropPreToolText) { - m_chatModel->dropTrailingAssistantMessage(requestId); - } - m_chatModel->addToolExecutionStatus(requestId, toolId, toolName, arguments); - m_awaitingContinuation.insert(requestId); -} - -void ClientInterface::handleToolExecutionCompleted( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QString &toolOutput) -{ - if (!m_activeRequests.contains(requestId)) { - LOG_MESSAGE(QString("Ignoring tool execution result for non-chat request: %1").arg(requestId)); - return; - } - - m_chatModel->updateToolResult(requestId, toolId, toolName, toolOutput); -} - bool ClientInterface::isImageFile(const QString &filePath) const { static const QSet imageExtensions = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"}; @@ -693,46 +489,10 @@ QString ClientInterface::encodeImageToBase64(const QString &filePath) const return imageData.toBase64(); } -QVector ClientInterface::loadImagesFromStorage( - const QList &storedImages) const -{ - QVector apiImages; - - for (const auto &storedImage : storedImages) { - QString base64Data - = ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath); - if (base64Data.isEmpty()) { - LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath)); - continue; - } - - PluginLLMCore::ImageAttachment apiImage; - apiImage.data = base64Data; - apiImage.mediaType = storedImage.mediaType; - apiImage.isUrl = false; - - apiImages.append(apiImage); - } - - return apiImages; -} - void ClientInterface::setChatFilePath(const QString &filePath) { - if (!m_chatFilePath.isEmpty() && m_chatFilePath != filePath) { - const auto providerName = Settings::generalSettings().caProvider(); - auto *provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName); - - if (provider - && provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::Tools) - && provider->toolsManager()) { - if (auto *todoTool = qobject_cast( - provider->toolsManager()->tool("todo_tool"))) { - todoTool->clearSession(m_chatFilePath); - } - } - } - + if (m_chatFilePath != filePath) + m_contentCache->clear(); m_chatFilePath = filePath; m_chatModel->setChatFilePath(filePath); } diff --git a/ChatView/ClientInterface.hpp b/ChatView/ClientInterface.hpp index 843a706..e11c465 100644 --- a/ChatView/ClientInterface.hpp +++ b/ChatView/ClientInterface.hpp @@ -5,16 +5,25 @@ #pragma once #include -#include +#include #include -#include +#include + +#include #include "ChatModel.hpp" -#include "Provider.hpp" -#include "pluginllmcore/IPromptProvider.hpp" +#include "ChatSerializer.hpp" +#include #include +#include #include +namespace QodeAssist { +class SessionManager; +class Session; +class ConversationHistory; +} + namespace QodeAssist::Skills { class SkillsManager; } @@ -26,23 +35,23 @@ class ClientInterface : public QObject Q_OBJECT public: - explicit ClientInterface( - ChatModel *chatModel, PluginLLMCore::IPromptProvider *promptProvider, QObject *parent = nullptr); + explicit ClientInterface(ChatModel *chatModel, QObject *parent = nullptr); ~ClientInterface(); void setSkillsManager(Skills::SkillsManager *skillsManager); + void setSessionManager(SessionManager *sessionManager); + void setHistory(ConversationHistory *history); + void setActiveAgent(const QString &agentName); void sendMessage( const QString &message, const QList &attachments = {}, - const QList &linkedFiles = {}, - bool useTools = false, - bool useThinking = false); + const QList &linkedFiles = {}); void clearMessages(); void cancelRequest(); Context::ContextManager *contextManager() const; - + void setChatFilePath(const QString &filePath); QString chatFilePath() const; @@ -53,50 +62,34 @@ signals: void messageUsageReceived( int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens); -private slots: - void handlePartialResponse(const QString &requestId, const QString &partialText); - void handleFullResponse(const QString &requestId, const QString &fullText); - void handleRequestFinalized(const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info); - void handleRequestFailed(const QString &requestId, const QString &error); - void handleThinkingBlockReceived( - const QString &requestId, const QString &thinking, const QString &signature); - void handleToolExecutionStarted( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QJsonObject &arguments); - void handleToolExecutionCompleted( - const QString &requestId, - const QString &toolId, - const QString &toolName, - const QString &toolOutput); - private: - void handleLLMResponse(const QString &response, const QJsonObject &request); - QString getCurrentFileContext() const; - QString getSystemPromptWithLinkedFiles( - const QString &basePrompt, const QList &linkedFiles) const; + void onSessionEvent(Session *session, const QodeAssist::ResponseEvent &ev); + void onSessionFinished(const QString &requestId); + void onSessionFailed(const QString &requestId, const QodeAssist::ErrorInfo &error); + + QStringList invokedSkillNames(const QString &message) const; + QString buildChatContextLayer() const; + QString requestIdForSession(Session *session) const; bool isImageFile(const QString &filePath) const; QString getMediaTypeForImage(const QString &filePath) const; QString encodeImageToBase64(const QString &filePath) const; - QVector loadImagesFromStorage(const QList &storedImages) const; struct RequestContext { QJsonObject originalRequest; - PluginLLMCore::Provider *provider; - bool dropPreToolText = false; + QPointer session; }; - PluginLLMCore::IPromptProvider *m_promptProvider = nullptr; ChatModel *m_chatModel; Context::ContextManager *m_contextManager; + QPointer m_history; Skills::SkillsManager *m_skillsManager = nullptr; + QPointer m_sessionManager; + QString m_activeAgent; QString m_chatFilePath; + std::shared_ptr m_contentCache; QHash m_activeRequests; - QHash m_accumulatedResponses; - QSet m_awaitingContinuation; }; } // namespace QodeAssist::Chat diff --git a/ChatView/FileEditController.cpp b/ChatView/FileEditController.cpp index 689c457..8273871 100644 --- a/ChatView/FileEditController.cpp +++ b/ChatView/FileEditController.cpp @@ -10,15 +10,13 @@ #include #include -#include "ChatModel.hpp" #include "Logger.hpp" #include "context/ChangesManager.h" namespace QodeAssist::Chat { -FileEditController::FileEditController(ChatModel *chatModel, QObject *parent) +FileEditController::FileEditController(QObject *parent) : QObject(parent) - , m_chatModel(chatModel) { auto &changes = Context::ChangesManager::instance(); connect(&changes, &Context::ChangesManager::fileEditAdded, this, [this](const QString &) { @@ -80,7 +78,6 @@ void FileEditController::applyFileEdit(const QString &editId) LOG_MESSAGE(QString("Applying file edit: %1").arg(editId)); if (Context::ChangesManager::instance().applyFileEdit(editId)) { emit infoMessage(QString("File edit applied successfully")); - updateFileEditStatus(editId, "applied"); } else { auto edit = Context::ChangesManager::instance().getFileEdit(editId); emit errorOccurred( @@ -95,7 +92,6 @@ void FileEditController::rejectFileEdit(const QString &editId) LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId)); if (Context::ChangesManager::instance().rejectFileEdit(editId)) { emit infoMessage(QString("File edit rejected")); - updateFileEditStatus(editId, "rejected"); } else { auto edit = Context::ChangesManager::instance().getFileEdit(editId); emit errorOccurred( @@ -110,7 +106,6 @@ void FileEditController::undoFileEdit(const QString &editId) LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId)); if (Context::ChangesManager::instance().undoFileEdit(editId)) { emit infoMessage(QString("File edit undone successfully")); - updateFileEditStatus(editId, "rejected"); } else { auto edit = Context::ChangesManager::instance().getFileEdit(editId); emit errorOccurred( @@ -163,44 +158,6 @@ void FileEditController::openFileEditInEditor(const QString &editId) LOG_MESSAGE(QString("Opened file in editor: %1").arg(edit.filePath)); } -void FileEditController::updateFileEditStatus(const QString &editId, const QString &status) -{ - auto messages = m_chatModel->getChatHistory(); - for (int i = 0; i < messages.size(); ++i) { - if (messages[i].role == Chat::ChatModel::FileEdit && messages[i].id == editId) { - QString content = messages[i].content; - - const QString marker = "QODEASSIST_FILE_EDIT:"; - int markerPos = content.indexOf(marker); - - QString jsonStr = content; - if (markerPos >= 0) { - jsonStr = content.mid(markerPos + marker.length()); - } - - QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8()); - if (doc.isObject()) { - QJsonObject obj = doc.object(); - obj["status"] = status; - - auto edit = Context::ChangesManager::instance().getFileEdit(editId); - if (!edit.statusMessage.isEmpty()) { - obj["status_message"] = edit.statusMessage; - } - - QString updatedContent = marker - + QString::fromUtf8( - QJsonDocument(obj).toJson(QJsonDocument::Compact)); - m_chatModel->updateMessageContent(editId, updatedContent); - LOG_MESSAGE(QString("Updated file edit status to: %1").arg(status)); - } - break; - } - } - - updateStats(); -} - void FileEditController::applyAllForCurrentMessage() { if (m_currentRequestId.isEmpty()) { @@ -223,13 +180,6 @@ void FileEditController::applyAllForCurrentMessage() : QString("Failed to apply some file edits:\n%1").arg(errorMsg)); } - auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId); - for (const auto &edit : edits) { - if (edit.status == Context::ChangesManager::Applied) { - updateFileEditStatus(edit.editId, "applied"); - } - } - updateStats(); } @@ -255,13 +205,6 @@ void FileEditController::undoAllForCurrentMessage() : QString("Failed to undo some file edits:\n%1").arg(errorMsg)); } - auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId); - for (const auto &edit : edits) { - if (edit.status == Context::ChangesManager::Rejected) { - updateFileEditStatus(edit.editId, "rejected"); - } - } - updateStats(); } diff --git a/ChatView/FileEditController.hpp b/ChatView/FileEditController.hpp index 6e7e261..691278a 100644 --- a/ChatView/FileEditController.hpp +++ b/ChatView/FileEditController.hpp @@ -9,14 +9,12 @@ namespace QodeAssist::Chat { -class ChatModel; - class FileEditController : public QObject { Q_OBJECT public: - explicit FileEditController(ChatModel *chatModel, QObject *parent = nullptr); + explicit FileEditController(QObject *parent = nullptr); void setCurrentRequestId(const QString &requestId); void clearCurrentRequestId(); @@ -41,9 +39,6 @@ signals: void errorOccurred(const QString &error); private: - void updateFileEditStatus(const QString &editId, const QString &status); - - ChatModel *m_chatModel; QString m_currentRequestId; int m_totalEdits{0}; int m_appliedEdits{0}; diff --git a/ChatView/FileMentionItem.cpp b/ChatView/FileMentionItem.cpp index e608181..f561298 100644 --- a/ChatView/FileMentionItem.cpp +++ b/ChatView/FileMentionItem.cpp @@ -88,22 +88,6 @@ void FileMentionItem::moveDown() } } -void FileMentionItem::selectCurrent() -{ - if (m_currentIndex < 0 || m_currentIndex >= m_searchResults.size()) - return; - - const QVariantMap item = m_searchResults[m_currentIndex].toMap(); - if (item.value("isProject").toBool()) { - emit projectSelected(item.value("projectName").toString()); - } else { - emit fileSelected( - item.value("absolutePath").toString(), - item.value("relativePath").toString(), - item.value("projectName").toString()); - } -} - void FileMentionItem::dismiss() { m_searchResults.clear(); diff --git a/ChatView/FileMentionItem.hpp b/ChatView/FileMentionItem.hpp index eba52c2..c2701ce 100644 --- a/ChatView/FileMentionItem.hpp +++ b/ChatView/FileMentionItem.hpp @@ -29,7 +29,6 @@ public: Q_INVOKABLE void refreshSearch(); Q_INVOKABLE void moveUp(); Q_INVOKABLE void moveDown(); - Q_INVOKABLE void selectCurrent(); Q_INVOKABLE void dismiss(); Q_INVOKABLE QVariantMap handleFileSelection( diff --git a/ChatView/InputTokenCounter.cpp b/ChatView/InputTokenCounter.cpp index dd0d395..27c0794 100644 --- a/ChatView/InputTokenCounter.cpp +++ b/ChatView/InputTokenCounter.cpp @@ -6,48 +6,21 @@ #include -#include -#include -#include - -#include - -#include "ChatAssistantSettings.hpp" -#include "ChatModel.hpp" -#include "GeneralSettings.hpp" #include "Logger.hpp" -#include "ProvidersManager.hpp" #include "context/ContextManager.hpp" #include "context/TokenUtils.hpp" +#include +#include + namespace QodeAssist::Chat { InputTokenCounter::InputTokenCounter( - ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent) + ConversationHistory *history, Context::ContextManager *contextManager, QObject *parent) : QObject(parent) - , m_chatModel(chatModel) + , m_history(history) , m_contextManager(contextManager) { - auto &settings = Settings::chatAssistantSettings(); - connect( - &settings.useSystemPrompt, - &Utils::BaseAspect::changed, - this, - &InputTokenCounter::recompute); - connect( - &settings.systemPrompt, &Utils::BaseAspect::changed, this, &InputTokenCounter::recompute); - connect( - &settings.enableChatTools, - &Utils::BaseAspect::changed, - this, - &InputTokenCounter::recompute); - - connect(&Settings::generalSettings().caProvider, &Utils::BaseAspect::changed, this, [this]() { - rewireToolsChangedConnection(); - recompute(); - }); - - rewireToolsChangedConnection(); recompute(); } @@ -74,32 +47,9 @@ void InputTokenCounter::setLinkedFiles(const QStringList &linkedFiles) recompute(); } -void InputTokenCounter::rewireToolsChangedConnection() -{ - if (m_toolsChangedConn) - QObject::disconnect(m_toolsChangedConn); - m_toolsChangedConn = {}; - - const auto providerName = Settings::generalSettings().caProvider(); - auto *provider = PluginLLMCore::ProvidersManager::instance().getProviderByName(providerName); - if (!provider) - return; - auto *tm = provider->toolsManager(); - if (!tm) - return; - - m_toolsChangedConn = connect( - tm, &::LLMQore::ToolRegistry::toolsChanged, this, &InputTokenCounter::recompute); -} - void InputTokenCounter::recompute() { int inputTokens = m_messageTokens; - auto &settings = Settings::chatAssistantSettings(); - - if (settings.useSystemPrompt()) { - inputTokens += Context::TokenUtils::estimateTokens(settings.systemPrompt()); - } const auto splitImageEstimate = [](const QStringList &paths, QStringList &textPaths) { int imageTokens = 0; @@ -130,24 +80,10 @@ void InputTokenCounter::recompute() } } - const auto &history = m_chatModel->getChatHistory(); - for (const auto &message : history) { - inputTokens += Context::TokenUtils::estimateTokens(message.content); - inputTokens += 4; // + role - } - - if (settings.enableChatTools()) { - const auto providerName = Settings::generalSettings().caProvider(); - if (auto *provider = PluginLLMCore::ProvidersManager::instance().getProviderByName( - providerName)) { - if (auto *tm = provider->toolsManager()) { - const QJsonArray toolDefs = tm->getToolsDefinitions(); - if (!toolDefs.isEmpty()) { - const QByteArray serialized - = QJsonDocument(toolDefs).toJson(QJsonDocument::Compact); - inputTokens += static_cast(serialized.size() / 4); - } - } + if (m_history) { + for (const auto &message : m_history->messages()) { + inputTokens += Context::TokenUtils::estimateTokens(message.text()); + inputTokens += 4; // + role } } diff --git a/ChatView/InputTokenCounter.hpp b/ChatView/InputTokenCounter.hpp index 2e1ac83..9f79982 100644 --- a/ChatView/InputTokenCounter.hpp +++ b/ChatView/InputTokenCounter.hpp @@ -7,21 +7,25 @@ #include #include +namespace QodeAssist { +class ConversationHistory; +} + namespace QodeAssist::Context { class ContextManager; } namespace QodeAssist::Chat { -class ChatModel; - class InputTokenCounter : public QObject { Q_OBJECT public: InputTokenCounter( - ChatModel *chatModel, Context::ContextManager *contextManager, QObject *parent = nullptr); + ConversationHistory *history, + Context::ContextManager *contextManager, + QObject *parent = nullptr); int inputTokens() const; @@ -37,11 +41,8 @@ signals: void inputTokensChanged(); private: - void rewireToolsChangedConnection(); - - ChatModel *m_chatModel; + ConversationHistory *m_history; Context::ContextManager *m_contextManager; - QMetaObject::Connection m_toolsChangedConn; QStringList m_attachments; QStringList m_linkedFiles; diff --git a/ChatView/icons/context-icon.svg b/ChatView/icons/context-icon.svg deleted file mode 100644 index 4aee188..0000000 --- a/ChatView/icons/context-icon.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/ChatView/icons/rules-icon.svg b/ChatView/icons/rules-icon.svg deleted file mode 100644 index 8277a15..0000000 --- a/ChatView/icons/rules-icon.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 585af60..3314dd4 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -118,7 +118,6 @@ ChatRootView { text: qsTr("Сhat name: %1").arg(root.chatFileName.length > 0 ? root.chatFileName : "Unsaved") } openChatHistory.onClicked: root.openChatHistoryFolder() - contextButton.onClicked: contextViewer.open() pinButton { visible: typeof _chatview !== 'undefined' checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false @@ -138,43 +137,18 @@ ChatRootView { relocateTooltip.text: (typeof _chatview !== 'undefined') ? qsTr("Move this chat to an editor tab") : qsTr("Move this chat to a separate window") - toolsButton { - checked: root.useTools - onCheckedChanged: { - root.useTools = toolsButton.checked - } - } - thinkingMode { - checked: root.useThinking - enabled: root.isThinkingSupport - onCheckedChanged: { - root.useThinking = thinkingMode.checked - } - } settingsButton.onClicked: root.openSettings() - configSelector { - model: root.availableConfigurations - displayText: root.currentConfiguration + agentSelector { + model: root.availableChatAgents + displayText: root.currentChatAgent onActivated: function(index) { - if (index > 0) { - root.applyConfiguration(root.availableConfigurations[index]) - } + root.currentChatAgent = root.availableChatAgents[index] } + Component.onCompleted: root.loadAvailableChatAgents() + popup.onAboutToShow: { - root.loadAvailableConfigurations() - } - } - - roleSelector { - model: root.availableAgentRoles - displayText: root.currentAgentRole - onActivated: function(index) { - root.applyAgentRole(root.availableAgentRoles[index]) - } - - popup.onAboutToShow: { - root.loadAvailableAgentRoles() + root.loadAvailableChatAgents() } } } @@ -593,6 +567,8 @@ ChatRootView { isCompressing: root.isCompressing isProcessing: root.isRequestInProgress + canCompress: root.canCompress + canSend: root.currentChatAgent !== "" sendButton.onClicked: !root.isRequestInProgress ? root.sendChatMessage() : root.cancelRequest() sendButton.icon.source: root.isRequestInProgress @@ -604,9 +580,11 @@ ChatRootView { ? root.errorColor : "transparent" sendButtonTooltip.text: root.isRequestInProgress ? qsTr("Stop") - : (root.hasActiveError - ? root.lastErrorMessage - : qsTr("Send message to LLM %1").arg(root.sendShortcutText)) + : (root.currentChatAgent === "" + ? qsTr("Assign a chat agent in the Pipelines settings") + : (root.hasActiveError + ? root.lastErrorMessage + : qsTr("Send message to LLM %1").arg(root.sendShortcutText))) compressButton.onClicked: compressConfirmDialog.open() cancelCompressButton.onClicked: root.cancelCompression() syncOpenFiles { @@ -831,30 +809,6 @@ ChatRootView { toastTextColor: "#FFFFFF" } - ContextViewer { - id: contextViewer - - width: Math.min(parent.width * 0.85, 800) - height: Math.min(parent.height * 0.85, 700) - x: (parent.width - width) / 2 - y: (parent.height - height) / 2 - - baseSystemPrompt: root.baseSystemPrompt - currentAgentRole: root.currentAgentRole - currentAgentRoleDescription: root.currentAgentRoleDescription - currentAgentRoleSystemPrompt: root.currentAgentRoleSystemPrompt - activeRules: root.activeRules - activeRulesCount: root.activeRulesCount - - onOpenSettings: root.openSettings() - onOpenAgentRolesSettings: root.openAgentRolesSettings() - onOpenRulesFolder: root.openRulesFolder() - onRefreshRules: root.refreshRules() - onRuleSelected: function(index) { - contextViewer.selectedRuleContent = root.getRuleContent(index) - } - } - Connections { target: root function onLastErrorMessageChanged() { diff --git a/ChatView/qml/controls/BottomBar.qml b/ChatView/qml/controls/BottomBar.qml index e831054..b1740b1 100644 --- a/ChatView/qml/controls/BottomBar.qml +++ b/ChatView/qml/controls/BottomBar.qml @@ -21,6 +21,8 @@ Rectangle { property bool isCompressing: false property bool isProcessing: false + property bool canCompress: true + property bool canSend: true property alias sendButtonTooltip: sendButtonTooltipId color: palette.window.hslLightness > 0.5 ? @@ -139,50 +141,78 @@ Rectangle { } } - QoAButton { - id: compressButtonId + Item { + id: compressButtonContainer visible: !root.isCompressing - text: qsTr("Compress") + implicitWidth: compressButtonId.implicitWidth + implicitHeight: compressButtonId.implicitHeight - icon { - source: "qrc:/qt/qml/ChatView/icons/compress-icon.svg" - height: 15 - width: 15 + QoAButton { + id: compressButtonId + + anchors.fill: parent + enabled: root.canCompress + text: qsTr("Compress") + + icon { + source: "qrc:/qt/qml/ChatView/icons/compress-icon.svg" + height: 15 + width: 15 + } + } + + HoverHandler { + id: compressHoverHandler } QoAToolTip { - visible: compressButtonId.hovered + visible: compressHoverHandler.hovered delay: 250 - text: qsTr("Compress chat (create summarized copy using LLM)") + text: root.canCompress + ? qsTr("Compress chat (create summarized copy using LLM)") + : qsTr("Assign a compression agent in the Pipelines settings") } } - QoAButton { - id: sendButtonId + Item { + id: sendButtonContainer - leftPadding: root.isProcessing ? 22 : 4 + implicitWidth: sendButtonId.implicitWidth + implicitHeight: sendButtonId.implicitHeight - icon { - height: 15 - width: 15 + QoAButton { + id: sendButtonId + + anchors.fill: parent + enabled: root.isProcessing || root.canSend + leftPadding: root.isProcessing ? 22 : 4 + + icon { + height: 15 + width: 15 + } + + QoABusyIndicator { + id: sendBusyIndicator + + anchors.left: parent.left + anchors.leftMargin: 5 + anchors.verticalCenter: parent.verticalCenter + width: 14 + height: 14 + running: root.isProcessing + } } - QoABusyIndicator { - id: sendBusyIndicator - - anchors.left: parent.left - anchors.leftMargin: 5 - anchors.verticalCenter: parent.verticalCenter - width: 14 - height: 14 - running: root.isProcessing + HoverHandler { + id: sendHoverHandler } QoAToolTip { id: sendButtonTooltipId - visible: sendButtonId.hovered + visible: sendHoverHandler.hovered delay: 250 } } diff --git a/ChatView/qml/controls/ContextViewer.qml b/ChatView/qml/controls/ContextViewer.qml deleted file mode 100644 index a57723b..0000000 --- a/ChatView/qml/controls/ContextViewer.qml +++ /dev/null @@ -1,543 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import QtQuick.Controls.Basic as QQC - -import UIControls -import ChatView - -Popup { - id: root - - property string baseSystemPrompt - property string currentAgentRole - property string currentAgentRoleDescription - property string currentAgentRoleSystemPrompt - property var activeRules - property int activeRulesCount - property string selectedRuleContent - - signal openSettings() - signal openAgentRolesSettings() - signal openRulesFolder() - signal refreshRules() - signal ruleSelected(int index) - - modal: true - focus: true - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - - background: Rectangle { - color: palette.window - border.color: palette.mid - border.width: 1 - radius: 4 - } - - ChatUtils { - id: utils - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 8 - - RowLayout { - Layout.fillWidth: true - spacing: 10 - - Text { - text: qsTr("Chat Context") - font.pixelSize: 16 - font.bold: true - color: palette.text - Layout.fillWidth: true - } - - QoAButton { - text: qsTr("Refresh") - onClicked: root.refreshRules() - } - - QoAButton { - text: qsTr("Close") - onClicked: root.close() - } - } - - Rectangle { - Layout.fillWidth: true - height: 1 - color: palette.mid - } - - Flickable { - id: mainFlickable - - Layout.fillWidth: true - Layout.fillHeight: true - contentHeight: sectionsColumn.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds - - ColumnLayout { - id: sectionsColumn - - width: mainFlickable.width - spacing: 8 - - CollapsibleSection { - id: systemPromptSection - - Layout.fillWidth: true - title: qsTr("Base System Prompt") - badge: root.baseSystemPrompt.length > 0 ? qsTr("Active") : qsTr("Empty") - badgeColor: root.baseSystemPrompt.length > 0 ? Qt.rgba(0.2, 0.6, 0.3, 1.0) : palette.mid - - sectionContent: ColumnLayout { - spacing: 5 - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: Math.min(Math.max(systemPromptText.implicitHeight + 16, 50), 200) - color: palette.base - border.color: palette.mid - border.width: 1 - radius: 2 - - Flickable { - id: systemPromptFlickable - - anchors.fill: parent - anchors.margins: 8 - contentHeight: systemPromptText.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds - - TextEdit { - id: systemPromptText - - width: systemPromptFlickable.width - text: root.baseSystemPrompt.length > 0 ? root.baseSystemPrompt : qsTr("No system prompt configured") - readOnly: true - selectByMouse: true - wrapMode: Text.WordWrap - color: root.baseSystemPrompt.length > 0 ? palette.text : palette.mid - font.family: "monospace" - font.pixelSize: 11 - } - - QQC.ScrollBar.vertical: QQC.ScrollBar { - policy: systemPromptFlickable.contentHeight > systemPromptFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff - } - } - } - - RowLayout { - Layout.fillWidth: true - - Item { Layout.fillWidth: true } - - QoAButton { - text: qsTr("Copy") - enabled: root.baseSystemPrompt.length > 0 - onClicked: utils.copyToClipboard(root.baseSystemPrompt) - } - - QoAButton { - text: qsTr("Edit in Settings") - onClicked: { - root.openSettings() - root.close() - } - } - } - } - } - - CollapsibleSection { - id: agentRoleSection - - Layout.fillWidth: true - title: qsTr("Agent Role") - badge: root.currentAgentRole - badgeColor: root.currentAgentRoleSystemPrompt.length > 0 ? Qt.rgba(0.3, 0.4, 0.7, 1.0) : palette.mid - - sectionContent: ColumnLayout { - spacing: 8 - - Text { - text: root.currentAgentRoleDescription - font.pixelSize: 11 - font.italic: true - color: palette.mid - wrapMode: Text.WordWrap - Layout.fillWidth: true - visible: root.currentAgentRoleDescription.length > 0 - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: Math.min(Math.max(agentPromptText.implicitHeight + 16, 50), 200) - color: palette.base - border.color: palette.mid - border.width: 1 - radius: 2 - visible: root.currentAgentRoleSystemPrompt.length > 0 - - Flickable { - id: agentPromptFlickable - - anchors.fill: parent - anchors.margins: 8 - contentHeight: agentPromptText.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds - - TextEdit { - id: agentPromptText - - width: agentPromptFlickable.width - text: root.currentAgentRoleSystemPrompt - readOnly: true - selectByMouse: true - wrapMode: Text.WordWrap - color: palette.text - font.family: "monospace" - font.pixelSize: 11 - } - - QQC.ScrollBar.vertical: QQC.ScrollBar { - policy: agentPromptFlickable.contentHeight > agentPromptFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff - } - } - } - - Text { - text: qsTr("No role selected. Using base system prompt only.") - font.pixelSize: 11 - color: palette.mid - wrapMode: Text.WordWrap - Layout.fillWidth: true - visible: root.currentAgentRoleSystemPrompt.length === 0 - } - - RowLayout { - Layout.fillWidth: true - - Item { Layout.fillWidth: true } - - QoAButton { - text: qsTr("Copy") - enabled: root.currentAgentRoleSystemPrompt.length > 0 - onClicked: utils.copyToClipboard(root.currentAgentRoleSystemPrompt) - } - - QoAButton { - text: qsTr("Manage Roles") - onClicked: { - root.openAgentRolesSettings() - root.close() - } - } - } - } - } - - CollapsibleSection { - id: projectRulesSection - - Layout.fillWidth: true - title: qsTr("Project Rules") - badge: root.activeRulesCount > 0 ? qsTr("%1 active").arg(root.activeRulesCount) : qsTr("None") - badgeColor: root.activeRulesCount > 0 ? Qt.rgba(0.6, 0.5, 0.2, 1.0) : palette.mid - - sectionContent: ColumnLayout { - spacing: 8 - - SplitView { - Layout.fillWidth: true - Layout.preferredHeight: 220 - orientation: Qt.Horizontal - visible: root.activeRulesCount > 0 - - Rectangle { - SplitView.minimumWidth: 120 - SplitView.preferredWidth: 180 - color: palette.base - border.color: palette.mid - border.width: 1 - radius: 2 - - ColumnLayout { - anchors.fill: parent - anchors.margins: 5 - spacing: 5 - - Text { - text: qsTr("Rules (%1)").arg(rulesList.count) - font.pixelSize: 11 - font.bold: true - color: palette.text - Layout.fillWidth: true - } - - ListView { - id: rulesList - - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - model: root.activeRules - currentIndex: 0 - boundsBehavior: Flickable.StopAtBounds - - delegate: ItemDelegate { - required property var modelData - required property int index - - width: ListView.view.width - height: ruleItemContent.implicitHeight + 8 - highlighted: ListView.isCurrentItem - - background: Rectangle { - color: { - if (parent.highlighted) - return palette.highlight - if (parent.hovered) - return Qt.tint(palette.base, Qt.rgba(0, 0, 0, 0.05)) - return "transparent" - } - radius: 2 - } - - contentItem: ColumnLayout { - id: ruleItemContent - spacing: 2 - - Text { - text: modelData.fileName - font.pixelSize: 10 - color: parent.parent.highlighted ? palette.highlightedText : palette.text - elide: Text.ElideMiddle - Layout.fillWidth: true - } - - Text { - text: modelData.category - font.pixelSize: 9 - color: parent.parent.highlighted ? palette.highlightedText : palette.mid - Layout.fillWidth: true - } - } - - onClicked: { - rulesList.currentIndex = index - root.ruleSelected(index) - } - } - - QQC.ScrollBar.vertical: QQC.ScrollBar { - policy: rulesList.contentHeight > rulesList.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff - } - } - } - } - - Rectangle { - SplitView.fillWidth: true - SplitView.minimumWidth: 200 - color: palette.base - border.color: palette.mid - border.width: 1 - radius: 2 - - ColumnLayout { - anchors.fill: parent - anchors.margins: 5 - spacing: 5 - - RowLayout { - Layout.fillWidth: true - spacing: 5 - - Text { - text: qsTr("Content") - font.pixelSize: 11 - font.bold: true - color: palette.text - Layout.fillWidth: true - } - - QoAButton { - text: qsTr("Copy") - enabled: root.selectedRuleContent.length > 0 - onClicked: utils.copyToClipboard(root.selectedRuleContent) - } - } - - Flickable { - id: ruleContentFlickable - - Layout.fillWidth: true - Layout.fillHeight: true - contentHeight: ruleContentArea.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds - - TextEdit { - id: ruleContentArea - - width: ruleContentFlickable.width - text: root.selectedRuleContent - readOnly: true - selectByMouse: true - wrapMode: Text.WordWrap - selectionColor: palette.highlight - color: palette.text - font.family: "monospace" - font.pixelSize: 11 - } - - QQC.ScrollBar.vertical: QQC.ScrollBar { - policy: ruleContentFlickable.contentHeight > ruleContentFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff - } - } - } - } - } - - Text { - text: qsTr("No project rules found.\nCreate .md files in .qodeassist/rules/common/ or .qodeassist/rules/chat/") - font.pixelSize: 11 - color: palette.mid - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignHCenter - Layout.fillWidth: true - visible: root.activeRulesCount === 0 - } - - RowLayout { - Layout.fillWidth: true - - Item { Layout.fillWidth: true } - - QoAButton { - text: qsTr("Open Rules Folder") - onClicked: root.openRulesFolder() - } - } - } - } - } - - QQC.ScrollBar.vertical: QQC.ScrollBar { - policy: mainFlickable.contentHeight > mainFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff - } - } - - Rectangle { - Layout.fillWidth: true - height: 1 - color: palette.mid - } - - Text { - text: qsTr("Final prompt: Base System Prompt + Agent Role + Project Info + Project Rules + Linked Files") - font.pixelSize: 9 - color: palette.mid - wrapMode: Text.WordWrap - Layout.fillWidth: true - } - } - - component CollapsibleSection: ColumnLayout { - id: sectionRoot - - property string title - property string badge - property color badgeColor: palette.mid - property Component sectionContent: null - property bool expanded: false - - spacing: 0 - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 32 - color: sectionMouseArea.containsMouse ? Qt.tint(palette.button, Qt.rgba(0, 0, 0, 0.05)) : palette.button - border.color: palette.mid - border.width: 1 - radius: 2 - - MouseArea { - id: sectionMouseArea - - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: sectionRoot.expanded = !sectionRoot.expanded - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 8 - anchors.rightMargin: 8 - spacing: 8 - - Text { - text: sectionRoot.expanded ? "▼" : "▶" - font.pixelSize: 10 - color: palette.text - } - - Text { - text: sectionRoot.title - font.pixelSize: 12 - font.bold: true - color: palette.text - Layout.fillWidth: true - } - - Rectangle { - implicitWidth: badgeText.implicitWidth + 12 - implicitHeight: 18 - color: sectionRoot.badgeColor - radius: 3 - - Text { - id: badgeText - - anchors.centerIn: parent - text: sectionRoot.badge - font.pixelSize: 10 - color: "#FFFFFF" - } - } - } - } - - Loader { - id: contentLoader - - Layout.fillWidth: true - Layout.leftMargin: 12 - Layout.topMargin: 8 - Layout.bottomMargin: 4 - sourceComponent: sectionRoot.sectionContent - visible: sectionRoot.expanded - active: sectionRoot.expanded - } - } - - onOpened: { - if (root.activeRulesCount > 0) { - root.ruleSelected(0) - } - } -} diff --git a/ChatView/qml/controls/TopBar.qml b/ChatView/qml/controls/TopBar.qml index efe792e..f8fd90c 100644 --- a/ChatView/qml/controls/TopBar.qml +++ b/ChatView/qml/controls/TopBar.qml @@ -22,12 +22,8 @@ Rectangle { property alias openChatHistory: openChatHistoryId property alias pinButton: pinButtonId property alias relocateButton: relocateButtonId - property alias contextButton: contextButtonId - property alias toolsButton: toolsButtonId - property alias thinkingMode: thinkingModeId property alias settingsButton: settingsButtonId - property alias configSelector: configSelectorId - property alias roleSelector: roleSelector + property alias agentSelector: agentSelectorId property alias relocateTooltip: relocateTooltipId color: palette.window.hslLightness > 0.5 ? @@ -134,7 +130,7 @@ Rectangle { } QoAComboBox { - id: configSelectorId + id: agentSelectorId implicitHeight: 25 @@ -142,87 +138,17 @@ Rectangle { currentIndex: 0 QoAToolTip { - visible: configSelectorId.hovered + visible: agentSelectorId.hovered delay: 250 - text: qsTr("Switch saved AI configuration") + text: qsTr("Select chat agent (provider and model come from the agent)") } } - QoAComboBox { - id: roleSelector - - implicitHeight: 25 - - model: [] - currentIndex: 0 - - QoAToolTip { - visible: roleSelector.hovered - delay: 250 - text: qsTr("Switch agent role (different system prompts)") - } - } } Row { spacing: 10 - QoAButton { - id: toolsButtonId - - anchors.verticalCenter: parent.verticalCenter - - checkable: true - opacity: enabled ? 1.0 : 0.2 - - icon { - source: checked ? "qrc:/qt/qml/ChatView/icons/tools-icon-on.svg" - : "qrc:/qt/qml/ChatView/icons/tools-icon-off.svg" - color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF" - height: 15 - width: 15 - } - - QoAToolTip { - visible: toolsButtonId.hovered - delay: 250 - text: { - if (!toolsButtonId.enabled) { - return qsTr("Tools are disabled in General Settings") - } - return toolsButtonId.checked - ? qsTr("Tools enabled: AI can use tools to read files, search project, and build code") - : qsTr("Tools disabled: Simple conversation without tool access") - } - } - } - - QoAButton { - id: thinkingModeId - - anchors.verticalCenter: parent.verticalCenter - - checkable: true - opacity: enabled ? 1.0 : 0.2 - - icon { - source: checked ? "qrc:/qt/qml/ChatView/icons/thinking-icon-on.svg" - : "qrc:/qt/qml/ChatView/icons/thinking-icon-off.svg" - color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF" - height: 15 - width: 15 - } - - QoAToolTip { - visible: thinkingModeId.hovered - delay: 250 - text: thinkingModeId.enabled - ? (thinkingModeId.checked ? qsTr("Thinking Mode enabled (Check model list support it)") - : qsTr("Thinking Mode disabled")) - : qsTr("Thinking Mode is not available for this provider") - } - } - QoAButton { id: settingsButtonId @@ -332,23 +258,6 @@ Rectangle { QoASeparator {} - QoAButton { - id: contextButtonId - - icon { - source: "qrc:/qt/qml/ChatView/icons/context-icon.svg" - color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF" - height: 15 - width: 15 - } - - QoAToolTip { - visible: contextButtonId.hovered - delay: 250 - text: qsTr("View chat context (system prompt, role, rules)") - } - } - Badge { id: tokensBadgeId diff --git a/CodeHandler.cpp b/CodeHandler.cpp index 22dccea..9247886 100644 --- a/CodeHandler.cpp +++ b/CodeHandler.cpp @@ -209,24 +209,4 @@ QString CodeHandler::detectLanguageFromExtension(const QString &extension) return extensionToLanguage.value(extension.toLower(), ""); } -const QRegularExpression &CodeHandler::getFullCodeBlockRegex() -{ - static const QRegularExpression - regex(R"(```[\w\s]*\n([\s\S]*?)```)", QRegularExpression::MultilineOption); - return regex; -} - -const QRegularExpression &CodeHandler::getPartialStartBlockRegex() -{ - static const QRegularExpression - regex(R"(```[\w\s]*\n([\s\S]*?)$)", QRegularExpression::MultilineOption); - return regex; -} - -const QRegularExpression &CodeHandler::getPartialEndBlockRegex() -{ - static const QRegularExpression regex(R"(^([\s\S]*?)```)", QRegularExpression::MultilineOption); - return regex; -} - } // namespace QodeAssist diff --git a/CodeHandler.hpp b/CodeHandler.hpp index 6d5cdab..bf79ec6 100644 --- a/CodeHandler.hpp +++ b/CodeHandler.hpp @@ -32,10 +32,6 @@ public: private: static QString getCommentPrefix(const QString &language); - - static const QRegularExpression &getFullCodeBlockRegex(); - static const QRegularExpression &getPartialStartBlockRegex(); - static const QRegularExpression &getPartialEndBlockRegex(); }; } // namespace QodeAssist diff --git a/ConfigurationManager.cpp b/ConfigurationManager.cpp deleted file mode 100644 index 4bdfc18..0000000 --- a/ConfigurationManager.cpp +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "ConfigurationManager.hpp" - -#include -#include - -#include "QodeAssisttr.h" - -namespace QodeAssist { - -ConfigurationManager &ConfigurationManager::instance() -{ - static ConfigurationManager instance; - return instance; -} - -void ConfigurationManager::init() -{ - setupConnections(); - updateAllTemplateDescriptions(); - checkAllTemplate(); -} - -void ConfigurationManager::updateTemplateDescription(const Utils::StringAspect &templateAspect) -{ - PluginLLMCore::PromptTemplate *templ = m_templateManger.getFimTemplateByName(templateAspect.value()); - - if (!templ) { - return; - } - - if (&templateAspect == &m_generalSettings.ccTemplate) { - m_generalSettings.ccTemplateDescription.setValue(templ->description()); - } else if (&templateAspect == &m_generalSettings.caTemplate) { - m_generalSettings.caTemplateDescription.setValue(templ->description()); - } else if (&templateAspect == &m_generalSettings.qrTemplate) { - m_generalSettings.qrTemplateDescription.setValue(templ->description()); - } -} - -void ConfigurationManager::updateAllTemplateDescriptions() -{ - updateTemplateDescription(m_generalSettings.ccTemplate); - updateTemplateDescription(m_generalSettings.caTemplate); - updateTemplateDescription(m_generalSettings.qrTemplate); -} - -void ConfigurationManager::checkTemplate(const Utils::StringAspect &templateAspect) -{ - PluginLLMCore::PromptTemplate *templ = m_templateManger.getFimTemplateByName(templateAspect.value()); - - if (templ->name() == templateAspect.value()) - return; - - if (&templateAspect == &m_generalSettings.ccTemplate) { - m_generalSettings.ccTemplate.setValue(templ->name()); - } else if (&templateAspect == &m_generalSettings.caTemplate) { - m_generalSettings.caTemplate.setValue(templ->name()); - } -} - -void ConfigurationManager::checkAllTemplate() -{ - checkTemplate(m_generalSettings.ccTemplate); - checkTemplate(m_generalSettings.caTemplate); -} - -ConfigurationManager::ConfigurationManager(QObject *parent) - : QObject(parent) - , m_generalSettings(Settings::generalSettings()) - , m_providersManager(PluginLLMCore::ProvidersManager::instance()) - , m_templateManger(PluginLLMCore::PromptTemplateManager::instance()) -{} - -void ConfigurationManager::setupConnections() -{ - using Config = ConfigurationManager; - using Button = ButtonAspect; - - connect(&m_generalSettings.ccSelectProvider, &Button::clicked, this, &Config::selectProvider); - connect(&m_generalSettings.caSelectProvider, &Button::clicked, this, &Config::selectProvider); - connect(&m_generalSettings.qrSelectProvider, &Button::clicked, this, &Config::selectProvider); - connect(&m_generalSettings.ccSelectModel, &Button::clicked, this, &Config::selectModel); - connect(&m_generalSettings.caSelectModel, &Button::clicked, this, &Config::selectModel); - connect(&m_generalSettings.qrSelectModel, &Button::clicked, this, &Config::selectModel); - connect(&m_generalSettings.ccSelectTemplate, &Button::clicked, this, &Config::selectTemplate); - connect(&m_generalSettings.caSelectTemplate, &Button::clicked, this, &Config::selectTemplate); - connect(&m_generalSettings.qrSelectTemplate, &Button::clicked, this, &Config::selectTemplate); - connect(&m_generalSettings.ccSetUrl, &Button::clicked, this, &Config::selectUrl); - connect(&m_generalSettings.caSetUrl, &Button::clicked, this, &Config::selectUrl); - connect(&m_generalSettings.qrSetUrl, &Button::clicked, this, &Config::selectUrl); - - connect( - &m_generalSettings.ccPreset1SelectProvider, &Button::clicked, this, &Config::selectProvider); - connect(&m_generalSettings.ccPreset1SetUrl, &Button::clicked, this, &Config::selectUrl); - connect(&m_generalSettings.ccPreset1SelectModel, &Button::clicked, this, &Config::selectModel); - connect( - &m_generalSettings.ccPreset1SelectTemplate, &Button::clicked, this, &Config::selectTemplate); - - connect(&m_generalSettings.ccTemplate, &Utils::StringAspect::changed, this, [this]() { - updateTemplateDescription(m_generalSettings.ccTemplate); - }); - - connect(&m_generalSettings.caTemplate, &Utils::StringAspect::changed, this, [this]() { - updateTemplateDescription(m_generalSettings.caTemplate); - }); - - connect(&m_generalSettings.qrTemplate, &Utils::StringAspect::changed, this, [this]() { - updateTemplateDescription(m_generalSettings.qrTemplate); - }); -} - -void ConfigurationManager::selectProvider() -{ - const auto providersList = m_providersManager.providersNames(); - - auto *settingsButton = qobject_cast(sender()); - if (!settingsButton) - return; - - auto &targetSettings = (settingsButton == &m_generalSettings.ccSelectProvider) - ? m_generalSettings.ccProvider - : settingsButton == &m_generalSettings.ccPreset1SelectProvider - ? m_generalSettings.ccPreset1Provider - : settingsButton == &m_generalSettings.qrSelectProvider - ? m_generalSettings.qrProvider - : m_generalSettings.caProvider; - - QTimer::singleShot(0, this, [this, providersList, &targetSettings] { - m_generalSettings.showSelectionDialog( - providersList, targetSettings, Tr::tr("Select LLM Provider"), Tr::tr("Providers:")); - }); -} - -void ConfigurationManager::selectModel() -{ - auto *settingsButton = qobject_cast(sender()); - if (!settingsButton) - return; - - const bool isCodeCompletion = (settingsButton == &m_generalSettings.ccSelectModel); - const bool isPreset1 = (settingsButton == &m_generalSettings.ccPreset1SelectModel); - const bool isQuickRefactor = (settingsButton == &m_generalSettings.qrSelectModel); - - const QString providerName = isCodeCompletion ? m_generalSettings.ccProvider.volatileValue() - : isPreset1 ? m_generalSettings.ccPreset1Provider.volatileValue() - : isQuickRefactor ? m_generalSettings.qrProvider.volatileValue() - : m_generalSettings.caProvider.volatileValue(); - - const auto providerUrl = isCodeCompletion ? m_generalSettings.ccUrl.volatileValue() - : isPreset1 ? m_generalSettings.ccPreset1Url.volatileValue() - : isQuickRefactor ? m_generalSettings.qrUrl.volatileValue() - : m_generalSettings.caUrl.volatileValue(); - - auto *targetSettings = &(isCodeCompletion ? m_generalSettings.ccModel - : isPreset1 ? m_generalSettings.ccPreset1Model - : isQuickRefactor ? m_generalSettings.qrModel - : m_generalSettings.caModel); - - if (auto provider = m_providersManager.getProviderByName(providerName)) { - if (!provider->capabilities().testFlag(PluginLLMCore::ProviderCapability::ModelListing)) { - m_generalSettings.showModelsNotSupportedDialog(*targetSettings); - return; - } - - provider->getInstalledModels(providerUrl) - .then(this, [this, targetSettings](const QList &modelList) { - if (modelList.isEmpty()) { - m_generalSettings.showModelsNotFoundDialog(*targetSettings); - return; - } - m_generalSettings.showSelectionDialog( - modelList, *targetSettings, Tr::tr("Select LLM Model"), Tr::tr("Models:")); - }); - } -} - -void ConfigurationManager::selectTemplate() -{ - auto *settingsButton = qobject_cast(sender()); - if (!settingsButton) - return; - - const bool isCodeCompletion = (settingsButton == &m_generalSettings.ccSelectTemplate); - const bool isPreset1 = (settingsButton == &m_generalSettings.ccPreset1SelectTemplate); - const bool isQuickRefactor = (settingsButton == &m_generalSettings.qrSelectTemplate); - const QString providerName = isCodeCompletion ? m_generalSettings.ccProvider.volatileValue() - : isPreset1 ? m_generalSettings.ccPreset1Provider.volatileValue() - : isQuickRefactor ? m_generalSettings.qrProvider.volatileValue() - : m_generalSettings.caProvider.volatileValue(); - auto providerID = m_providersManager.getProviderByName(providerName)->providerID(); - - const auto templateList = isCodeCompletion || isPreset1 - ? m_templateManger.getFimTemplatesForProvider(providerID) - : m_templateManger.getChatTemplatesForProvider(providerID); - - auto &targetSettings = isCodeCompletion ? m_generalSettings.ccTemplate - : isPreset1 ? m_generalSettings.ccPreset1Template - : isQuickRefactor ? m_generalSettings.qrTemplate - : m_generalSettings.caTemplate; - - QTimer::singleShot(0, &m_generalSettings, [this, templateList, &targetSettings]() { - m_generalSettings.showSelectionDialog( - templateList, targetSettings, Tr::tr("Select Template"), Tr::tr("Templates:")); - }); -} - -void ConfigurationManager::selectUrl() -{ - auto *settingsButton = qobject_cast(sender()); - if (!settingsButton) - return; - - QStringList urls; - for (const auto &name : m_providersManager.providersNames()) { - const auto url = m_providersManager.getProviderByName(name)->url(); - if (!urls.contains(url)) - urls.append(url); - } - - auto &targetSettings = (settingsButton == &m_generalSettings.ccSetUrl) ? m_generalSettings.ccUrl - : settingsButton == &m_generalSettings.ccPreset1SetUrl - ? m_generalSettings.ccPreset1Url - : settingsButton == &m_generalSettings.qrSetUrl - ? m_generalSettings.qrUrl - : m_generalSettings.caUrl; - - QTimer::singleShot(0, &m_generalSettings, [this, urls, &targetSettings]() { - m_generalSettings.showUrlSelectionDialog(targetSettings, urls); - }); -} - -} // namespace QodeAssist diff --git a/ConfigurationManager.hpp b/ConfigurationManager.hpp deleted file mode 100644 index 0cd10b1..0000000 --- a/ConfigurationManager.hpp +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include "pluginllmcore/PromptTemplateManager.hpp" -#include "pluginllmcore/ProvidersManager.hpp" -#include "settings/GeneralSettings.hpp" - -namespace QodeAssist { - -class ConfigurationManager : public QObject -{ - Q_OBJECT - -public: - static ConfigurationManager &instance(); - - void init(); - - void updateTemplateDescription(const Utils::StringAspect &templateAspect); - void updateAllTemplateDescriptions(); - void checkTemplate(const Utils::StringAspect &templateAspect); - void checkAllTemplate(); - -public slots: - void selectProvider(); - void selectModel(); - void selectTemplate(); - void selectUrl(); - -private: - explicit ConfigurationManager(QObject *parent = nullptr); - ~ConfigurationManager() = default; - ConfigurationManager(const ConfigurationManager &) = delete; - ConfigurationManager &operator=(const ConfigurationManager &) = delete; - - Settings::GeneralSettings &m_generalSettings; - PluginLLMCore::ProvidersManager &m_providersManager; - PluginLLMCore::PromptTemplateManager &m_templateManger; - - void setupConnections(); -}; - -} // namespace QodeAssist diff --git a/LLMClientInterface.cpp b/LLMClientInterface.cpp index 4938f4e..0ffb265 100644 --- a/LLMClientInterface.cpp +++ b/LLMClientInterface.cpp @@ -5,36 +5,57 @@ #include "LLMClientInterface.hpp" #include +#include +#include #include #include #include +#include +#include +#include + +#include "sources/common/ContextData.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + #include "CodeHandler.hpp" #include "context/DocumentContextReader.hpp" #include "context/Utils.hpp" #include "logger/Logger.hpp" #include "settings/CodeCompletionSettings.hpp" #include "settings/GeneralSettings.hpp" -#include +#include "sources/settings/PipelinesConfig.hpp" namespace QodeAssist { LLMClientInterface::LLMClientInterface( const Settings::GeneralSettings &generalSettings, const Settings::CodeCompletionSettings &completeSettings, - PluginLLMCore::IProviderRegistry &providerRegistry, - PluginLLMCore::IPromptProvider *promptProvider, + AgentFactory &agentFactory, + SessionManager &sessionManager, Context::IDocumentReader &documentReader, IRequestPerformanceLogger &performanceLogger) : m_generalSettings(generalSettings) , m_completeSettings(completeSettings) - , m_providerRegistry(providerRegistry) - , m_promptProvider(promptProvider) + , m_agentFactory(agentFactory) + , m_sessionManager(sessionManager) , m_documentReader(documentReader) , m_performanceLogger(performanceLogger) , m_contextManager(new Context::ContextManager(this)) -{ -} +{} LLMClientInterface::~LLMClientInterface() { @@ -51,58 +72,56 @@ void LLMClientInterface::startImpl() emit started(); } -void LLMClientInterface::handleFullResponse(const QString &requestId, const QString &fullText) +void LLMClientInterface::onCompletionFinished(const QString &requestId) { auto it = m_activeRequests.find(requestId); if (it == m_activeRequests.end()) return; - const RequestContext &ctx = it.value(); - sendCompletionToClient(fullText, ctx.originalRequest, true); + QString fullText; + if (Session *session = it.value().session) { + if (auto *history = session->history(); history && !history->isEmpty()) + fullText = history->messages().back().text(); + } + const QJsonObject originalRequest = it.value().originalRequest; - m_activeRequests.erase(it); - m_performanceLogger.endTimeMeasurement(requestId); + sendCompletionToClient(fullText, originalRequest, true); + finishRequest(requestId); } -void LLMClientInterface::handleRequestFinalized( - const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info) -{ - if (!m_activeRequests.contains(requestId) || !info.usage) - return; - - const auto &u = *info.usage; - LOG_MESSAGE(QString("Completion usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5") - .arg(requestId) - .arg(u.promptTokens) - .arg(u.completionTokens) - .arg(u.cachedPromptTokens) - .arg(u.reasoningTokens)); -} - -void LLMClientInterface::handleRequestFailed(const QString &requestId, const QString &error) +void LLMClientInterface::onCompletionFailed(const QString &requestId, const QString &error) { auto it = m_activeRequests.find(requestId); if (it == m_activeRequests.end()) return; - const RequestContext &ctx = it.value(); - LOG_MESSAGE(QString("Request %1 failed: %2").arg(requestId, error)); - // Send LSP error response to client QJsonObject response; response["jsonrpc"] = "2.0"; - response[LanguageServerProtocol::idKey] = ctx.originalRequest["id"]; + response[LanguageServerProtocol::idKey] = it.value().originalRequest["id"]; QJsonObject errorObject; errorObject["code"] = -32603; // Internal error code errorObject["message"] = error; response["error"] = errorObject; - + emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); - + finishRequest(requestId); +} + +void LLMClientInterface::finishRequest(const QString &requestId) +{ + auto it = m_activeRequests.find(requestId); + if (it == m_activeRequests.end()) + return; + + Session *session = it.value().session; m_activeRequests.erase(it); m_performanceLogger.endTimeMeasurement(requestId); + + if (session) + m_sessionManager.release(session); } void LLMClientInterface::sendData(const QByteArray &data) @@ -135,26 +154,15 @@ void LLMClientInterface::sendData(const QByteArray &data) void LLMClientInterface::handleCancelRequest() { - QSet providers; - for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { - if (it.value().provider) { - providers.insert(it.value().provider); - } - } - - for (auto *provider : providers) { - disconnect(provider->client(), nullptr, this, nullptr); - } - - for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) { - const RequestContext &ctx = it.value(); - if (ctx.provider) { - ctx.provider->cancelRequest(it.key()); - } - } - + const auto requests = m_activeRequests; m_activeRequests.clear(); + for (auto it = requests.begin(); it != requests.end(); ++it) { + m_performanceLogger.endTimeMeasurement(it.key()); + if (Session *session = it.value().session) + m_sessionManager.release(session); + } + LOG_MESSAGE("All requests cancelled and state cleared"); } @@ -193,34 +201,19 @@ void LLMClientInterface::handleShutdown(const QJsonObject &request) void LLMClientInterface::handleTextDocumentDidOpen(const QJsonObject &request) {} -void LLMClientInterface::handleInitialized(const QJsonObject &request) -{ - QJsonObject response; - response["jsonrpc"] = "2.0"; - response["method"] = "initialized"; - response["params"] = QJsonObject(); - - emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); -} - -void LLMClientInterface::handleExit(const QJsonObject &request) -{ - emit finished(); -} - void LLMClientInterface::sendErrorResponse(const QJsonObject &request, const QString &errorMessage) { QJsonObject response; response["jsonrpc"] = "2.0"; response[LanguageServerProtocol::idKey] = request["id"]; - + QJsonObject errorObject; errorObject["code"] = -32603; // Internal error code errorObject["message"] = errorMessage; response["error"] = errorObject; - + emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); - + // End performance measurement if it was started QString requestId = request["id"].toString(); m_performanceLogger.endTimeMeasurement(requestId); @@ -237,133 +230,103 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request) return; } - auto updatedContext = prepareContext(request, documentInfo); - - bool isPreset1Active = m_contextManager->isSpecifyCompletion(documentInfo); - - const auto providerName = !isPreset1Active ? m_generalSettings.ccProvider() - : m_generalSettings.ccPreset1Provider(); - const auto modelName = !isPreset1Active ? m_generalSettings.ccModel() - : m_generalSettings.ccPreset1Model(); - const auto url = !isPreset1Active ? m_generalSettings.ccUrl() - : m_generalSettings.ccPreset1Url(); - - const auto provider = m_providerRegistry.getProviderByName(providerName); - - if (!provider) { - QString error = QString("No provider found with name: %1").arg(providerName); + const QString agentName = pickCompletionAgent(filePath); + if (agentName.isEmpty()) { + QString error = QString("No code completion agent matches: %1").arg(filePath); LOG_MESSAGE(error); sendErrorResponse(request, error); return; } - auto templateName = !isPreset1Active ? m_generalSettings.ccTemplate() - : m_generalSettings.ccPreset1Template(); + QString sessionError; + Session *session = m_sessionManager.acquire(agentName, &sessionError); + if (!session) { + LOG_MESSAGE(sessionError); + sendErrorResponse(request, sessionError); + return; + } - auto promptTemplate = m_promptProvider->getTemplateByName(templateName); + Templates::ContextRenderer::Bindings bindings; + if (auto *project = ProjectExplorer::ProjectManager::projectForFile( + Utils::FilePath::fromString(filePath))) + bindings.projectDir = project->projectDirectory().toFSPathString(); + bindings.configDir = AgentFactory::userConfigDir(); + bindings.language = CodeHandler::detectLanguageFromExtension(QFileInfo(filePath).suffix()); + session->setContextBindings(bindings); - if (!promptTemplate) { - QString error = QString("No template found with name: %1").arg(templateName); + Templates::ContextData context = prepareContext(request, documentInfo); + + QString editorContext; + if (context.fileContext.has_value()) + editorContext.append(context.fileContext.value()); + + if (m_completeSettings.useOpenFilesContext()) + editorContext.append(m_contextManager->openedFilesContext({filePath})); + + if (!editorContext.isEmpty()) + session->systemPrompt()->setLayer(QStringLiteral("completion.context"), editorContext); + + connect( + session, + &Session::finished, + this, + [this, session](const LLMQore::RequestID &, const QString &) { + onCompletionFinished(requestIdForSession(session)); + }); + connect( + session, + &Session::failed, + this, + [this, session](const LLMQore::RequestID &, const QodeAssist::ErrorInfo &error) { + onCompletionFailed(requestIdForSession(session), error.message); + }); + + if (auto *client = session->client()) + client->setTransferTimeout(static_cast(m_generalSettings.requestTimeout() * 1000)); + + std::vector> blocks; + blocks.push_back( + std::make_unique( + context.prefix.value_or(QString()), context.suffix.value_or(QString()))); + const LLMQore::RequestID requestId = session->send(std::move(blocks)); + if (requestId.isEmpty()) { + QString error = QString("Failed to start completion request for agent '%1': %2") + .arg(agentName, session->lastError().message); + session->deleteLater(); LOG_MESSAGE(error); sendErrorResponse(request, error); return; } - QJsonObject payload{{"model", modelName}, {"stream", true}}; - - const auto stopWords = QJsonArray::fromStringList(promptTemplate->stopWords()); - if (!stopWords.isEmpty()) - payload["stop"] = stopWords; - - QString systemPrompt; - if (m_completeSettings.useSystemPrompt()) - systemPrompt.append( - m_completeSettings.useUserMessageTemplateForCC() - && promptTemplate->type() == PluginLLMCore::TemplateType::Chat - ? m_completeSettings.systemPromptForNonFimModels() - : m_completeSettings.systemPrompt()); - - auto project = PluginLLMCore::RulesLoader::getActiveProject(); - if (project) { - QString projectRules - = PluginLLMCore::RulesLoader::loadRulesForProject(project, PluginLLMCore::RulesContext::Completions); - - if (!projectRules.isEmpty()) { - systemPrompt += "\n\n# Project Rules\n\n" + projectRules; - LOG_MESSAGE("Loaded project rules for completion"); - } - } - - if (updatedContext.fileContext.has_value()) - systemPrompt.append(updatedContext.fileContext.value()); - - if (m_completeSettings.useOpenFilesContext()) { - if (provider->providerID() == PluginLLMCore::ProviderID::LlamaCpp) { - for (const auto openedFilePath : m_contextManager->openedFiles({filePath})) { - if (!updatedContext.filesMetadata) { - updatedContext.filesMetadata = QList(); - } - updatedContext.filesMetadata->append({openedFilePath.first, openedFilePath.second}); - } - } else { - systemPrompt.append(m_contextManager->openedFilesContext({filePath})); - } - } - - updatedContext.systemPrompt = systemPrompt; - - if (promptTemplate->type() == PluginLLMCore::TemplateType::Chat) { - QString userMessage; - if (m_completeSettings.useUserMessageTemplateForCC()) { - userMessage = m_completeSettings.processMessageToFIM( - updatedContext.prefix.value_or(""), updatedContext.suffix.value_or("")); - } else { - userMessage = updatedContext.prefix.value_or("") + updatedContext.suffix.value_or(""); - } - - // TODO refactor add message - QVector messages; - messages.append({"user", userMessage}); - updatedContext.history = messages; - } - - provider->prepareRequest( - payload, - promptTemplate, - updatedContext, - PluginLLMCore::RequestType::CodeCompletion, - false, - false); - - connect( - provider->client(), - &::LLMQore::BaseClient::requestCompleted, - this, - &LLMClientInterface::handleFullResponse, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::requestFinalized, - this, - &LLMClientInterface::handleRequestFinalized, - Qt::UniqueConnection); - connect( - provider->client(), - &::LLMQore::BaseClient::requestFailed, - this, - &LLMClientInterface::handleRequestFailed, - Qt::UniqueConnection); - - provider->client()->setTransferTimeout( - static_cast(m_generalSettings.requestTimeout() * 1000)); - - auto requestId - = provider->sendRequest(QUrl(url), payload, resolveEndpoint(promptTemplate, isPreset1Active)); - m_activeRequests[requestId] = {request, provider}; + m_activeRequests[requestId] = {request, session}; m_performanceLogger.startTimeMeasurement(requestId); } -PluginLLMCore::ContextData LLMClientInterface::prepareContext( +QString LLMClientInterface::pickCompletionAgent(const QString &filePath) const +{ + const QStringList roster = Settings::PipelinesConfig::load().rosters.codeCompletion; + if (roster.isEmpty()) + return {}; + + AgentRouter::Context ctx; + ctx.filePath = filePath; + if (auto *project = ProjectExplorer::ProjectManager::projectForFile( + Utils::FilePath::fromString(filePath))) + ctx.projectName = project->displayName(); + + return AgentRouter::pickAgent(roster, ctx, m_agentFactory); +} + +QString LLMClientInterface::requestIdForSession(Session *session) const +{ + for (auto it = m_activeRequests.cbegin(); it != m_activeRequests.cend(); ++it) { + if (it.value().session == session) + return it.key(); + } + return {}; +} + +Templates::ContextData LLMClientInterface::prepareContext( const QJsonObject &request, const Context::DocumentInfo &documentInfo) { QJsonObject params = request["params"].toObject(); @@ -377,14 +340,6 @@ PluginLLMCore::ContextData LLMClientInterface::prepareContext( return reader.prepareContext(lineNumber, cursorPosition, m_completeSettings); } -QString LLMClientInterface::resolveEndpoint( - PluginLLMCore::PromptTemplate *promptTemplate, bool isLanguageSpecify) const -{ - const QString custom = isLanguageSpecify ? m_generalSettings.ccPreset1CustomEndpoint() - : m_generalSettings.ccCustomEndpoint(); - return !custom.isEmpty() ? custom : promptTemplate->endpoint(); -} - Context::ContextManager *LLMClientInterface::contextManager() const { return m_contextManager; @@ -393,15 +348,6 @@ Context::ContextManager *LLMClientInterface::contextManager() const void LLMClientInterface::sendCompletionToClient( const QString &completion, const QJsonObject &request, bool isComplete) { - auto filePath = Context::extractFilePathFromRequest(request); - auto documentInfo = m_documentReader.readDocument(filePath); - bool isPreset1Active = m_contextManager->isSpecifyCompletion(documentInfo); - - auto templateName = !isPreset1Active ? m_generalSettings.ccTemplate() - : m_generalSettings.ccPreset1Template(); - - auto promptTemplate = m_promptProvider->getTemplateByName(templateName); - QJsonObject position = request["params"].toObject()["doc"].toObject()["position"].toObject(); QJsonObject response; @@ -420,14 +366,13 @@ void LLMClientInterface::sendCompletionToClient( if (outputHandler == "Raw text") { processedCompletion = completion; } else if (outputHandler == "Force processing") { - processedCompletion = CodeHandler::processText(completion, - Context::extractFilePathFromRequest(request)); + processedCompletion + = CodeHandler::processText(completion, Context::extractFilePathFromRequest(request)); } else { // "Auto" - processedCompletion = CodeHandler::hasCodeBlocks(completion) - ? CodeHandler::processText(completion, - Context::extractFilePathFromRequest( - request)) - : completion; + processedCompletion + = CodeHandler::hasCodeBlocks(completion) + ? CodeHandler::processText(completion, Context::extractFilePathFromRequest(request)) + : completion; } if (processedCompletion.endsWith('\n')) { @@ -439,11 +384,11 @@ void LLMClientInterface::sendCompletionToClient( } completionItem[LanguageServerProtocol::textKey] = processedCompletion; - + QJsonObject range; range["start"] = position; range["end"] = position; - + completionItem[LanguageServerProtocol::rangeKey] = range; completionItem[LanguageServerProtocol::positionKey] = position; completions.append(completionItem); diff --git a/LLMClientInterface.hpp b/LLMClientInterface.hpp index 5eb223e..9973769 100644 --- a/LLMClientInterface.hpp +++ b/LLMClientInterface.hpp @@ -8,12 +8,11 @@ #include #include +#include + #include #include #include -#include -#include -#include #include #include #include @@ -23,6 +22,14 @@ class QNetworkAccessManager; namespace QodeAssist { +class AgentFactory; +class Session; +class SessionManager; + +namespace Templates { +struct ContextData; +} + class LLMClientInterface : public LanguageClient::BaseClientInterface { Q_OBJECT @@ -31,8 +38,8 @@ public: LLMClientInterface( const Settings::GeneralSettings &generalSettings, const Settings::CodeCompletionSettings &completeSettings, - PluginLLMCore::IProviderRegistry &providerRegistry, - PluginLLMCore::IPromptProvider *promptProvider, + AgentFactory &agentFactory, + SessionManager &sessionManager, Context::IDocumentReader &documentReader, IRequestPerformanceLogger &performanceLogger); ~LLMClientInterface() override; @@ -52,37 +59,33 @@ public: protected: void startImpl() override; -private slots: - void handleFullResponse(const QString &requestId, const QString &fullText); - void handleRequestFinalized( - const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info); - void handleRequestFailed(const QString &requestId, const QString &error); - private: void handleInitialize(const QJsonObject &request); void handleShutdown(const QJsonObject &request); void handleTextDocumentDidOpen(const QJsonObject &request); - void handleInitialized(const QJsonObject &request); - void handleExit(const QJsonObject &request); void handleCancelRequest(); void sendErrorResponse(const QJsonObject &request, const QString &errorMessage); + void onCompletionFinished(const QString &requestId); + void onCompletionFailed(const QString &requestId, const QString &error); + void finishRequest(const QString &requestId); + QString requestIdForSession(Session *session) const; + struct RequestContext { QJsonObject originalRequest; - PluginLLMCore::Provider *provider; + QPointer session; }; - PluginLLMCore::ContextData prepareContext( + Templates::ContextData prepareContext( const QJsonObject &request, const Context::DocumentInfo &documentInfo); - QString resolveEndpoint( - PluginLLMCore::PromptTemplate *promptTemplate, bool isLanguageSpecify) const; + QString pickCompletionAgent(const QString &filePath) const; const Settings::CodeCompletionSettings &m_completeSettings; const Settings::GeneralSettings &m_generalSettings; - PluginLLMCore::IPromptProvider *m_promptProvider = nullptr; - PluginLLMCore::IProviderRegistry &m_providerRegistry; + AgentFactory &m_agentFactory; + SessionManager &m_sessionManager; Context::IDocumentReader &m_documentReader; IRequestPerformanceLogger &m_performanceLogger; QElapsedTimer m_completionTimer; diff --git a/LSPCompletion.hpp b/LSPCompletion.hpp index b8d2698..894554e 100644 --- a/LSPCompletion.hpp +++ b/LSPCompletion.hpp @@ -35,7 +35,6 @@ namespace QodeAssist { class Completion : public LanguageServerProtocol::JsonObject { static constexpr LanguageServerProtocol::Key displayTextKey{"displayText"}; - static constexpr LanguageServerProtocol::Key uuidKey{"uuid"}; public: using JsonObject::JsonObject; @@ -55,7 +54,6 @@ public: } QString text() const { return typedValue(LanguageServerProtocol::textKey); } void setText(const QString &text) { insert(LanguageServerProtocol::textKey, text); } - QString uuid() const { return typedValue(uuidKey); } bool isValid() const override { diff --git a/QodeAssistClient.cpp b/QodeAssistClient.cpp index 4980632..0b45f1a 100644 --- a/QodeAssistClient.cpp +++ b/QodeAssistClient.cpp @@ -159,6 +159,16 @@ QodeAssistClient::QodeAssistClient(LLMClientInterface *clientInterface) m_refactorWidgetHandler = new RefactorWidgetHandler(this); } +void QodeAssistClient::setSessionManager(SessionManager *sessionManager) +{ + m_sessionManager = sessionManager; +} + +void QodeAssistClient::setAgentFactory(AgentFactory *agentFactory) +{ + m_agentFactory = agentFactory; +} + QodeAssistClient::~QodeAssistClient() { cleanupConnections(); @@ -263,9 +273,8 @@ void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor) return; - if (m_llmClient->contextManager() - ->ignoreManager() - ->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) { + if (m_llmClient->contextManager()->shouldIgnore( + editor->textDocument()->filePath().toUrlishString())) { LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1") .arg(editor->textDocument()->filePath().toUrlishString())); return; @@ -309,9 +318,8 @@ void QodeAssistClient::requestQuickRefactor( if (!isEnabled(project)) return; - if (m_llmClient->contextManager() - ->ignoreManager() - ->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) { + if (m_llmClient->contextManager()->shouldIgnore( + editor->textDocument()->filePath().toUrlishString())) { LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1") .arg(editor->textDocument()->filePath().toUrlishString())); return; @@ -319,6 +327,8 @@ void QodeAssistClient::requestQuickRefactor( if (!m_refactorHandler) { m_refactorHandler = new QuickRefactorHandler(this); + m_refactorHandler->setSessionManager(m_sessionManager); + m_refactorHandler->setAgentFactory(m_agentFactory); connect( m_refactorHandler, &QuickRefactorHandler::refactoringCompleted, diff --git a/QodeAssistClient.hpp b/QodeAssistClient.hpp index d828ec1..ddb238e 100644 --- a/QodeAssistClient.hpp +++ b/QodeAssistClient.hpp @@ -6,6 +6,7 @@ #pragma once #include +#include #include "LLMClientInterface.hpp" #include "LSPCompletion.hpp" @@ -13,14 +14,14 @@ #include "RefactorSuggestionHoverHandler.hpp" #include "widgets/CompletionProgressHandler.hpp" #include "widgets/CompletionErrorHandler.hpp" -#include "widgets/EditorChatButtonHandler.hpp" #include "widgets/RefactorWidgetHandler.hpp" #include -#include -#include namespace QodeAssist { +class SessionManager; +class AgentFactory; + class QodeAssistClient : public LanguageClient::Client { Q_OBJECT @@ -28,6 +29,9 @@ public: explicit QodeAssistClient(LLMClientInterface *clientInterface); ~QodeAssistClient() override; + void setSessionManager(SessionManager *sessionManager); + void setAgentFactory(AgentFactory *agentFactory); + void openDocument(TextEditor::TextDocument *document) override; bool canOpenProject(ProjectExplorer::Project *project) override; @@ -63,11 +67,12 @@ private: int m_recentCharCount; CompletionProgressHandler m_progressHandler; CompletionErrorHandler m_errorHandler; - EditorChatButtonHandler m_chatButtonHandler; QuickRefactorHandler *m_refactorHandler{nullptr}; RefactorSuggestionHoverHandler *m_refactorHoverHandler{nullptr}; RefactorWidgetHandler *m_refactorWidgetHandler{nullptr}; LLMClientInterface *m_llmClient; + SessionManager *m_sessionManager{nullptr}; + AgentFactory *m_agentFactory{nullptr}; }; } // namespace QodeAssist diff --git a/QuickRefactorHandler.cpp b/QuickRefactorHandler.cpp index 3eccf10..51d49ca 100644 --- a/QuickRefactorHandler.cpp +++ b/QuickRefactorHandler.cpp @@ -4,24 +4,43 @@ #include "QuickRefactorHandler.hpp" +#include + #include +#include +#include +#include #include #include #include +#include +#include +#include + #include -#include #include +#include #include -#include -#include -#include #include -#include +#include #include #include #include +#include "sources/common/ContextData.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "sources/settings/PipelinesConfig.hpp" +#include "tools/ToolsRegistration.hpp" + namespace QodeAssist { QuickRefactorHandler::QuickRefactorHandler(QObject *parent) @@ -34,6 +53,16 @@ QuickRefactorHandler::QuickRefactorHandler(QObject *parent) QuickRefactorHandler::~QuickRefactorHandler() {} +void QuickRefactorHandler::setSessionManager(SessionManager *sessionManager) +{ + m_sessionManager = sessionManager; +} + +void QuickRefactorHandler::setAgentFactory(AgentFactory *agentFactory) +{ + m_agentFactory = agentFactory; +} + void QuickRefactorHandler::sendRefactorRequest( TextEditor::TextEditorWidget *editor, const QString &instructions) { @@ -88,105 +117,114 @@ void QuickRefactorHandler::sendRefactorRequest( prepareAndSendRequest(editor, instructions, range); } +QString QuickRefactorHandler::configuredAgent(AgentFactory *agentFactory) +{ + const QString configured = Settings::PipelinesConfig::load().rosters.quickRefactor; + if (configured.isEmpty() || !agentFactory || !agentFactory->configByName(configured)) + return {}; + return configured; +} + +QString QuickRefactorHandler::pickRefactorAgent() const +{ + return configuredAgent(m_agentFactory); +} + void QuickRefactorHandler::prepareAndSendRequest( TextEditor::TextEditorWidget *editor, const QString &instructions, const Utils::Text::Range &range) { - auto &settings = Settings::generalSettings(); - - auto &providerRegistry = PluginLLMCore::ProvidersManager::instance(); - auto &promptManager = PluginLLMCore::PromptTemplateManager::instance(); - - const auto providerName = settings.qrProvider(); - auto provider = providerRegistry.getProviderByName(providerName); - - if (!provider) { - QString error = QString("No provider found with name: %1").arg(providerName); + const auto emitError = [this, editor](const QString &error) { LOG_MESSAGE(error); RefactorResult result; result.success = false; result.errorMessage = error; result.editor = editor; emit refactoringCompleted(result); + }; + + if (!m_sessionManager) { + emitError(QStringLiteral("Quick refactor session manager is not available")); return; } - const auto templateName = settings.qrTemplate(); - auto promptTemplate = promptManager.getChatTemplateByName(templateName); - - if (!promptTemplate) { - QString error = QString("No template found with name: %1").arg(templateName); - LOG_MESSAGE(error); - RefactorResult result; - result.success = false; - result.errorMessage = error; - result.editor = editor; - emit refactoringCompleted(result); + const QString agentName = pickRefactorAgent(); + if (agentName.isEmpty()) { + emitError(QStringLiteral( + "No quick refactor agent configured. Set one in QodeAssist > General.")); return; } - QJsonObject payload{ - {"model", Settings::generalSettings().qrModel()}, {"stream", true}}; + QString sessionError; + Session *session = m_sessionManager->acquire(agentName, &sessionError); + if (!session) { + emitError(sessionError.isEmpty() ? QStringLiteral("No quick refactor agent selected") + : sessionError); + return; + } - PluginLLMCore::ContextData context = prepareContext(editor, range, instructions); + auto *client = session->client(); + if (!client) { + m_sessionManager->removeSession(session); + emitError(QStringLiteral("Quick refactor agent has no live client")); + return; + } - bool enableTools = Settings::quickRefactorSettings().useTools(); - bool enableThinking = Settings::quickRefactorSettings().useThinking(); - provider->prepareRequest( - payload, - promptTemplate, - context, - PluginLLMCore::RequestType::QuickRefactoring, - enableTools, - enableThinking); + auto *project = ProjectExplorer::ProjectManager::startupProject(); + Templates::ContextRenderer::Bindings bindings; + bindings.projectDir = project ? project->projectDirectory().toFSPathString() : QString(); + bindings.configDir = AgentFactory::userConfigDir(); + session->setContextBindings(bindings); - provider->client()->setMaxToolContinuations( - Settings::toolsSettings().maxToolContinuations()); + const AgentConfig *agentConfig + = m_agentFactory ? m_agentFactory->configByName(agentName) : nullptr; + if (agentConfig && agentConfig->enableTools) { + m_sessionManager->toolContributors().contribute(client->tools()); + client->toolLoop()->setMaxRounds(Settings::toolsSettings().maxToolContinuations()); + } - provider->client()->setTransferTimeout( + session->systemPrompt()->setLayer( + QStringLiteral("refactor"), buildSystemPrompt(editor, range)); + + client->setTransferTimeout( static_cast(Settings::generalSettings().requestTimeout() * 1000)); m_isRefactoringInProgress = true; connect( - provider->client(), - &::LLMQore::BaseClient::requestCompleted, - this, - &QuickRefactorHandler::handleFullResponse, - Qt::UniqueConnection); - + session, &Session::finished, this, + [this](const LLMQore::RequestID &id, const QString &) { onRefactorFinished(id); }); connect( - provider->client(), - &::LLMQore::BaseClient::requestFinalized, - this, - &QuickRefactorHandler::handleRequestFinalized, - Qt::UniqueConnection); + session, &Session::failed, this, + [this](const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error) { + onRefactorFailed(id, error); + }); - connect( - provider->client(), - &::LLMQore::BaseClient::requestFailed, - this, - &QuickRefactorHandler::handleRequestFailed, - Qt::UniqueConnection); + std::vector> blocks; + const QString userMessage = instructions.isEmpty() + ? QStringLiteral("Refactor the code to improve its quality and maintainability.") + : instructions; + blocks.push_back(std::make_unique(userMessage)); + + const LLMQore::RequestID requestId = session->send(std::move(blocks)); + if (requestId.isEmpty()) { + m_isRefactoringInProgress = false; + const QString reason = session->lastError().message; + m_sessionManager->removeSession(session); + emitError(QStringLiteral("Failed to start quick refactor request for agent '%1': %2") + .arg(agentName, reason)); + return; + } - const QString customEndpoint = Settings::generalSettings().qrCustomEndpoint(); - const QString endpoint = !customEndpoint.isEmpty() ? customEndpoint - : promptTemplate->endpoint(); - auto requestId - = provider->sendRequest(QUrl(Settings::generalSettings().qrUrl()), payload, endpoint); m_lastRequestId = requestId; - QJsonObject request{{"id", requestId}}; - - m_activeRequests[requestId] = {request, provider}; + m_activeRequests[requestId] = {QJsonObject{{"id", requestId}}, session}; } -PluginLLMCore::ContextData QuickRefactorHandler::prepareContext( - TextEditor::TextEditorWidget *editor, - const Utils::Text::Range &range, - const QString &instructions) +QString QuickRefactorHandler::buildSystemPrompt( + TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range) { - PluginLLMCore::ContextData context; + Q_UNUSED(range) auto textDocument = editor->textDocument(); Context::DocumentReaderQtCreator documentReader; @@ -194,7 +232,7 @@ PluginLLMCore::ContextData QuickRefactorHandler::prepareContext( if (!documentInfo.document) { LOG_MESSAGE("Error: Document is not available"); - return context; + return {}; } QTextCursor cursor = editor->textCursor(); @@ -268,24 +306,10 @@ PluginLLMCore::ContextData QuickRefactorHandler::prepareContext( taggedContent = contextBefore + "" + contextAfter; } - QString systemPrompt = Settings::quickRefactorSettings().systemPrompt(); + QString systemPrompt = Context::EnvBlockFormatter::formatFile( + {documentInfo.filePath, documentInfo.mimeType}); - auto project = PluginLLMCore::RulesLoader::getActiveProject(); - if (project) { - QString projectRules = PluginLLMCore::RulesLoader::loadRulesForProject( - project, PluginLLMCore::RulesContext::QuickRefactor); - - if (!projectRules.isEmpty()) { - systemPrompt += "\n\n# Project Rules\n\n" + projectRules; - LOG_MESSAGE("Loaded project rules for quick refactor"); - } - } - - systemPrompt += "\n\nFile information:"; - systemPrompt += "\nLanguage: " + documentInfo.mimeType; - systemPrompt += "\nFile path: " + documentInfo.filePath; - - systemPrompt += "\n\n# Code Context with Position Markers\n" + taggedContent; + systemPrompt += "\n# Code Context with Position Markers\n" + taggedContent; systemPrompt += "\n\n# Output Requirements\n## What to Generate:"; systemPrompt += cursor.hasSelection() @@ -294,7 +318,7 @@ PluginLLMCore::ContextData QuickRefactorHandler::prepareContext( "\n- Your output will completely replace the selected code" : "\n- Generate ONLY the code that should be INSERTED at the position" "\n- Your output will be inserted at the cursor location"; - + systemPrompt += "\n\n## Formatting Rules:" "\n- Output ONLY the code itself, without ANY explanations or descriptions" "\n- Do NOT include markdown code blocks (no ```, no language tags)" @@ -302,9 +326,9 @@ PluginLLMCore::ContextData QuickRefactorHandler::prepareContext( "\n- Do NOT repeat existing code, be precise with context" "\n- Do NOT send in answer or and other tags" "\n- The output must be ready to insert directly into the editor as-is"; - + systemPrompt += "\n\n## Indentation and Whitespace:"; - + if (cursor.hasSelection()) { QTextBlock startBlock = documentInfo.document->findBlock(cursor.selectionStart()); int leadingSpaces = 0; @@ -336,7 +360,7 @@ PluginLLMCore::ContextData QuickRefactorHandler::prepareContext( .arg(leadingSpaces); } } - + systemPrompt += "\n- Use the same indentation style (spaces or tabs) as the surrounding code" "\n- Maintain consistent indentation for nested blocks" "\n- Do NOT remove or reduce the base indentation level" @@ -349,42 +373,7 @@ PluginLLMCore::ContextData QuickRefactorHandler::prepareContext( systemPrompt += "\n\n" + m_contextManager.openedFilesContext({documentInfo.filePath}); } - context.systemPrompt = systemPrompt; - - QVector messages; - messages.append( - {"user", - instructions.isEmpty() ? "Refactor the code to improve its quality and maintainability." - : instructions}); - context.history = messages; - - return context; -} - -void QuickRefactorHandler::handleLLMResponse( - const QString &response, const QJsonObject &request, bool isComplete) -{ - if (request["id"].toString() != m_lastRequestId) { - return; - } - - if (isComplete) { - m_isRefactoringInProgress = false; - QString cleanedResponse = PluginLLMCore::ResponseCleaner::clean(response); - - RefactorResult result; - result.newText = cleanedResponse; - result.insertRange = m_currentRange; - result.success = true; - result.editor = m_currentEditor; - - LOG_MESSAGE("Refactoring completed successfully. New code to insert: "); - LOG_MESSAGE("---------- BEGIN REFACTORED CODE ----------"); - LOG_MESSAGE(cleanedResponse); - LOG_MESSAGE("----------- END REFACTORED CODE -----------"); - - emit refactoringCompleted(result); - } + return systemPrompt; } void QuickRefactorHandler::cancelRequest() @@ -398,10 +387,10 @@ void QuickRefactorHandler::cancelRequest() auto it = m_activeRequests.find(id); if (it != m_activeRequests.end()) { - auto provider = it.value().provider; + Session *session = it.value().session; m_activeRequests.erase(it); - if (provider) - provider->cancelRequest(id); + if (session && m_sessionManager) + m_sessionManager->release(session); } RefactorResult result; @@ -410,42 +399,66 @@ void QuickRefactorHandler::cancelRequest() emit refactoringCompleted(result); } -void QuickRefactorHandler::handleFullResponse(const QString &requestId, const QString &fullText) +void QuickRefactorHandler::onRefactorFinished(const QString &requestId) { - if (requestId == m_lastRequestId) { - m_activeRequests.remove(requestId); - QJsonObject request{{"id", requestId}}; - handleLLMResponse(fullText, request, true); - } -} - -void QuickRefactorHandler::handleRequestFinalized( - const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info) -{ - if (requestId != m_lastRequestId || !info.usage) + if (requestId != m_lastRequestId) return; - const auto &u = *info.usage; - LOG_MESSAGE( - QString("Quick refactor usage [%1]: prompt=%2 completion=%3 cached=%4 reasoning=%5") - .arg(requestId) - .arg(u.promptTokens) - .arg(u.completionTokens) - .arg(u.cachedPromptTokens) - .arg(u.reasoningTokens)); + auto it = m_activeRequests.find(requestId); + Session *session = (it != m_activeRequests.end()) ? it.value().session.data() : nullptr; + if (it != m_activeRequests.end()) + m_activeRequests.erase(it); + + QString fullText; + if (session) { + if (auto *history = session->history(); history && !history->isEmpty()) + fullText = history->messages().back().text(); + } + + m_isRefactoringInProgress = false; + m_lastRequestId.clear(); + + const QString cleanedResponse = ResponseCleaner::clean(fullText); + + RefactorResult result; + result.newText = cleanedResponse; + result.insertRange = m_currentRange; + result.success = true; + result.editor = m_currentEditor; + + LOG_MESSAGE("Refactoring completed successfully. New code to insert: "); + LOG_MESSAGE("---------- BEGIN REFACTORED CODE ----------"); + LOG_MESSAGE(cleanedResponse); + LOG_MESSAGE("----------- END REFACTORED CODE -----------"); + + emit refactoringCompleted(result); + + if (session && m_sessionManager) + m_sessionManager->release(session); } -void QuickRefactorHandler::handleRequestFailed(const QString &requestId, const QString &error) +void QuickRefactorHandler::onRefactorFailed( + const QString &requestId, const QodeAssist::ErrorInfo &error) { - if (requestId == m_lastRequestId) { - m_activeRequests.remove(requestId); - m_isRefactoringInProgress = false; - RefactorResult result; - result.success = false; - result.errorMessage = error; - result.editor = m_currentEditor; - emit refactoringCompleted(result); - } + if (requestId != m_lastRequestId) + return; + + auto it = m_activeRequests.find(requestId); + Session *session = (it != m_activeRequests.end()) ? it.value().session.data() : nullptr; + if (it != m_activeRequests.end()) + m_activeRequests.erase(it); + + m_isRefactoringInProgress = false; + m_lastRequestId.clear(); + + RefactorResult result; + result.success = false; + result.errorMessage = error.message; + result.editor = m_currentEditor; + emit refactoringCompleted(result); + + if (session && m_sessionManager) + m_sessionManager->release(session); } } // namespace QodeAssist diff --git a/QuickRefactorHandler.hpp b/QuickRefactorHandler.hpp index 8d58b73..c9c7266 100644 --- a/QuickRefactorHandler.hpp +++ b/QuickRefactorHandler.hpp @@ -6,18 +6,22 @@ #include #include +#include #include #include #include +#include #include #include -#include -#include namespace QodeAssist { +class SessionManager; +class Session; +class AgentFactory; + struct RefactorResult { QString newText; @@ -35,38 +39,39 @@ public: explicit QuickRefactorHandler(QObject *parent = nullptr); ~QuickRefactorHandler() override; + void setSessionManager(SessionManager *sessionManager); + void setAgentFactory(AgentFactory *agentFactory); + void sendRefactorRequest(TextEditor::TextEditorWidget *editor, const QString &instructions); void cancelRequest(); bool isProcessing() const { return m_isRefactoringInProgress; } + static QString configuredAgent(AgentFactory *agentFactory); + signals: void refactoringCompleted(const QodeAssist::RefactorResult &result); -private slots: - void handleFullResponse(const QString &requestId, const QString &fullText); - void handleRequestFinalized( - const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info); - void handleRequestFailed(const QString &requestId, const QString &error); - private: void prepareAndSendRequest( TextEditor::TextEditorWidget *editor, const QString &instructions, const Utils::Text::Range &range); - void handleLLMResponse(const QString &response, const QJsonObject &request, bool isComplete); - PluginLLMCore::ContextData prepareContext( - TextEditor::TextEditorWidget *editor, - const Utils::Text::Range &range, - const QString &instructions); + void onRefactorFinished(const QString &requestId); + void onRefactorFailed(const QString &requestId, const QodeAssist::ErrorInfo &error); + QString buildSystemPrompt( + TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range); + QString pickRefactorAgent() const; struct RequestContext { QJsonObject originalRequest; - PluginLLMCore::Provider *provider; + QPointer session; }; + QPointer m_sessionManager; + QPointer m_agentFactory; QHash m_activeRequests; TextEditor::TextEditorWidget *m_currentEditor; Utils::Text::Range m_currentRange; diff --git a/README.md b/README.md index d83740e..a4e1a62 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance: - **File Context** — attach, link, or auto-sync open editor files for richer prompts - **Many Providers** — Ollama, llama.cpp, LM Studio (Chat + Responses), Claude, OpenAI (Chat + Responses), Google AI, Mistral, Codestral, OpenRouter, Qwen (OpenAI + Responses), DeepSeek, any OpenAI-compatible endpoint - **Reasoning / Thinking** — streamed chain-of-thought is shown for reasoning models across Claude, Google, OpenAI Responses, and any OpenAI-compatible endpoint that returns `reasoning_content` (DeepSeek, Qwen QwQ/Qwen3-Thinking, LM Studio, OpenRouter, …) -- **Customizable** — per-project rules (`.qodeassist/rules/`), agent roles, reusable refactor templates, full prompt-template control +- **Customizable** — per-agent personas (agent TOML `system_prompt`), reusable refactor templates, full prompt-template control **Join our [Discord Community](https://discord.gg/BGMkUsXUgf)** to get support and connect with other users! @@ -217,9 +217,8 @@ For optimal coding assistance, we recommend using these top-tier models: ### Additional Configuration -- **[Agent Roles](docs/agent-roles.md)** - Create AI personas with specialized system prompts +- **[Creating and Extending Agents](docs/creating-agents.md)** - Add custom agents (including personas via `system_prompt`) with TOML profiles - **[Chat Summarization](docs/chat-summarization.md)** - Compress conversations to save context tokens -- **[Project Rules](docs/project-rules.md)** - Customize AI behavior for your project - **[Ignoring Files](docs/ignoring-files.md)** - Exclude files from context using `.qodeassistignore` ## Features @@ -255,7 +254,7 @@ Configure in: `Tools → Options → QodeAssist → Code Completion → General - Multiple chat panels: side panel, bottom panel, and popup window - Chat history with auto-save and restore - Token usage monitoring -- **[Agent Roles](docs/agent-roles.md)** - Switch between AI personas (Developer, Reviewer, custom roles) +- AI personas via agent `system_prompt` — switch personas by switching agents (see [Creating Agents](docs/creating-agents.md)) - **[Chat Summarization](docs/chat-summarization.md)** - Compress long conversations into AI-generated summaries - **[File Context](docs/file-context.md)** - Attach or link files for better context - Automatic syncing with open editor files (optional) @@ -349,18 +348,16 @@ QodeAssist uses a flexible prompt composition system that adapts to different co ├─────────────────────────────────────────────────────────────────────────────┤ │ Examples: Codestral, Qwen2.5-Coder, DeepSeek-Coder │ │ │ -│ 1. System Prompt (from Code Completion Settings - FIM variant) │ -│ 2. Project Rules: │ -│ └─ .qodeassist/rules/completion/*.md │ -│ 3. Open Files Context (optional, if enabled): │ -│ └─ Currently open editor files │ -│ 4. Code Context: │ +│ 1. Editor Context: │ +│ ├─ File information (language, path) │ +│ ├─ Recent project changes (optional, if enabled) │ +│ └─ Open editor files (optional, if enabled) │ +│ 2. Code Context: │ │ ├─ Code before cursor (prefix) │ │ └─ Code after cursor (suffix) │ │ │ -│ Final Prompt: FIM_Template(Prefix: SystemPrompt + Rules + OpenFiles + │ -│ CodeBefore, │ -│ Suffix: CodeAfter) │ +│ Final Request: the agent's TOML [body] template renders prefix/suffix │ +│ into the provider's native FIM fields │ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -375,21 +372,19 @@ QodeAssist uses a flexible prompt composition system that adapts to different co ├─────────────────────────────────────────────────────────────────────────────┤ │ Examples: DeepSeek-Coder-Instruct, Qwen2.5-Coder-Instruct │ │ │ -│ 1. System Prompt (from Code Completion Settings - Non-FIM variant) │ -│ └─ Includes response formatting instructions │ -│ 2. Project Rules: │ -│ └─ .qodeassist/rules/completion/*.md │ -│ 3. Open Files Context (optional, if enabled): │ -│ └─ Currently open editor files │ -│ 4. Code Context: │ +│ 1. Completion Instructions (from the agent's TOML profile) │ +│ └─ Includes response formatting rules │ +│ 2. Editor Context: │ │ ├─ File information (language, path) │ +│ ├─ Recent project changes (optional, if enabled) │ +│ └─ Open editor files (optional, if enabled) │ +│ 3. Code Context: │ │ ├─ Code before cursor │ │ ├─ marker │ │ └─ Code after cursor │ -│ 5. User Message: "Complete the code at cursor position" │ │ │ -│ Final Prompt: [System: SystemPrompt + Rules] │ -│ [User: OpenFiles + Context + CompletionRequest] │ +│ Final Prompt: [System: Instructions + EditorContext] │ +│ [User: Code around cursor as a completion request] │ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -402,27 +397,22 @@ QodeAssist uses a flexible prompt composition system that adapts to different co ┌─────────────────────────────────────────────────────────────────────────────┐ │ CHAT ASSISTANT │ ├─────────────────────────────────────────────────────────────────────────────┤ -│ 1. System Prompt (from Chat Assistant Settings) │ -│ 2. Agent Role (optional, from role selector): │ -│ └─ Role-specific system prompt (Developer, Reviewer, custom) │ -│ 3. Project Rules: │ -│ ├─ .qodeassist/rules/common/*.md │ -│ └─ .qodeassist/rules/chat/*.md │ -│ 4. File Context (optional): │ -│ ├─ Attached files (manual) │ -│ ├─ Linked files (persistent) │ -│ └─ Open editor files (if auto-sync enabled) │ -│ 5. Tool Definitions (if enabled): │ -│ ├─ ReadProjectFileByName │ -│ ├─ ListProjectFiles │ -│ ├─ SearchInProject │ -│ └─ GetIssuesList │ -│ 6. Conversation History │ -│ 7. User Message │ +│ 1. Agent System Prompt (persona, from the agent's TOML profile) │ +│ 2. Project Info + Skills (catalog and always-on skills) │ +│ 3. Tool Definitions (if the agent enables tools) │ +│ 4. Conversation History: │ +│ ├─ Previous messages and tool calls/results │ +│ ├─ Attachments stay with the message they were sent with │ +│ └─ /skill instructions persist for the whole conversation │ +│ 5. Linked Files + Open Editor Files (if auto-sync enabled): │ +│ └─ FRESH snapshot of current file content, re-read on every │ +│ request and placed next to your latest message — never │ +│ duplicated into the history │ +│ 6. User Message (+ this turn's attachments and images) │ │ │ -│ Final Prompt: [System: SystemPrompt + AgentRole + Rules + Tools] │ +│ Final Prompt: [System: Persona + ProjectInfo + Skills] │ │ [History: Previous messages] │ -│ [User: FileContext + UserMessage] │ +│ [User: CurrentFilesSnapshot + UserMessage + Attachments] │ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -435,28 +425,26 @@ QodeAssist uses a flexible prompt composition system that adapts to different co ┌─────────────────────────────────────────────────────────────────────────────┐ │ QUICK REFACTORING │ ├─────────────────────────────────────────────────────────────────────────────┤ -│ 1. System Prompt (from Quick Refactor Settings) │ -│ 2. Project Rules: │ -│ ├─ .qodeassist/rules/common/*.md │ -│ └─ .qodeassist/rules/quickrefactor/*.md │ -│ 3. Code Context: │ +│ 1. Agent System Prompt (persona, from the agent's TOML profile) │ +│ 2. Code Context (generated): │ │ ├─ File information (language, path) │ │ ├─ Code before selection (configurable amount) │ │ ├─ marker │ │ ├─ Selected code (or current line) │ │ ├─ marker │ │ ├─ marker (position within selection) │ -│ └─ Code after selection (configurable amount) │ -│ 4. Refactor Instruction: │ +│ ├─ Code after selection (configurable amount) │ +│ └─ Output formatting and indentation rules │ +│ 3. Refactor Instruction (the user message): │ │ ├─ Built-in (e.g., "Improve Code", "Alternative Solution") │ │ ├─ Custom Instruction (from library) │ │ │ └─ ~/.config/QtProject/qtcreator/qodeassist/ │ │ │ quick_refactor/instructions/*.json │ │ └─ Additional Details (optional user input) │ -│ 5. Tool Definitions (if enabled) │ +│ 4. Tool Definitions (if the agent enables tools) │ │ │ -│ Final Prompt: [System: SystemPrompt + Rules] │ -│ [User: Context + Markers + Instruction + Details] │ +│ Final Prompt: [System: Persona + CodeContext + Rules] │ +│ [User: Instruction + Details] │ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -464,17 +452,15 @@ QodeAssist uses a flexible prompt composition system that adapts to different co ### Key Points -- **Project Rules** are automatically loaded from `.qodeassist/rules/` directory structure -- **System Prompts** are configured independently for each feature in Settings -- **Agent Roles** add role-specific prompts on top of the base system prompt (Chat only) -- **FIM vs Non-FIM models** for code completion use different System Prompts: - - FIM models: Direct completion prompt - - Non-FIM models: Prompt includes response formatting instructions -- **Quick Refactor** has its own provider/model configuration, independent from Chat +- **System Prompts** live in the agent's TOML profile (`system_prompt`); switch personas by switching agents +- **Linked and open-synced files are always current**: their content is not stored in the conversation — every request re-reads the files and sends a fresh snapshot next to your latest message. Editing a linked file between messages never leaves a stale copy in the context, and changing it does not invalidate the provider's prompt cache for the whole conversation +- **One-time attachments are different**: they are saved with the message they were sent with and stay in the history as sent +- **FIM vs Non-FIM** for code completion is the agent's choice: a FIM agent renders prefix/suffix into native FIM fields, an instruct agent sends a chat-shaped request — pick the agent that matches your model +- **Quick Refactor** has its own agent roster, independent from Chat - **Custom Instructions** provide reusable templates that can be augmented with specific details -- **Tool Calling** is available for Chat and Quick Refactor when enabled +- **Tool Calling** is available for Chat and Quick Refactor when the agent enables it; tool rounds per request are limited (configurable in `Settings → Tools`) -See [Project Rules Documentation](docs/project-rules.md), [Agent Roles Guide](docs/agent-roles.md), and [Quick Refactoring Guide](docs/quick-refactoring.md) for more details. +See the [Quick Refactoring Guide](docs/quick-refactoring.md) for more details. ## QtCreator Version Compatibility @@ -522,7 +508,6 @@ For additional support, join our [Discord Community](https://discord.gg/BGMkUsXU - [x] Diff sharing with models - [x] Tools / function calling (file I/O, build, terminal, diagnostics) - [x] Agent Skills (project + global directories, `/skill` commands, always-on, `load_skill` tool) -- [x] Project-specific rules (`.qodeassist/rules/`) - [x] MCP (Model Context Protocol) — QodeAssist as a server - [x] MCP — QodeAssist as a client (consume external MCP tools; authenticated MCP servers not yet supported) - [ ] Full project source sharing @@ -533,7 +518,7 @@ If you find QodeAssist helpful, there are several ways you can support the proje 1. **Report Issues**: If you encounter any bugs or have suggestions for improvements, please [open an issue](https://github.com/Palm1r/qodeassist/issues) on our GitHub repository. -2. **Contribute**: Feel free to submit pull requests with bug fixes or new features. +2. **Contribute**: Feel free to submit pull requests with bug fixes or new features. The easiest contribution is an agent preset for a provider or model you use — it's a single TOML file, no C++ required; see [Contributing your agent](docs/creating-agents.md#contributing-your-agent-to-qodeassist). 3. **Spread the Word**: Star our GitHub repository and share QodeAssist with your fellow developers. @@ -581,6 +566,10 @@ cmake --build . ## For Contributors +### Adding an agent preset + +New provider/model presets are plain TOML — extend a provider base, register the file in `agents.qrc`, and the test suite validates it automatically. Step-by-step guide: [docs/creating-agents.md](docs/creating-agents.md#contributing-your-agent-to-qodeassist). + ### Code Style - **QML**: Follow [QML Coding Guide](https://github.com/Furkanzmc/QML-Coding-Guide) by @Furkanzmc @@ -589,7 +578,7 @@ cmake --build . ### Development Guidelines -For detailed development guidelines, architecture patterns, and best practices, see the [project workspace rules](.cursor/rules.mdc). +For detailed development guidelines and architecture patterns, see [docs/architecture.md](docs/architecture.md) and [docs/target-architecture.md](docs/target-architecture.md). ## License diff --git a/RefactorSuggestionHoverHandler.hpp b/RefactorSuggestionHoverHandler.hpp index ce877b1..cd4b33b 100644 --- a/RefactorSuggestionHoverHandler.hpp +++ b/RefactorSuggestionHoverHandler.hpp @@ -32,7 +32,6 @@ public: void setSuggestionRange(const Utils::Text::Range &range); void clearSuggestionRange(); - bool hasSuggestion() const { return m_hasSuggestion; } void setApplyCallback(ApplyCallback callback) { m_applyCallback = std::move(callback); } void setDismissCallback(DismissCallback callback) { m_dismissCallback = std::move(callback); } diff --git a/TaskFlow/CMakeLists.txt b/TaskFlow/CMakeLists.txt deleted file mode 100644 index cebfdf7..0000000 --- a/TaskFlow/CMakeLists.txt +++ /dev/null @@ -1,14 +0,0 @@ -add_subdirectory(core) -add_subdirectory(Editor) -# add_subdirectory(serialization) -# add_subdirectory(tasks) - -qt_add_library(TaskFlow STATIC) - -target_link_libraries(TaskFlow - PUBLIC - TaskFlowCore - TaskFlowEditorplugin - # TaskFlowSerialization - # TaskFlowTasks -) diff --git a/TaskFlow/Editor/CMakeLists.txt b/TaskFlow/Editor/CMakeLists.txt deleted file mode 100644 index be451cf..0000000 --- a/TaskFlow/Editor/CMakeLists.txt +++ /dev/null @@ -1,41 +0,0 @@ -qt_add_library(TaskFlowEditor STATIC) - -qt_policy(SET QTP0001 NEW) -qt_policy(SET QTP0004 NEW) - -qt_add_qml_module(TaskFlowEditor - URI TaskFlow.Editor - VERSION 1.0 - DEPENDENCIES QtQuick - RESOURCES - QML_FILES - qml/FlowEditorView.qml - qml/Flow.qml - qml/Task.qml - qml/TaskPort.qml - qml/TaskParameter.qml - qml/TaskConnection.qml - SOURCES - FlowEditor.hpp FlowEditor.cpp - FlowsModel.hpp FlowsModel.cpp - TaskItem.hpp TaskItem.cpp - FlowItem.hpp FlowItem.cpp - TaskModel.hpp TaskModel.cpp - TaskPortItem.hpp TaskPortItem.cpp - TaskPortModel.hpp TaskPortModel.cpp - TaskConnectionsModel.hpp TaskConnectionsModel.cpp - TaskConnectionItem.hpp TaskConnectionItem.cpp - GridBackground.hpp GridBackground.cpp -) - -target_link_libraries(TaskFlowEditor - PUBLIC - Qt::Quick - PRIVATE - TaskFlowCore -) - -target_include_directories(TaskFlowEditor - PUBLIC - ${CMAKE_CURRENT_LIST_DIR} -) diff --git a/TaskFlow/Editor/FlowEditor.cpp b/TaskFlow/Editor/FlowEditor.cpp deleted file mode 100644 index f825f70..0000000 --- a/TaskFlow/Editor/FlowEditor.cpp +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "FlowEditor.hpp" - -namespace QodeAssist::TaskFlow { - -FlowEditor::FlowEditor(QQuickItem *parent) - : QQuickItem(parent) -{} - -void FlowEditor::initialize() -{ - emit availableTaskTypesChanged(); - emit availableFlowsChanged(); - - m_flowsModel = new FlowsModel(m_flowManager, this); - - emit flowsModelChanged(); - - if (m_flowsModel->rowCount() > 0) { - setCurrentFlowIndex(0); - } - - // setCurrentFlowId(m_flowManager->flows().begin().value()->flowId()); - m_currentFlow = m_flowManager->getFlow(); - emit currentFlowChanged(); -} - -QString FlowEditor::currentFlowId() const -{ - return m_currentFlowId; -} - -void FlowEditor::setCurrentFlowId(const QString &newCurrentFlowId) -{ - if (m_currentFlowId == newCurrentFlowId) - return; - m_currentFlowId = newCurrentFlowId; - emit currentFlowIdChanged(); -} - -QStringList FlowEditor::availableTaskTypes() const -{ - if (m_flowManager) - return m_flowManager->getAvailableTasksTypes(); - else { - return {"No flow manager"}; - } -} - -QStringList FlowEditor::availableFlows() const -{ - if (m_flowManager) { - auto flows = m_flowManager->getAvailableFlows(); - return flows.size() > 0 ? flows : QStringList{"No flows"}; - } else { - return {"No flow manager"}; - } -} - -void FlowEditor::setFlowManager(FlowManager *newFlowManager) -{ - if (m_flowManager == newFlowManager) - return; - m_flowManager = newFlowManager; - - initialize(); -} - -FlowsModel *FlowEditor::flowsModel() const -{ - return m_flowsModel; -} - -int FlowEditor::currentFlowIndex() const -{ - return m_currentFlowIndex; -} - -void FlowEditor::setCurrentFlowIndex(int newCurrentFlowIndex) -{ - if (m_currentFlowIndex == newCurrentFlowIndex) - return; - m_currentFlowIndex = newCurrentFlowIndex; - emit currentFlowIndexChanged(); -} - -Flow *FlowEditor::getFlow(const QString &flowName) -{ - return m_flowManager->getFlow(flowName); -} - -Flow *FlowEditor::getCurrentFlow() -{ - return m_flowManager->getFlow(m_currentFlowId); -} - -Flow *FlowEditor::currentFlow() const -{ - return m_currentFlow; -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/FlowEditor.hpp b/TaskFlow/Editor/FlowEditor.hpp deleted file mode 100644 index 4e09b15..0000000 --- a/TaskFlow/Editor/FlowEditor.hpp +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include "FlowsModel.hpp" -#include - -namespace QodeAssist::TaskFlow { - -class FlowEditor : public QQuickItem -{ - Q_OBJECT - QML_ELEMENT - - Q_PROPERTY( - QString currentFlowId READ currentFlowId WRITE setCurrentFlowId NOTIFY currentFlowIdChanged) - Q_PROPERTY( - QStringList availableTaskTypes READ availableTaskTypes NOTIFY availableTaskTypesChanged) - Q_PROPERTY(QStringList availableFlows READ availableFlows NOTIFY availableFlowsChanged) - Q_PROPERTY(FlowsModel *flowsModel READ flowsModel NOTIFY flowsModelChanged) - Q_PROPERTY(int currentFlowIndex READ currentFlowIndex WRITE setCurrentFlowIndex NOTIFY - currentFlowIndexChanged) - - Q_PROPERTY(Flow *currentFlow READ currentFlow NOTIFY currentFlowChanged FINAL) - -public: - FlowEditor(QQuickItem *parent = nullptr); - - void initialize(); - - QString currentFlowId() const; - void setCurrentFlowId(const QString &newCurrentFlowId); - - QStringList availableTaskTypes() const; - QStringList availableFlows() const; - - void setFlowManager(FlowManager *newFlowManager); - - FlowsModel *flowsModel() const; - - int currentFlowIndex() const; - void setCurrentFlowIndex(int newCurrentFlowIndex); - - Q_INVOKABLE Flow *getFlow(const QString &flowName); - Q_INVOKABLE Flow *getCurrentFlow(); - - Flow *currentFlow() const; - -signals: - void currentFlowIdChanged(); - void availableTaskTypesChanged(); - void availableFlowsChanged(); - void flowsModelChanged(); - - void currentFlowIndexChanged(); - - void currentFlowChanged(); - -private: - FlowManager *m_flowManager = nullptr; - QString m_currentFlowId; - FlowsModel *m_flowsModel; - int m_currentFlowIndex; - Flow *m_currentFlow = nullptr; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/FlowItem.cpp b/TaskFlow/Editor/FlowItem.cpp deleted file mode 100644 index 7df0b46..0000000 --- a/TaskFlow/Editor/FlowItem.cpp +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "FlowItem.hpp" - -namespace QodeAssist::TaskFlow { - -FlowItem::FlowItem(QQuickItem *parent) - : QQuickItem(parent) -{ - connect(this, &QQuickItem::childrenChanged, this, [this]() { updateFlowLayout(); }); -} - -QString FlowItem::flowId() const -{ - if (!m_flow) - return {"no flow"}; - return m_flow->flowId(); -} - -void FlowItem::setFlowId(const QString &newFlowId) -{ - if (m_flow->flowId() == newFlowId) - return; - m_flow->setFlowId(newFlowId); - emit flowIdChanged(); -} - -Flow *FlowItem::flow() const -{ - return m_flow; -} - -void FlowItem::setFlow(Flow *newFlow) -{ - if (m_flow == newFlow) - return; - m_flow = newFlow; - emit flowChanged(); - emit flowIdChanged(); - qDebug() << "FlowItem::setFlow" << m_flow->flowId() << newFlow; - - m_taskModel = new TaskModel(m_flow, this); - m_connectionsModel = new TaskConnectionsModel(m_flow, this); - - emit taskModelChanged(); - emit connectionsModelChanged(); -} - -TaskModel *FlowItem::taskModel() const -{ - return m_taskModel; -} - -TaskConnectionsModel *FlowItem::connectionsModel() const -{ - return m_connectionsModel; -} - -QVariantList FlowItem::taskItems() const -{ - return m_taskItems; -} - -void FlowItem::setTaskItems(const QVariantList &newTaskItems) -{ - qDebug() << "FlowItem::setTaskItems" << newTaskItems; - if (m_taskItems == newTaskItems) - return; - m_taskItems = newTaskItems; - emit taskItemsChanged(); -} - -void FlowItem::updateFlowLayout() -{ - auto allItems = this->childItems(); - - for (auto child : allItems) { - if (child->objectName() == QString("TaskItem")) { - qDebug() << "Found TaskItem:" << child; - auto taskItem = qobject_cast(child); - m_taskItemsList.insert(taskItem, taskItem->task()); - } - - if (child->objectName() == QString("TaskConnectionItem")) { - qDebug() << "Found TaskConnectionItem:" << child; - auto connectionItem = qobject_cast(child); - m_taskConnectionsList.insert(connectionItem, connectionItem->connection()); - } - } -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/FlowItem.hpp b/TaskFlow/Editor/FlowItem.hpp deleted file mode 100644 index 6f4f7df..0000000 --- a/TaskFlow/Editor/FlowItem.hpp +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include "TaskConnectionItem.hpp" -#include "TaskConnectionsModel.hpp" -#include "TaskItem.hpp" -#include "TaskModel.hpp" -#include -#include - -namespace QodeAssist::TaskFlow { - -class FlowItem : public QQuickItem -{ - Q_OBJECT - QML_ELEMENT - - Q_PROPERTY(QString flowId READ flowId WRITE setFlowId NOTIFY flowIdChanged) - Q_PROPERTY(Flow *flow READ flow WRITE setFlow NOTIFY flowChanged) - Q_PROPERTY(TaskModel *taskModel READ taskModel NOTIFY taskModelChanged) - Q_PROPERTY( - TaskConnectionsModel *connectionsModel READ connectionsModel NOTIFY connectionsModelChanged) - Q_PROPERTY(QVariantList taskItems READ taskItems WRITE setTaskItems NOTIFY taskItemsChanged) - -public: - explicit FlowItem(QQuickItem *parent = nullptr); - - QString flowId() const; - void setFlowId(const QString &newFlowId); - - Flow *flow() const; - void setFlow(Flow *newFlow); - - TaskModel *taskModel() const; - - TaskConnectionsModel *connectionsModel() const; - - QVariantList taskItems() const; - void setTaskItems(const QVariantList &newTaskItems); - - void updateFlowLayout(); - -signals: - void flowIdChanged(); - void flowChanged(); - void taskModelChanged(); - void connectionsModelChanged(); - void taskItemsChanged(); - -private: - Flow *m_flow = nullptr; - TaskModel *m_taskModel = nullptr; - TaskConnectionsModel *m_connectionsModel = nullptr; - QVariantList m_taskItems; - - QHash m_taskItemsList; - QHash m_taskConnectionsList; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/FlowsModel.cpp b/TaskFlow/Editor/FlowsModel.cpp deleted file mode 100644 index 1aa82dc..0000000 --- a/TaskFlow/Editor/FlowsModel.cpp +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "FlowsModel.hpp" - -#include "FlowManager.hpp" - -namespace QodeAssist::TaskFlow { - -FlowsModel::FlowsModel(FlowManager *flowManager, QObject *parent) - : QAbstractListModel(parent) - , m_flowManager(flowManager) -{ - connect(m_flowManager, &FlowManager::flowAdded, this, &FlowsModel::onFlowAdded); -} - -int FlowsModel::rowCount(const QModelIndex &parent) const -{ - return m_flowManager->flows().size(); -} - -QVariant FlowsModel::data(const QModelIndex &index, int role) const -{ - if (!index.isValid() || !m_flowManager || index.row() >= m_flowManager->flows().size()) - return QVariant(); - - const auto flows = m_flowManager->flows().values(); - - switch (role) { - case FlowRoles::FlowIdRole: - return flows.at(index.row())->flowId(); - case FlowRoles::FlowDataRole: - return QVariant::fromValue(flows.at(index.row())); - default: - return QVariant(); - } -} - -QHash FlowsModel::roleNames() const -{ - QHash roles; - roles[FlowRoles::FlowIdRole] = "flowId"; - roles[FlowRoles::FlowDataRole] = "flowData"; - return roles; -} - -void FlowsModel::onFlowAdded(const QString &flowId) -{ - // qDebug() << "FlowsModel::Flow added: " << flowId; - // int newIndex = m_flowManager->flows().size(); - // beginInsertRows(QModelIndex(), newIndex, newIndex); - // endInsertRows(); -} - -void FlowsModel::onFlowRemoved(const QString &flowId) {} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/FlowsModel.hpp b/TaskFlow/Editor/FlowsModel.hpp deleted file mode 100644 index 97c6555..0000000 --- a/TaskFlow/Editor/FlowsModel.hpp +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -// #include "tasks/Flow.hpp" -#include - -namespace QodeAssist::TaskFlow { - -class FlowsModel : public QAbstractListModel -{ - Q_OBJECT -public: - enum FlowRoles { FlowIdRole = Qt::UserRole, FlowDataRole }; - - FlowsModel(FlowManager *flowManager, QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - -public slots: - void onFlowAdded(const QString &flowId); - void onFlowRemoved(const QString &flowId); - -private: - FlowManager *m_flowManager; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/GridBackground.cpp b/TaskFlow/Editor/GridBackground.cpp deleted file mode 100644 index e1f5c3c..0000000 --- a/TaskFlow/Editor/GridBackground.cpp +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "GridBackground.hpp" -#include -#include -#include -#include -#include - -namespace QodeAssist::TaskFlow { - -GridBackground::GridBackground(QQuickItem *parent) - : QQuickItem(parent) -{ - setFlag(QQuickItem::ItemHasContents, true); -} - -int GridBackground::gridSize() const -{ - return m_gridSize; -} - -void GridBackground::setGridSize(int size) -{ - if (m_gridSize != size) { - m_gridSize = size; - update(); - emit gridSizeChanged(); - } -} - -QColor GridBackground::gridColor() const -{ - return m_gridColor; -} - -void GridBackground::setGridColor(const QColor &color) -{ - if (m_gridColor != color) { - m_gridColor = color; - update(); - emit gridColorChanged(); - } -} - -QSGNode *GridBackground::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) -{ - QSGSimpleTextureNode *node = static_cast(oldNode); - if (!node) { - node = new QSGSimpleTextureNode(); - } - - QPixmap pixmap(width(), height()); - pixmap.fill(Qt::transparent); - - QPainter painter(&pixmap); - painter.setRenderHint(QPainter::Antialiasing, false); - - QPen pen(m_gridColor); - pen.setWidth(1); - painter.setPen(pen); - painter.setOpacity(this->opacity()); - - for (int x = 0; x < width(); x += m_gridSize) { - painter.drawLine(x, 0, x, height()); - } - - for (int y = 0; y < height(); y += m_gridSize) { - painter.drawLine(0, y, width(), y); - } - - painter.end(); - - QSGTexture *texture = window()->createTextureFromImage(pixmap.toImage()); - node->setTexture(texture); - node->setRect(boundingRect()); - - return node; -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/GridBackground.hpp b/TaskFlow/Editor/GridBackground.hpp deleted file mode 100644 index df52b9c..0000000 --- a/TaskFlow/Editor/GridBackground.hpp +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include - -namespace QodeAssist::TaskFlow { - -class GridBackground : public QQuickItem -{ - Q_OBJECT - QML_ELEMENT - - Q_PROPERTY(int gridSize READ gridSize WRITE setGridSize NOTIFY gridSizeChanged) - Q_PROPERTY(QColor gridColor READ gridColor WRITE setGridColor NOTIFY gridColorChanged) - -public: - explicit GridBackground(QQuickItem *parent = nullptr); - - int gridSize() const; - void setGridSize(int size); - - QColor gridColor() const; - void setGridColor(const QColor &color); - -signals: - void gridSizeChanged(); - void gridColorChanged(); - -protected: - QSGNode *updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) override; - -private: - int m_gridSize = 20; - QColor m_gridColor = QColor(128, 128, 128); -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskConnectionItem.cpp b/TaskFlow/Editor/TaskConnectionItem.cpp deleted file mode 100644 index 585e49d..0000000 --- a/TaskFlow/Editor/TaskConnectionItem.cpp +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "TaskConnectionItem.hpp" -#include "TaskItem.hpp" -#include "TaskPortItem.hpp" -#include - -namespace QodeAssist::TaskFlow { - -TaskConnectionItem::TaskConnectionItem(QQuickItem *parent) - : QQuickItem(parent) -{ - setObjectName("TaskConnectionItem"); -} - -void TaskConnectionItem::setConnection(TaskConnection *connection) -{ - if (m_connection == connection) - return; - - m_connection = connection; - emit connectionChanged(); - - calculatePositions(); -} - -void TaskConnectionItem::updatePositions() -{ - // calculatePositions(); -} - -void TaskConnectionItem::calculatePositions() -{ - if (!m_connection) { - return; - } - - // Find source task item - QQuickItem *sourceTaskItem = findTaskItem(m_connection->sourceTask()); - QQuickItem *targetTaskItem = findTaskItem(m_connection->targetTask()); - - if (!sourceTaskItem || !targetTaskItem) { - return; - } - - // Find port items within tasks - QQuickItem *sourcePortItem = findPortItem(sourceTaskItem, m_connection->sourcePort()); - QQuickItem *targetPortItem = findPortItem(targetTaskItem, m_connection->targetPort()); - - if (!sourcePortItem || !targetPortItem) { - return; - } - - // Calculate global positions - QPointF sourceGlobal - = sourcePortItem - ->mapToItem(parentItem(), sourcePortItem->width() / 2, sourcePortItem->height() / 2); - QPointF targetGlobal - = targetPortItem - ->mapToItem(parentItem(), targetPortItem->width() / 2, targetPortItem->height() / 2); - - if (m_startPoint != sourceGlobal) { - m_startPoint = sourceGlobal; - emit startPointChanged(); - } - - if (m_endPoint != targetGlobal) { - m_endPoint = targetGlobal; - emit endPointChanged(); - } -} - -QQuickItem *TaskConnectionItem::findTaskItem(BaseTask *task) -{ - for (const QVariant &item : m_taskItems) { - QQuickItem *taskItem = qvariant_cast(item); - if (!taskItem) - continue; - - QVariant taskProp = taskItem->property("task"); - if (taskProp.isValid() && taskProp.value() == task) { - return taskItem; - } - } - return nullptr; -} - -QQuickItem *TaskConnectionItem::findTaskItemRecursive(QQuickItem *item, BaseTask *task) -{ - // Проверяем objectName и task property - if (item->objectName() == "TaskItem") { - QVariant taskProp = item->property("task"); - if (taskProp.isValid()) { - BaseTask *itemTask = taskProp.value(); - if (itemTask == task) { - return item; - } - } - } - - // Рекурсивно ищем в детях - auto children = item->childItems(); - - for (QQuickItem *child : children) { - if (QQuickItem *found = findTaskItemRecursive(child, task)) { - return found; - } - } - - return nullptr; -} - -QQuickItem *TaskConnectionItem::findPortItem(QQuickItem *taskItem, TaskPort *port) -{ - std::function findPortRecursive = - [&](QQuickItem *item) -> QQuickItem * { - // Проверяем objectName и port property - if (item->objectName() == "TaskPortItem") { - QVariant portProp = item->property("port"); - if (portProp.isValid()) { - TaskPort *itemPort = portProp.value(); - if (itemPort == port) { - return item; - } - } - } - - // Рекурсивно ищем в детях - for (QQuickItem *child : item->childItems()) { - if (QQuickItem *found = findPortRecursive(child)) { - return found; - } - } - return nullptr; - }; - - return findPortRecursive(taskItem); -} - -QVariantList TaskConnectionItem::taskItems() const -{ - return m_taskItems; -} - -void TaskConnectionItem::setTaskItems(const QVariantList &newTaskItems) -{ - if (m_taskItems == newTaskItems) - return; - m_taskItems = newTaskItems; - emit taskItemsChanged(); - - calculatePositions(); -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskConnectionItem.hpp b/TaskFlow/Editor/TaskConnectionItem.hpp deleted file mode 100644 index 96c9f13..0000000 --- a/TaskFlow/Editor/TaskConnectionItem.hpp +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include "TaskConnection.hpp" -#include -#include - -namespace QodeAssist::TaskFlow { - -class TaskConnectionItem : public QQuickItem -{ - Q_OBJECT - QML_ELEMENT - - Q_PROPERTY(QPointF startPoint READ startPoint NOTIFY startPointChanged) - Q_PROPERTY(QPointF endPoint READ endPoint NOTIFY endPointChanged) - Q_PROPERTY( - TaskConnection *connection READ connection WRITE setConnection NOTIFY connectionChanged) - - Q_PROPERTY(QVariantList taskItems READ taskItems WRITE setTaskItems NOTIFY taskItemsChanged) - -public: - TaskConnectionItem(QQuickItem *parent = nullptr); - - QPointF startPoint() const { return m_startPoint; } - QPointF endPoint() const { return m_endPoint; } - - TaskConnection *connection() const { return m_connection; } - void setConnection(TaskConnection *connection); - - Q_INVOKABLE void updatePositions(); - - QVariantList taskItems() const; - void setTaskItems(const QVariantList &newTaskItems); - -signals: - void startPointChanged(); - void endPointChanged(); - void connectionChanged(); - - void taskItemsChanged(); - -private: - void calculatePositions(); - QQuickItem *findTaskItem(BaseTask *task); - QQuickItem *findTaskItemRecursive(QQuickItem *item, BaseTask *task); - QQuickItem *findPortItem(QQuickItem *taskItem, TaskPort *port); - -private: - TaskConnection *m_connection = nullptr; - QPointF m_startPoint; - QPointF m_endPoint; - QVariantList m_taskItems; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskConnectionsModel.cpp b/TaskFlow/Editor/TaskConnectionsModel.cpp deleted file mode 100644 index 0abea93..0000000 --- a/TaskFlow/Editor/TaskConnectionsModel.cpp +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "TaskConnectionsModel.hpp" - -namespace QodeAssist::TaskFlow { - -TaskConnectionsModel::TaskConnectionsModel(Flow *flow, QObject *parent) - : QAbstractListModel(parent) - , m_flow(flow) -{} - -int TaskConnectionsModel::rowCount(const QModelIndex &parent) const -{ - return m_flow->connections().size(); -} - -QVariant TaskConnectionsModel::data(const QModelIndex &index, int role) const -{ - if (role == TaskConnectionsRoles::TaskConnectionsRole) - return QVariant::fromValue(m_flow->connections().at(index.row())); - return QVariant(); -} - -QHash TaskConnectionsModel::roleNames() const -{ - QHash roles; - roles[TaskConnectionsRoles::TaskConnectionsRole] = "connectionData"; - return roles; -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskConnectionsModel.hpp b/TaskFlow/Editor/TaskConnectionsModel.hpp deleted file mode 100644 index 9a1d814..0000000 --- a/TaskFlow/Editor/TaskConnectionsModel.hpp +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -#include - -namespace QodeAssist::TaskFlow { - -class TaskConnectionsModel : public QAbstractListModel -{ -public: - enum TaskConnectionsRoles { TaskConnectionsRole = Qt::UserRole }; - - explicit TaskConnectionsModel(Flow *flow, QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - -private: - Flow *m_flow; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskItem.cpp b/TaskFlow/Editor/TaskItem.cpp deleted file mode 100644 index c92bf42..0000000 --- a/TaskFlow/Editor/TaskItem.cpp +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "TaskItem.hpp" - -namespace QodeAssist::TaskFlow { - -TaskItem::TaskItem(QQuickItem *parent) - : QQuickItem(parent) -{ - setObjectName("TaskItem"); -} - -QString TaskItem::taskId() const -{ - return m_taskId; -} - -void TaskItem::setTaskId(const QString &newTaskId) -{ - if (m_taskId == newTaskId) - return; - m_taskId = newTaskId; - emit taskIdChanged(); -} - -QString TaskItem::taskType() const -{ - return m_task ? m_task->taskType() : QString(); -} - -BaseTask *TaskItem::task() const -{ - return m_task; -} - -void TaskItem::setTask(BaseTask *newTask) -{ - if (m_task == newTask) - return; - - m_task = newTask; - - if (m_task) { - m_taskId = m_task->taskId(); - - // Обновляем модели портов - m_inputPorts = new TaskPortModel(m_task->getInputPorts(), this); - m_outputPorts = new TaskPortModel(m_task->getOutputPorts(), this); - } else { - m_inputPorts = nullptr; - m_outputPorts = nullptr; - } - - emit taskChanged(); - emit inputPortsChanged(); - emit outputPortsChanged(); - emit taskIdChanged(); - emit taskTypeChanged(); -} - -TaskPortModel *TaskItem::inputPorts() const -{ - return m_inputPorts; -} - -TaskPortModel *TaskItem::outputPorts() const -{ - return m_outputPorts; -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskItem.hpp b/TaskFlow/Editor/TaskItem.hpp deleted file mode 100644 index a775067..0000000 --- a/TaskFlow/Editor/TaskItem.hpp +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include "TaskPortModel.hpp" -#include -#include - -namespace QodeAssist::TaskFlow { - -class TaskItem : public QQuickItem -{ - Q_OBJECT - QML_ELEMENT - - Q_PROPERTY(QString taskId READ taskId WRITE setTaskId NOTIFY taskIdChanged) - Q_PROPERTY(QString taskType READ taskType NOTIFY taskTypeChanged) - Q_PROPERTY(BaseTask *task READ task WRITE setTask NOTIFY taskChanged) - Q_PROPERTY(TaskPortModel *inputPorts READ inputPorts NOTIFY inputPortsChanged) - Q_PROPERTY(TaskPortModel *outputPorts READ outputPorts NOTIFY outputPortsChanged) - -public: - TaskItem(QQuickItem *parent = nullptr); - - QString taskId() const; - void setTaskId(const QString &newTaskId); - QString taskType() const; - - BaseTask *task() const; - void setTask(BaseTask *newTask); - - TaskPortModel *inputPorts() const; - TaskPortModel *outputPorts() const; - -signals: - void taskIdChanged(); - void taskTypeChanged(); - void taskChanged(); - void inputPortsChanged(); - void outputPortsChanged(); - -private: - QString m_taskId; - BaseTask *m_task = nullptr; - TaskPortModel *m_inputPorts = nullptr; - TaskPortModel *m_outputPorts = nullptr; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskModel.cpp b/TaskFlow/Editor/TaskModel.cpp deleted file mode 100644 index 5fc36a1..0000000 --- a/TaskFlow/Editor/TaskModel.cpp +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "TaskModel.hpp" - -namespace QodeAssist::TaskFlow { - -TaskModel::TaskModel(Flow *flow, QObject *parent) - : QAbstractListModel(parent) - , m_flow(flow) -{} - -int TaskModel::rowCount(const QModelIndex &parent) const -{ - return m_flow->tasks().size(); -} - -QVariant TaskModel::data(const QModelIndex &index, int role) const -{ - if (!index.isValid() || !m_flow || index.row() >= m_flow->tasks().size()) - return QVariant(); - - const auto &task = m_flow->tasks().values(); - - switch (role) { - case TaskRoles::TaskIdRole: - return task.at(index.row())->taskId(); - case TaskRoles::TaskDataRole: - return QVariant::fromValue(task.at(index.row())); - default: - return QVariant(); - } -} - -QHash TaskModel::roleNames() const -{ - QHash roles; - roles[TaskRoles::TaskIdRole] = "taskId"; - roles[TaskRoles::TaskDataRole] = "taskData"; - return roles; -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskModel.hpp b/TaskFlow/Editor/TaskModel.hpp deleted file mode 100644 index b9b74d5..0000000 --- a/TaskFlow/Editor/TaskModel.hpp +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include - -namespace QodeAssist::TaskFlow { - -class TaskModel : public QAbstractListModel -{ - Q_OBJECT -public: - enum TaskRoles { TaskIdRole = Qt::UserRole, TaskDataRole }; - - TaskModel(Flow *flow, QObject *parent); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - -private: - Flow *m_flow; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskPortItem.cpp b/TaskFlow/Editor/TaskPortItem.cpp deleted file mode 100644 index 5f97f13..0000000 --- a/TaskFlow/Editor/TaskPortItem.cpp +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "TaskPortItem.hpp" - -namespace QodeAssist::TaskFlow { - -TaskPortItem::TaskPortItem(QQuickItem *parent) - : QQuickItem(parent) -{ - setObjectName("TaskPortItem"); -} - -TaskPort *TaskPortItem::port() const -{ - return m_port; -} - -void TaskPortItem::setPort(TaskPort *newPort) -{ - if (m_port == newPort) - return; - m_port = newPort; - emit portChanged(); -} - -QString TaskPortItem::name() const -{ - return m_port->name(); -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskPortItem.hpp b/TaskFlow/Editor/TaskPortItem.hpp deleted file mode 100644 index 2ae6228..0000000 --- a/TaskFlow/Editor/TaskPortItem.hpp +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::TaskFlow { - -class TaskPortItem : public QQuickItem -{ - Q_OBJECT - QML_ELEMENT - - Q_PROPERTY(TaskPort *port READ port WRITE setPort NOTIFY portChanged) - Q_PROPERTY(QString name READ name CONSTANT) - -public: - TaskPortItem(QQuickItem *parent = nullptr); - - TaskPort *port() const; - void setPort(TaskPort *newPort); - - QString name() const; - -signals: - void portChanged(); - -private: - TaskPort *m_port = nullptr; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskPortModel.cpp b/TaskFlow/Editor/TaskPortModel.cpp deleted file mode 100644 index abdd6f1..0000000 --- a/TaskFlow/Editor/TaskPortModel.cpp +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "TaskPortModel.hpp" -#include "TaskPort.hpp" - -namespace QodeAssist::TaskFlow { - -TaskPortModel::TaskPortModel(const QList &ports, QObject *parent) - : QAbstractListModel(parent) - , m_ports(ports) -{} - -int TaskPortModel::rowCount(const QModelIndex &parent) const -{ - return m_ports.size(); -} - -QVariant TaskPortModel::data(const QModelIndex &index, int role) const -{ - if (!index.isValid() || index.row() >= m_ports.size()) - return QVariant(); - - switch (role) { - case TaskPortRoles::TaskPortNameRole: - return m_ports.at(index.row())->name(); - case TaskPortRoles::TaskPortDataRole: - return QVariant::fromValue(m_ports.at(index.row())); - default: - return QVariant(); - } -} - -QHash TaskPortModel::roleNames() const -{ - QHash roles; - roles[TaskPortRoles::TaskPortNameRole] = "taskPortName"; - roles[TaskPortRoles::TaskPortDataRole] = "taskPortData"; - return roles; -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/TaskPortModel.hpp b/TaskFlow/Editor/TaskPortModel.hpp deleted file mode 100644 index 4bd6012..0000000 --- a/TaskFlow/Editor/TaskPortModel.hpp +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include - -namespace QodeAssist::TaskFlow { - -class TaskPortModel : public QAbstractListModel -{ - Q_OBJECT -public: - enum TaskPortRoles { TaskPortNameRole = Qt::UserRole, TaskPortDataRole }; - - TaskPortModel(const QList &ports, QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - -private: - QList m_ports; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/Editor/qml/Flow.qml b/TaskFlow/Editor/qml/Flow.qml deleted file mode 100644 index 9007b13..0000000 --- a/TaskFlow/Editor/qml/Flow.qml +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -import QtQuick -import TaskFlow.Editor - -FlowItem { - id: root - - Repeater { - id: tasks - - model: root.taskModel - delegate: Task { - // task: taskData - } - } - - Repeater { - id: connections - - model: root.taskModel - delegate: TaskConnection { - // task: taskData - } - } - - // property var qtaskItems: [] - - // // Flow container background - // Rectangle { - // anchors.fill: parent - // color: palette.alternateBase - // border.color: palette.mid - // border.width: 2 - // radius: 8 - - // // Flow header - // Rectangle { - // id: flowHeader - // anchors.top: parent.top - // anchors.left: parent.left - // anchors.right: parent.right - // height: 40 - // color: palette.button - // radius: 6 - - // Rectangle { - // anchors.bottom: parent.bottom - // anchors.left: parent.left - // anchors.right: parent.right - // height: parent.radius - // color: parent.color - // } - - // Text { - // anchors.centerIn: parent - // text: root.flowId - // color: palette.buttonText - // font.pixelSize: 14 - // font.bold: true - // } - // } - - // // // Tasks container - // // Row { - // // id: tasksRow - // // anchors.top: flowHeader.bottom - // // anchors.left: parent.left - // // anchors.margins: 25 - // // anchors.topMargin: 25 - // // objectName: "FlowTaskRow" - - // // spacing: 40 - - // // Repeater { - // // model: root.taskModel - - // // delegate: Task { - // // task: taskData - // // } - - // // onItemAdded: function(index, item){ - // // console.log("task added", index, item) - // // qtaskItems.push(item) - // // root.insertTaskItem(index, item) - // // } - - // // onItemRemoved: function(index, item){ - // // console.log("task added", index, item) - // // var idx = qtaskItems.indexOf(item) - // // if (idx !== -1) qtaskItems.splice(idx, 1) - // // } - // // } - // // } - - // // Repeater { - // // model: root.connectionsModel - - // // delegate: TaskConnection { - // // connection: connectionData - // // } - // // } - // } - - // // Flow info tooltip - // Rectangle { - // id: infoTooltip - // anchors.top: parent.bottom - // anchors.left: parent.left - // anchors.topMargin: 5 - // width: infoText.width + 20 - // height: infoText.height + 10 - // color: palette.base - // border.color: palette.shadow - // border.width: 1 - // radius: 4 - // visible: false - - // Text { - // id: infoText - // anchors.centerIn: parent - // text: "Tasks: " + (root.taskModel ? root.taskModel.rowCount() : 0) - // color: palette.text - // font.pixelSize: 10 - // } - // } - - // MouseArea { - // anchors.fill: parent - // hoverEnabled: true - - // onEntered: infoTooltip.visible = true - // onExited: infoTooltip.visible = false - // } - -} diff --git a/TaskFlow/Editor/qml/FlowEditorView.qml b/TaskFlow/Editor/qml/FlowEditorView.qml deleted file mode 100644 index 724b41d..0000000 --- a/TaskFlow/Editor/qml/FlowEditorView.qml +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -import QtQuick -import QtQuick.Controls -import TaskFlow.Editor - -FlowEditor { - id: root - - width: 1200 - height: 800 - - property SystemPalette sysPalette: SystemPalette { - colorGroup: SystemPalette.Active - } - palette { - window: sysPalette.window - windowText: sysPalette.windowText - base: sysPalette.base - alternateBase: sysPalette.alternateBase - text: sysPalette.text - button: sysPalette.button - buttonText: sysPalette.buttonText - highlight: sysPalette.highlight - highlightedText: sysPalette.highlightedText - light: sysPalette.light - mid: sysPalette.mid - dark: sysPalette.dark - shadow: sysPalette.shadow - brightText: sysPalette.brightText - } - - // Background with grid pattern - Rectangle { - anchors.fill: parent - color: palette.window - - // Grid pattern using C++ implementation - GridBackground { - anchors.fill: parent - gridSize: 20 - gridColor: palette.mid - opacity: 0.3 - } - } - - // Header panel - Rectangle { - id: headerPanel - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: 60 - color: palette.base - border.color: palette.mid - border.width: 1 - - Row { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 20 - spacing: 20 - - Text { - text: "Flow Editor" - color: palette.windowText - font.pixelSize: 18 - font.bold: true - } - - Rectangle { - width: 2 - height: 30 - color: palette.mid - } - - Text { - text: "Flow:" - color: palette.text - font.pixelSize: 14 - } - - ComboBox { - id: flowComboBox - - model: root.flowsModel - textRole: "flowId" - currentIndex: root.currentFlowIndex - - onActivated: { - root.currentFlowIndex = currentIndex - } - } - - Text { - text: "Available Tasks: " + root.availableTaskTypes.join(", ") - color: palette.text - font.pixelSize: 12 - } - } - } - - // Main flow area - ScrollView { - id: scrollView - anchors.top: headerPanel.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - - contentWidth: flow.width - contentHeight: flow.height - - Flow { - id: flow - - // flow: root.currentFlow - - width: Math.max(root.width, 0) - height: Math.min(root.height, 0) - } - } -} diff --git a/TaskFlow/Editor/qml/Task.qml b/TaskFlow/Editor/qml/Task.qml deleted file mode 100644 index de9f0f3..0000000 --- a/TaskFlow/Editor/qml/Task.qml +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -import QtQuick -import TaskFlow.Editor - -TaskItem{ - id: root - - width: 280 - height: Math.max(200, contentColumn.height + 40) - - DragHandler { - id: dragHandler - - target: root - onActiveChanged: { - if (active) { - root.z = 1000; // Поднять над остальными - } else { - root.z = 0; - } - } - } - - // Task node background - Rectangle { - anchors.fill: parent - color: palette.window - border.color: palette.shadow - border.width: 1 - radius: 6 - - // Task header - Rectangle { - id: taskHeader - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: 40 - color: palette.button - radius: 6 - - Rectangle { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - height: parent.radius - color: parent.color - } - - Text { - anchors.centerIn: parent - // text: root.taskType - color: palette.buttonText - font.pixelSize: 14 - font.bold: true - } - } - - // Task content - Column { - id: contentColumn - anchors.top: taskHeader.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: 10 - spacing: 8 - - // Task ID - Text { - text: "ID: " + root.taskId - color: palette.text - font.pixelSize: 11 - width: parent.width - elide: Text.ElideRight - } - - // Parameters section - Item { - width: parent.width - height: paramColumn.height - // visible: root.parameters && root.parameters.rowCount() > 0 - - Column { - id: paramColumn - width: parent.width - spacing: 6 - - Text { - text: "Parameters:" - color: palette.text - font.pixelSize: 10 - font.bold: true - } - - Repeater { - model: root.parameters - delegate: Rectangle { - width: parent.width - height: 24 - color: palette.base - radius: 4 - - Row { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 8 - spacing: 6 - - Text { - text: paramKey + ":" - color: palette.text - font.pixelSize: 9 - font.bold: true - } - - Text { - text: paramValue - color: palette.windowText - font.pixelSize: 9 - width: Math.min(150, implicitWidth) - elide: Text.ElideRight - } - } - } - } - } - } - } - } - - // Input ports section (left side) - Column { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: -8 - spacing: 6 - // visible: root.inputPorts && root.inputPorts.rowCount() > 0 - - // Input label - Text { - text: "IN" - color: palette.highlight - font.pixelSize: 10 - font.bold: true - anchors.left: parent.left - anchors.leftMargin: -20 - } - - // Repeater { - // model: root.inputPorts - // delegate: Row { - // spacing: 6 - - // Text { - // text: taskPortName - // color: palette.text - // font.pixelSize: 9 - // anchors.verticalCenter: parent.verticalCenter - // horizontalAlignment: Text.AlignRight - // width: 60 - // elide: Text.ElideLeft - // } - - // TaskPort { - // port: taskPortData - // isInput: true - // } - // } - // } - } - - // Output ports section (right side) - Column { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.rightMargin: -10 - spacing: 8 - // visible: root.outputPorts && root.outputPorts.rowCount() > 0 - - // Output label - Text { - text: "OUT" - color: palette.highlight - font.pixelSize: 10 - font.bold: true - anchors.right: parent.right - anchors.rightMargin: -24 - } - - // Repeater { - // model: root.outputPorts - // delegate: Row { - // spacing: 6 - - // TaskPort { - // port: taskPortData - // isInput: false - // } - - // Text { - // text: taskPortName - // color: palette.text - // font.pixelSize: 9 - // anchors.verticalCenter: parent.verticalCenter - // width: 60 - // elide: Text.ElideRight - // } - // } - // } - } -} diff --git a/TaskFlow/Editor/qml/TaskConnection.qml b/TaskFlow/Editor/qml/TaskConnection.qml deleted file mode 100644 index abe4f09..0000000 --- a/TaskFlow/Editor/qml/TaskConnection.qml +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -import QtQuick -import QtQuick.Shapes -import TaskFlow.Editor - -TaskConnectionItem { - id: root - - property color connectionColor: "red" - - Rectangle { - width: 10 - height: 10 - radius: width / 2 - color: "blue" - } - - // width: Math.abs(endPoint.x - startPoint.x) + 40 - // height: Math.abs(endPoint.y - startPoint.y) + 40 - // x: Math.min(startPoint.x, endPoint.x) - 20 - // y: Math.min(startPoint.y, endPoint.y) - 20 - - // Shape { - // anchors.fill: parent - - // ShapePath { - // strokeWidth: 2 - // strokeColor: connectionColor - // fillColor: "transparent" - - // property point localStart: Qt.point( - // root.startPoint.x - root.x, - // root.startPoint.y - root.y - // ) - // property point localEnd: Qt.point( - // root.endPoint.x - root.x, - // root.endPoint.y - root.y - // ) - - // // Bezier curve - // property real controlOffset: Math.max(50, Math.abs(localEnd.x - localStart.x) * 0.4) - - // startX: localStart.x - // startY: localStart.y - - // PathCubic { - // x: parent.localEnd.x - // y: parent.localEnd.y - // control1X: parent.localStart.x + parent.controlOffset - // control1Y: parent.localStart.y - // control2X: parent.localEnd.x - parent.controlOffset - // control2Y: parent.localEnd.y - // } - // } - - // // Arrow head - // Rectangle { - // width: 8 - // height: 8 - // color: connectionColor - // rotation: 45 - // x: root.endPoint.x - root.x - 4 - // y: root.endPoint.y - root.y - 4 - // } - // } - - // // Update positions when tasks might have moved - // Component.onCompleted: updatePositions() -} diff --git a/TaskFlow/Editor/qml/TaskParameter.qml b/TaskFlow/Editor/qml/TaskParameter.qml deleted file mode 100644 index f1d179d..0000000 --- a/TaskFlow/Editor/qml/TaskParameter.qml +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -import QtQuick -import TaskFlow.Editor - -Item { - -} diff --git a/TaskFlow/Editor/qml/TaskPort.qml b/TaskFlow/Editor/qml/TaskPort.qml deleted file mode 100644 index b005ef1..0000000 --- a/TaskFlow/Editor/qml/TaskPort.qml +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -import QtQuick -import TaskFlow.Editor - -TaskPortItem { - id: root - - property bool isInput: true - - width: 20 - height: 20 - - // Port circle - Rectangle { - id: portCircle - anchors.centerIn: parent - width: 16 - height: 16 - radius: 8 - color: getPortColor() - border.color: palette.windowText - border.width: 1 - - // Inner circle for connected state simulation - Rectangle { - anchors.centerIn: parent - width: 8 - height: 8 - radius: 4 - color: root.port ? palette.windowText : "transparent" - visible: root.port !== null - } - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - - onEntered: { - portCircle.scale = 1.3 - portCircle.border.width = 2 - } - - onExited: { - portCircle.scale = 1.0 - portCircle.border.width = 1 - } - } - - function getPortColor() { - if (!root.port) return palette.mid - - // Different colors for input/output using system palette - if (root.isInput) { - return palette.highlight // System highlight color for inputs - } else { - return Qt.lighter(palette.highlight, 1.3) // Lighter highlight for outputs - } - } - - Behavior on scale { - NumberAnimation { duration: 100 } - } -} diff --git a/TaskFlow/core/BaseTask.cpp b/TaskFlow/core/BaseTask.cpp deleted file mode 100644 index 6e86a5f..0000000 --- a/TaskFlow/core/BaseTask.cpp +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "BaseTask.hpp" -#include "TaskPort.hpp" -#include -#include - -namespace QodeAssist::TaskFlow { - -BaseTask::BaseTask(QObject *parent) - : QObject(parent) - , m_taskId("unknown" + QUuid::createUuid().toString()) -{} - -BaseTask::~BaseTask() -{ - qDeleteAll(m_inputs); - qDeleteAll(m_outputs); -} - -QString BaseTask::taskId() const -{ - return m_taskId; -} - -void BaseTask::setTaskId(const QString &taskId) -{ - m_taskId = taskId; -} - -QString BaseTask::taskType() const -{ - return QString(metaObject()->className()).split("::").last(); -} - -void BaseTask::addInputPort(const QString &name) -{ - QMutexLocker locker(&m_tasksMutex); - m_inputs.append(new TaskPort(name, TaskPort::ValueType::Any, this)); -} - -void BaseTask::addOutputPort(const QString &name) -{ - QMutexLocker locker(&m_tasksMutex); - m_outputs.append(new TaskPort(name, TaskPort::ValueType::Any, this)); -} - -TaskPort *BaseTask::inputPort(const QString &name) const -{ - QMutexLocker locker(&m_tasksMutex); - - auto it = std::find_if(m_inputs.begin(), m_inputs.end(), [&name](const TaskPort *port) { - return port->name() == name; - }); - - return (it != m_inputs.end()) ? *it : nullptr; -} - -TaskPort *BaseTask::outputPort(const QString &name) const -{ - QMutexLocker locker(&m_tasksMutex); - - auto it = std::find_if(m_outputs.begin(), m_outputs.end(), [&name](const TaskPort *port) { - return port->name() == name; - }); - - return (it != m_outputs.end()) ? *it : nullptr; -} - -QList BaseTask::getInputPorts() const -{ - QMutexLocker locker(&m_tasksMutex); - return m_inputs; -} - -QList BaseTask::getOutputPorts() const -{ - QMutexLocker locker(&m_tasksMutex); - return m_outputs; -} - -QFuture BaseTask::executeAsync() -{ - return QtConcurrent::task([this]() -> TaskState { return execute(); }).spawn(); -} - -QString BaseTask::taskStateAsString(TaskState state) -{ - switch (state) { - case TaskState::Success: - return "Success"; - case TaskState::Failed: - return "Failed"; - case TaskState::Cancelled: - return "Cancelled"; - } - return "Unknown"; -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/core/BaseTask.hpp b/TaskFlow/core/BaseTask.hpp deleted file mode 100644 index fd6b9d4..0000000 --- a/TaskFlow/core/BaseTask.hpp +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include -#include - -namespace QodeAssist::TaskFlow { - -class TaskPort; - -enum class TaskState { Success, Failed, Cancelled }; - -class BaseTask : public QObject -{ - Q_OBJECT - -public: - explicit BaseTask(QObject *parent = nullptr); - virtual ~BaseTask(); - - QString taskId() const; - void setTaskId(const QString &taskId); - QString taskType() const; - - void addInputPort(const QString &name); - void addOutputPort(const QString &name); - - TaskPort *inputPort(const QString &name) const; - TaskPort *outputPort(const QString &name) const; - - QList getInputPorts() const; - QList getOutputPorts() const; - - virtual TaskState execute() = 0; - - static QString taskStateAsString(TaskState state); - -protected: - QFuture executeAsync(); - -private: - QString m_taskId; - QList m_inputs; - QList m_outputs; - mutable QMutex m_tasksMutex; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/core/CMakeLists.txt b/TaskFlow/core/CMakeLists.txt deleted file mode 100644 index a85b4db..0000000 --- a/TaskFlow/core/CMakeLists.txt +++ /dev/null @@ -1,22 +0,0 @@ -qt_add_library(TaskFlowCore STATIC - BaseTask.hpp BaseTask.cpp - TaskConnection.hpp TaskConnection.cpp - Flow.hpp Flow.cpp - TaskPort.hpp TaskPort.cpp - TaskRegistry.hpp TaskRegistry.cpp - FlowManager.hpp FlowManager.cpp - FlowRegistry.hpp FlowRegistry.cpp -) - -target_link_libraries(TaskFlowCore - PUBLIC - Qt::Core - Qt::Concurrent - PRIVATE - QodeAssistLogger -) - -target_include_directories(TaskFlowCore - PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR} -) diff --git a/TaskFlow/core/Flow.cpp b/TaskFlow/core/Flow.cpp deleted file mode 100644 index 4a4cfbf..0000000 --- a/TaskFlow/core/Flow.cpp +++ /dev/null @@ -1,340 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "Flow.hpp" -#include "TaskPort.hpp" -#include -#include - -namespace QodeAssist::TaskFlow { - -Flow::Flow(QObject *parent) - : QObject(parent) - , m_flowId("flow_" + QUuid::createUuid().toString()) -{} - -Flow::~Flow() -{ - QMutexLocker locker(&m_flowMutex); - qDeleteAll(m_connections); - qDeleteAll(m_tasks); -} - -QString Flow::flowId() const -{ - return m_flowId; -} - -void Flow::setFlowId(const QString &flowId) -{ - if (m_flowId != flowId) { - m_flowId = flowId; - } -} - -void Flow::addTask(BaseTask *task) -{ - if (!task) { - return; - } - - QMutexLocker locker(&m_flowMutex); - - QString taskId = task->taskId(); - if (m_tasks.contains(taskId)) { - qWarning() << "Flow::addTask - Task with ID" << taskId << "already exists"; - return; - } - - m_tasks.insert(taskId, task); - task->setParent(this); - - emit taskAdded(taskId); -} - -void Flow::removeTask(const QString &taskId) -{ - QMutexLocker locker(&m_flowMutex); - - BaseTask *task = m_tasks.value(taskId); - if (!task) { - return; - } - - auto it = m_connections.begin(); - while (it != m_connections.end()) { - TaskConnection *connection = *it; - if (connection->sourceTask() == task || connection->targetTask() == task) { - it = m_connections.erase(it); - emit connectionRemoved(connection); - delete connection; - } else { - ++it; - } - } - - m_tasks.remove(taskId); - emit taskRemoved(taskId); - delete task; -} - -void Flow::removeTask(BaseTask *task) -{ - if (!task) { - return; - } - removeTask(task->taskId()); -} - -BaseTask *Flow::getTask(const QString &taskId) const -{ - QMutexLocker locker(&m_flowMutex); - return m_tasks.value(taskId); -} - -bool Flow::hasTask(const QString &taskId) const -{ - QMutexLocker locker(&m_flowMutex); - return m_tasks.contains(taskId); -} - -QHash Flow::tasks() const -{ - QMutexLocker locker(&m_flowMutex); - return m_tasks; -} - -TaskConnection *Flow::addConnection(TaskPort *sourcePort, TaskPort *targetPort) -{ - if (!sourcePort || !targetPort) { - qWarning() << "Flow::addConnection - Invalid ports"; - return nullptr; - } - - // Verify ports belong to tasks in this flow - BaseTask *sourceTask = qobject_cast(sourcePort->parent()); - BaseTask *targetTask = qobject_cast(targetPort->parent()); - - if (!sourceTask || !targetTask) { - qWarning() << "Flow::addConnection - Ports don't belong to valid tasks"; - return nullptr; - } - - QMutexLocker locker(&m_flowMutex); - - if (!m_tasks.contains(sourceTask->taskId()) || !m_tasks.contains(targetTask->taskId())) { - qWarning() << "Flow::addConnection - Tasks not in this flow"; - return nullptr; - } - - for (TaskConnection *existingConnection : m_connections) { - if (existingConnection->sourcePort() == sourcePort - && existingConnection->targetPort() == targetPort) { - qWarning() << "Flow::addConnection - Connection already exists"; - return existingConnection; - } - } - - TaskConnection *connection = new TaskConnection(sourcePort, targetPort, this); - m_connections.append(connection); - - emit connectionAdded(connection); - return connection; -} - -void Flow::removeConnection(TaskConnection *connection) -{ - if (!connection) { - return; - } - - QMutexLocker locker(&m_flowMutex); - - if (m_connections.removeOne(connection)) { - emit connectionRemoved(connection); - delete connection; - } -} - -QList Flow::connections() const -{ - QMutexLocker locker(&m_flowMutex); - return m_connections; -} - -QFuture Flow::executeAsync() -{ - return QtConcurrent::run([this]() { return execute(); }); -} - -FlowState Flow::execute() -{ - emit executionStarted(); - - if (!isValid()) { - emit executionFinished(FlowState::Failed); - return FlowState::Failed; - } - - if (hasCircularDependencies()) { - qWarning() << "Flow::execute - Circular dependencies detected"; - emit executionFinished(FlowState::Failed); - return FlowState::Failed; - } - - QList executionOrder = getExecutionOrder(); - - for (BaseTask *task : executionOrder) { - TaskState taskResult = task->execute(); - - if (taskResult == TaskState::Failed) { - qWarning() << "Flow::execute - Task" << task->taskId() << "failed"; - emit executionFinished(FlowState::Failed); - return FlowState::Failed; - } - - if (taskResult == TaskState::Cancelled) { - qWarning() << "Flow::execute - Task" << task->taskId() << "cancelled"; - emit executionFinished(FlowState::Cancelled); - return FlowState::Cancelled; - } - } - - emit executionFinished(FlowState::Success); - return FlowState::Success; -} - -bool Flow::isValid() const -{ - QMutexLocker locker(&m_flowMutex); - - // Check all connections are valid - for (TaskConnection *connection : m_connections) { - if (!connection->isValid()) { - return false; - } - } - - return true; -} - -bool Flow::hasCircularDependencies() const -{ - return detectCircularDependencies(); -} - -QString Flow::flowStateAsString(FlowState state) -{ - switch (state) { - case FlowState::Success: - return "Success"; - case FlowState::Failed: - return "Failed"; - case FlowState::Cancelled: - return "Cancelled"; - } - return "Unknown"; -} - -QStringList Flow::getTaskIds() const -{ - QMutexLocker locker(&m_flowMutex); - return m_tasks.keys(); -} - -QList Flow::getExecutionOrder() const -{ - QMutexLocker locker(&m_flowMutex); - - QList result; - QSet visited; - QList allTasks = m_tasks.values(); - - std::function visit = [&](BaseTask *task) { - if (visited.contains(task)) { - return; - } - - visited.insert(task); - - QList dependencies = getTaskDependencies(task); - for (BaseTask *dependency : dependencies) { - visit(dependency); - } - - result.append(task); - }; - - for (BaseTask *task : allTasks) { - visit(task); - } - - return result; -} - -bool Flow::detectCircularDependencies() const -{ - QMutexLocker locker(&m_flowMutex); - - QSet visited; - QSet recursionStack; - bool hasCycle = false; - - for (BaseTask *task : m_tasks.values()) { - if (!visited.contains(task)) { - visitTask(task, visited, recursionStack, hasCycle); - if (hasCycle) { - return true; - } - } - } - - return false; -} - -void Flow::visitTask( - BaseTask *task, QSet &visited, QSet &recursionStack, bool &hasCycle) const -{ - if (hasCycle) { - return; - } - - visited.insert(task); - recursionStack.insert(task); - - for (TaskConnection *connection : m_connections) { - if (connection->sourceTask() == task) { - BaseTask *dependentTask = connection->targetTask(); - - if (recursionStack.contains(dependentTask)) { - hasCycle = true; - return; - } - - if (!visited.contains(dependentTask)) { - visitTask(dependentTask, visited, recursionStack, hasCycle); - } - } - } - - recursionStack.remove(task); -} - -QList Flow::getTaskDependencies(BaseTask *task) const -{ - QList dependencies; - - for (TaskConnection *connection : m_connections) { - if (connection->targetTask() == task) { - BaseTask *dependencyTask = connection->sourceTask(); - if (!dependencies.contains(dependencyTask)) { - dependencies.append(dependencyTask); - } - } - } - - return dependencies; -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/core/Flow.hpp b/TaskFlow/core/Flow.hpp deleted file mode 100644 index 74d305c..0000000 --- a/TaskFlow/core/Flow.hpp +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "BaseTask.hpp" -#include "TaskConnection.hpp" - -namespace QodeAssist::TaskFlow { - -enum class FlowState { Success, Failed, Cancelled }; - -class Flow : public QObject -{ - Q_OBJECT - -public: - explicit Flow(QObject *parent = nullptr); - ~Flow() override; - - QString flowId() const; - void setFlowId(const QString &flowId); - - void addTask(BaseTask *task); - void removeTask(const QString &taskId); - void removeTask(BaseTask *task); - - BaseTask *getTask(const QString &taskId) const; - bool hasTask(const QString &taskId) const; - QHash tasks() const; - - TaskConnection *addConnection(TaskPort *sourcePort, TaskPort *targetPort); - void removeConnection(TaskConnection *connection); - QList connections() const; - - QFuture executeAsync(); - virtual FlowState execute(); - - bool isValid() const; - bool hasCircularDependencies() const; - - static QString flowStateAsString(FlowState state); - QStringList getTaskIds() const; - -signals: - void taskAdded(const QString &taskId); - void taskRemoved(const QString &taskId); - void connectionAdded(QodeAssist::TaskFlow::TaskConnection *connection); - void connectionRemoved(QodeAssist::TaskFlow::TaskConnection *connection); - void executionStarted(); - void executionFinished(FlowState result); - -private: - QString m_flowId; - QHash m_tasks; - QList m_connections; - mutable QMutex m_flowMutex; - - QList getExecutionOrder() const; - bool detectCircularDependencies() const; - void visitTask( - BaseTask *task, - QSet &visited, - QSet &recursionStack, - bool &hasCycle) const; - QList getTaskDependencies(BaseTask *task) const; -}; - -} // namespace QodeAssist::TaskFlow - -Q_DECLARE_METATYPE(QodeAssist::TaskFlow::Flow *) -Q_DECLARE_METATYPE(QodeAssist::TaskFlow::FlowState) diff --git a/TaskFlow/core/FlowManager.cpp b/TaskFlow/core/FlowManager.cpp deleted file mode 100644 index ae5ac4c..0000000 --- a/TaskFlow/core/FlowManager.cpp +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "FlowManager.hpp" - -#include -#include -#include -#include - -#include "FlowRegistry.hpp" -#include "TaskRegistry.hpp" - -namespace QodeAssist::TaskFlow { - -FlowManager::FlowManager(QObject *parent) - : QObject(parent) - , m_taskRegistry(new TaskRegistry(this)) - , m_flowRegistry(new FlowRegistry(this)) -{ - LOG_MESSAGE("FlowManager created"); -} - -FlowManager::~FlowManager() -{ - clear(); -} - -// Flow *FlowManager::createFlow(const QString &flowId) -// { -// Flow *flow = new Flow(flowId, m_taskRegistry, this); -// if (!m_flows.contains(flow->flowId())) { -// m_flows.insert(flowId, flow); -// } else { -// LOG_MESSAGE( -// QString("FlowManager::createFlow - flow with id %1 already exists").arg(flow->flowId())); -// } - -// return flow; -// } - -void FlowManager::addFlow(Flow *flow) -{ - qDebug() << "FlowManager::addFlow" << flow->flowId(); - if (!m_flows.contains(flow->flowId())) { - m_flows.insert(flow->flowId(), flow); - flow->setParent(this); - emit flowAdded(flow->flowId()); - } else { - LOG_MESSAGE( - QString("FlowManager::addFlow - flow with id %1 already exists").arg(flow->flowId())); - } -} - -void FlowManager::clear() -{ - LOG_MESSAGE(QString("FlowManager::clear - removing %1 flows").arg(m_flows.size())); - - qDeleteAll(m_flows); - m_flows.clear(); -} - -QStringList FlowManager::getAvailableTasksTypes() -{ - return m_taskRegistry->getAvailableTypes(); -} - -QStringList FlowManager::getAvailableFlows() -{ - return m_flowRegistry->getAvailableTypes(); -} - -QHash FlowManager::flows() const -{ - return m_flows; -} - -TaskRegistry *FlowManager::taskRegistry() const -{ - return m_taskRegistry; -} - -FlowRegistry *FlowManager::flowRegistry() const -{ - return m_flowRegistry; -} - -Flow *FlowManager::getFlow(const QString &flowId) const -{ - // if (flowId.isEmpty()) { - // return m_flows.begin().value(); - // } - // return m_flows.value(flowId, nullptr); -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/core/FlowManager.hpp b/TaskFlow/core/FlowManager.hpp deleted file mode 100644 index 3aaec4b..0000000 --- a/TaskFlow/core/FlowManager.hpp +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include -#include -#include - -#include "Flow.hpp" - -namespace QodeAssist::TaskFlow { - -class TaskRegistry; -class FlowRegistry; - -class FlowManager : public QObject -{ - Q_OBJECT - -public: - explicit FlowManager(QObject *parent = nullptr); - ~FlowManager() override; - - // Flow *createFlow(const QString &flowId); - void addFlow(Flow *flow); - - void clear(); - - QStringList getAvailableTasksTypes(); - QStringList getAvailableFlows(); - - QHash flows() const; - - TaskRegistry *taskRegistry() const; - FlowRegistry *flowRegistry() const; - - Flow *getFlow(const QString &flowId = {}) const; - -signals: - void flowAdded(const QString &flowId); - void flowRemoved(const QString &flowId); - -private: - QHash m_flows; - - TaskRegistry *m_taskRegistry; - FlowRegistry *m_flowRegistry; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/core/FlowRegistry.cpp b/TaskFlow/core/FlowRegistry.cpp deleted file mode 100644 index 97d27f5..0000000 --- a/TaskFlow/core/FlowRegistry.cpp +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "FlowRegistry.hpp" -#include "Logger.hpp" - -namespace QodeAssist::TaskFlow { - -FlowRegistry::FlowRegistry(QObject *parent) - : QObject(parent) -{} - -void FlowRegistry::registerFlow(const QString &flowType, FlowCreator creator) -{ - m_flowCreators[flowType] = creator; - LOG_MESSAGE(QString("FlowRegistry: Registered flow type '%1'").arg(flowType)); -} - -Flow *FlowRegistry::createFlow(const QString &flowType, FlowManager *flowManager) const -{ - LOG_MESSAGE(QString("Trying to create flow: %1").arg(flowType)); - - if (m_flowCreators.contains(flowType)) { - LOG_MESSAGE(QString("Found creator for flow type: %1").arg(flowType)); - try { - Flow *flow = m_flowCreators[flowType](flowManager); - if (flow) { - LOG_MESSAGE(QString("Successfully created flow: %1").arg(flowType)); - return flow; - } - } catch (...) { - LOG_MESSAGE(QString("Exception while creating flow of type: %1").arg(flowType)); - } - } else { - LOG_MESSAGE(QString("No creator found for flow type: %1").arg(flowType)); - } - - return nullptr; -} - -QStringList FlowRegistry::getAvailableTypes() const -{ - return m_flowCreators.keys(); -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/core/FlowRegistry.hpp b/TaskFlow/core/FlowRegistry.hpp deleted file mode 100644 index 2bb1ff3..0000000 --- a/TaskFlow/core/FlowRegistry.hpp +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include -#include - -namespace QodeAssist::TaskFlow { - -class Flow; -class FlowManager; - -class FlowRegistry : public QObject -{ - Q_OBJECT -public: - using FlowCreator = std::function; - - explicit FlowRegistry(QObject *parent = nullptr); - - void registerFlow(const QString &flowType, FlowCreator creator); - Flow *createFlow(const QString &flowType, FlowManager *flowManager = nullptr) const; - QStringList getAvailableTypes() const; - -private: - QHash m_flowCreators; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/core/TaskConnection.cpp b/TaskFlow/core/TaskConnection.cpp deleted file mode 100644 index 155f1b8..0000000 --- a/TaskFlow/core/TaskConnection.cpp +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "TaskConnection.hpp" -#include "BaseTask.hpp" -#include "TaskPort.hpp" -#include - -namespace QodeAssist::TaskFlow { - -TaskConnection::TaskConnection(TaskPort *sourcePort, TaskPort *targetPort, QObject *parent) - : QObject(parent) - , m_sourcePort(sourcePort) - , m_targetPort(targetPort) -{ - setupConnection(); -} - -TaskConnection::~TaskConnection() -{ - cleanupConnection(); -} - -BaseTask *TaskConnection::sourceTask() const -{ - return m_sourcePort ? qobject_cast(m_sourcePort->parent()) : nullptr; -} - -BaseTask *TaskConnection::targetTask() const -{ - return m_targetPort ? qobject_cast(m_targetPort->parent()) : nullptr; -} - -TaskPort *TaskConnection::sourcePort() const -{ - return m_sourcePort; -} - -TaskPort *TaskConnection::targetPort() const -{ - return m_targetPort; -} - -bool TaskConnection::isValid() const -{ - return m_sourcePort && m_targetPort && m_sourcePort != m_targetPort && sourceTask() - && targetTask() && sourceTask() != targetTask(); -} - -bool TaskConnection::isTypeCompatible() const -{ - if (!isValid()) { - return false; - } - - return m_targetPort->isConnectionTypeCompatible(m_sourcePort); -} - -QString TaskConnection::toString() const -{ - if (!isValid()) { - return QString(); - } - - BaseTask *srcTask = sourceTask(); - BaseTask *tgtTask = targetTask(); - - return QString("%1.%2->%3.%4") - .arg(srcTask->taskId()) - .arg(m_sourcePort->name()) - .arg(tgtTask->taskId()) - .arg(m_targetPort->name()); -} - -bool TaskConnection::operator==(const TaskConnection &other) const -{ - return m_sourcePort == other.m_sourcePort && m_targetPort == other.m_targetPort; -} - -void TaskConnection::setupConnection() -{ - if (!isValid()) { - qWarning() << "TaskConnection::setupConnection - Invalid connection parameters"; - return; - } - - if (!isTypeCompatible()) { - QMetaEnum metaEnum = QMetaEnum::fromType(); - qWarning() << "TaskConnection::setupConnection - Type incompatible connection:" - << metaEnum.valueToKey(static_cast(m_sourcePort->valueType())) << "to" - << metaEnum.valueToKey(static_cast(m_targetPort->valueType())); - } - - m_sourcePort->setConnection(this); - m_targetPort->setConnection(this); -} - -void TaskConnection::cleanupConnection() -{ - if (m_sourcePort && m_sourcePort->connection() == this) { - m_sourcePort->setConnection(nullptr); - } - - if (m_targetPort && m_targetPort->connection() == this) { - m_targetPort->setConnection(nullptr); - } -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/core/TaskConnection.hpp b/TaskFlow/core/TaskConnection.hpp deleted file mode 100644 index 267409b..0000000 --- a/TaskFlow/core/TaskConnection.hpp +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::TaskFlow { - -class BaseTask; -class TaskPort; - -class TaskConnection : public QObject -{ - Q_OBJECT - -public: - // Constructor automatically sets up the connection - explicit TaskConnection(TaskPort *sourcePort, TaskPort *targetPort, QObject *parent = nullptr); - - // Destructor automatically cleans up the connection - ~TaskConnection() override; - - // Getters - BaseTask *sourceTask() const; - BaseTask *targetTask() const; - TaskPort *sourcePort() const; - TaskPort *targetPort() const; - - // Validation - bool isValid() const; - bool isTypeCompatible() const; - - // Utility - QString toString() const; - - // Comparison - bool operator==(const TaskConnection &other) const; - -private: - TaskPort *m_sourcePort; - TaskPort *m_targetPort; - - void setupConnection(); - void cleanupConnection(); -}; - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/core/TaskPort.cpp b/TaskFlow/core/TaskPort.cpp deleted file mode 100644 index bc0964c..0000000 --- a/TaskFlow/core/TaskPort.cpp +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "TaskPort.hpp" -#include "TaskConnection.hpp" -#include - -namespace QodeAssist::TaskFlow { - -TaskPort::TaskPort(const QString &name, ValueType type, QObject *parent) - : QObject(parent) - , m_name(name) - , m_valueType(type) -{} - -QString TaskPort::name() const -{ - return m_name; -} - -void TaskPort::setValueType(ValueType type) -{ - if (m_valueType != type) - m_valueType = type; -} - -TaskPort::ValueType TaskPort::valueType() const -{ - return m_valueType; -} - -void TaskPort::setValue(const QVariant &value) -{ - if (!isValueTypeCompatible(value)) { - qWarning() << "TaskPort::setValue - Type mismatch for port" << m_name << "Expected:" - << QMetaEnum::fromType().valueToKey(static_cast(m_valueType)) - << "Got:" << value.typeName(); - } - - if (m_value != value) { - m_value = value; - emit valueChanged(); - } -} - -QVariant TaskPort::value() const -{ - if (hasConnection() && m_connection->sourcePort()) { - return m_connection->sourcePort()->m_value; - } - return m_value; -} - -void TaskPort::setConnection(TaskConnection *connection) -{ - if (m_connection != connection) { - m_connection = connection; - emit connectionChanged(); - } -} - -TaskConnection *TaskPort::connection() const -{ - return m_connection; -} - -bool TaskPort::hasConnection() const -{ - return m_connection != nullptr; -} - -bool TaskPort::isValueTypeCompatible(const QVariant &value) const -{ - if (m_valueType == ValueType::Any) { - return true; - } - - switch (m_valueType) { - case ValueType::String: - return value.canConvert(); - - case ValueType::Number: - return value.canConvert() || value.canConvert(); - - case ValueType::Boolean: - return value.canConvert(); - - default: - return false; - } -} - -bool TaskPort::isConnectionTypeCompatible(const TaskPort *sourcePort) const -{ - if (!sourcePort) { - return false; - } - - if (sourcePort->valueType() == ValueType::Any || m_valueType == ValueType::Any) { - return true; - } - - return sourcePort->valueType() == m_valueType; -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/core/TaskPort.hpp b/TaskFlow/core/TaskPort.hpp deleted file mode 100644 index 215c98a..0000000 --- a/TaskFlow/core/TaskPort.hpp +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include - -#include "TaskConnection.hpp" - -namespace QodeAssist::TaskFlow { - -class TaskPort : public QObject -{ - Q_OBJECT - -public: - enum class ValueType { - Any, // QVariant - String, // QString - Number, // int/double - Boolean // bool - }; - Q_ENUM(ValueType) - - explicit TaskPort( - const QString &name, ValueType type = ValueType::Any, QObject *parent = nullptr); - - QString name() const; - - ValueType valueType() const; - void setValueType(ValueType type); - - void setValue(const QVariant &value); - QVariant value() const; - - void setConnection(TaskConnection *connection); - TaskConnection *connection() const; - bool hasConnection() const; - - bool isValueTypeCompatible(const QVariant &value) const; - bool isConnectionTypeCompatible(const TaskPort *sourcePort) const; - -signals: - void valueChanged(); - void connectionChanged(); - -private: - QString m_name; - ValueType m_valueType; - QVariant m_value; - TaskConnection *m_connection = nullptr; -}; - -} // namespace QodeAssist::TaskFlow - -Q_DECLARE_METATYPE(QodeAssist::TaskFlow::TaskPort *) -Q_DECLARE_METATYPE(QodeAssist::TaskFlow::TaskPort::ValueType) diff --git a/TaskFlow/core/TaskRegistry.cpp b/TaskFlow/core/TaskRegistry.cpp deleted file mode 100644 index 70be3c8..0000000 --- a/TaskFlow/core/TaskRegistry.cpp +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "TaskRegistry.hpp" - -#include - -#include "BaseTask.hpp" - -namespace QodeAssist::TaskFlow { - -TaskRegistry::TaskRegistry(QObject *parent) - : QObject(parent) -{} - -BaseTask *TaskRegistry::createTask(const QString &taskType, QObject *parent) const -{ - LOG_MESSAGE(QString("Trying to create task: %1").arg(taskType)); - - if (m_creators.contains(taskType)) { - LOG_MESSAGE(QString("Found creator for task type: %1").arg(taskType)); - try { - BaseTask *task = m_creators[taskType](parent); - if (task) { - LOG_MESSAGE(QString("Successfully created task: %1").arg(taskType)); - return task; - } - } catch (...) { - LOG_MESSAGE(QString("Exception while creating task of type: %1").arg(taskType)); - } - } else { - LOG_MESSAGE(QString("No creator found for task type: %1").arg(taskType)); - } - - return nullptr; -} - -QStringList TaskRegistry::getAvailableTypes() const -{ - return m_creators.keys(); -} - -} // namespace QodeAssist::TaskFlow diff --git a/TaskFlow/core/TaskRegistry.hpp b/TaskFlow/core/TaskRegistry.hpp deleted file mode 100644 index eec0df0..0000000 --- a/TaskFlow/core/TaskRegistry.hpp +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include -#include - -namespace QodeAssist::TaskFlow { - -class BaseTask; - -class TaskRegistry : public QObject -{ - Q_OBJECT -public: - using TaskCreator = std::function; - - explicit TaskRegistry(QObject *parent = nullptr); - - template - inline void registerTask(const QString &taskType) - { - m_creators[taskType] = [](QObject *parent) -> BaseTask * { return new T(parent); }; - } - BaseTask *createTask(const QString &taskType, QObject *parent = nullptr) const; - QStringList getAvailableTypes() const; - -private: - QHash m_creators; -}; - -} // namespace QodeAssist::TaskFlow diff --git a/UIControls/qml/QoAButton.qml b/UIControls/qml/QoAButton.qml index 63da82c..5257a20 100644 --- a/UIControls/qml/QoAButton.qml +++ b/UIControls/qml/QoAButton.qml @@ -13,6 +13,8 @@ Button { focusPolicy: Qt.NoFocus padding: 4 + opacity: control.enabled ? 1.0 : 0.4 + icon.width: 16 icon.height: 16 diff --git a/UpdateStatusWidget.cpp b/UpdateStatusWidget.cpp index a4368d2..adcc32d 100644 --- a/UpdateStatusWidget.cpp +++ b/UpdateStatusWidget.cpp @@ -49,12 +49,6 @@ void UpdateStatusWidget::showUpdateAvailable(const QString &version) m_updateButton->setToolTip(tr("Check update information")); } -void UpdateStatusWidget::hideUpdateInfo() -{ - m_versionLabel->setVisible(false); - m_updateButton->setVisible(false); -} - void UpdateStatusWidget::setChatButtonAction(QAction *action) { m_chatButton->setDefaultAction(action); diff --git a/UpdateStatusWidget.hpp b/UpdateStatusWidget.hpp index e31d19d..f9f3aa1 100644 --- a/UpdateStatusWidget.hpp +++ b/UpdateStatusWidget.hpp @@ -24,7 +24,6 @@ public: void setDefaultAction(QAction *action); void showUpdateAvailable(const QString &version); - void hideUpdateInfo(); void setChatButtonAction(QAction *action); void setChatButtonMenu(QMenu *menu); diff --git a/context/CMakeLists.txt b/context/CMakeLists.txt index 0ef3d9f..4573c95 100644 --- a/context/CMakeLists.txt +++ b/context/CMakeLists.txt @@ -1,7 +1,10 @@ add_library(Context STATIC DocumentContextReader.hpp DocumentContextReader.cpp + EnvBlockFormatter.hpp EnvBlockFormatter.cpp ChangesManager.h ChangesManager.cpp ContextManager.hpp ContextManager.cpp + IProjectScanner.hpp + ProjectScannerQtCreator.hpp ProjectScannerQtCreator.cpp ContentFile.hpp DocumentReaderQtCreator.hpp IDocumentReader.hpp @@ -21,7 +24,7 @@ target_link_libraries(Context QtCreator::Utils QtCreator::ProjectExplorer PRIVATE - PluginLLMCore + Common QodeAssistSettings ) diff --git a/context/ChangesManager.cpp b/context/ChangesManager.cpp index a74396e..8328dc9 100644 --- a/context/ChangesManager.cpp +++ b/context/ChangesManager.cpp @@ -282,175 +282,6 @@ ChangesManager::FileEdit ChangesManager::getFileEdit(const QString &editId) cons return m_fileEdits.value(editId); } -QList ChangesManager::getPendingEdits() const -{ - QMutexLocker locker(&m_mutex); - - QList pendingEdits; - for (const auto &edit : m_fileEdits.values()) { - if (edit.status == Pending) { - pendingEdits.append(edit); - } - } - return pendingEdits; -} - -bool ChangesManager::performFileEdit( - const QString &filePath, const QString &oldContent, const QString &newContent, QString *errorMsg) -{ - auto setError = [errorMsg](const QString &msg) { - if (errorMsg) *errorMsg = msg; - }; - - auto editors = Core::EditorManager::visibleEditors(); - for (auto *editor : editors) { - if (!editor || !editor->document()) { - continue; - } - - QString editorPath = editor->document()->filePath().toFSPathString(); - if (editorPath == filePath) { - QByteArray contentBytes = editor->document()->contents(); - QString currentContent = QString::fromUtf8(contentBytes); - - if (oldContent.isEmpty()) { - if (auto *textEditor - = qobject_cast(editor->document())) { - QTextDocument *doc = textEditor->document(); - - QTextCursor cursor(doc); - cursor.beginEditBlock(); - cursor.movePosition(QTextCursor::End); - cursor.insertText(newContent); - cursor.endEditBlock(); - - LOG_MESSAGE(QString("Appended to open editor: %1").arg(filePath)); - setError("Applied successfully (appended to end of file)"); - return true; - } - } - - int matchPos = currentContent.indexOf(oldContent); - if (matchPos != -1) { - if (auto *textEditor - = qobject_cast(editor->document())) { - QTextDocument *doc = textEditor->document(); - - QTextCursor cursor(doc); - cursor.beginEditBlock(); - cursor.setPosition(matchPos); - cursor.setPosition(matchPos + oldContent.length(), QTextCursor::KeepAnchor); - cursor.removeSelectedText(); - cursor.insertText(newContent); - cursor.endEditBlock(); - - LOG_MESSAGE(QString("Updated open editor (exact match): %1").arg(filePath)); - setError("Applied successfully (exact match)"); - return true; - } - } else { - double similarity = 0.0; - QString matchedContent = findBestMatch(currentContent, oldContent, 0.82, &similarity); - if (!matchedContent.isEmpty()) { - matchPos = currentContent.indexOf(matchedContent); - if (matchPos != -1) { - if (auto *textEditor - = qobject_cast(editor->document())) { - QTextDocument *doc = textEditor->document(); - - QTextCursor cursor(doc); - cursor.beginEditBlock(); - cursor.setPosition(matchPos); - cursor.setPosition(matchPos + matchedContent.length(), QTextCursor::KeepAnchor); - cursor.removeSelectedText(); - cursor.insertText(newContent); - cursor.endEditBlock(); - - LOG_MESSAGE(QString("Updated open editor (fuzzy match %1%%): %2") - .arg(qRound(similarity * 100)).arg(filePath)); - setError(QString("Applied with fuzzy match (%1%% similarity)").arg(qRound(similarity * 100))); - return true; - } - } - } - - LOG_MESSAGE(QString("Old content not found in open editor (best similarity: %1%%): %2") - .arg(qRound(similarity * 100)).arg(filePath)); - setError(QString("Content not found. Best match: %1%% (threshold: 82%%). " - "File may have changed.").arg(qRound(similarity * 100))); - return false; - } - } - } - - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - QString msg = QString("Cannot open file: %1").arg(file.errorString()); - LOG_MESSAGE(QString("Failed to open file for reading: %1 - %2").arg(filePath, file.errorString())); - setError(msg); - return false; - } - - QString currentContent = QString::fromUtf8(file.readAll()); - file.close(); - - QString updatedContent; - - if (oldContent.isEmpty()) { - updatedContent = currentContent + newContent; - LOG_MESSAGE(QString("Appending to file: %1").arg(filePath)); - setError("Applied successfully (appended to end of file)"); - } - else if (currentContent.contains(oldContent)) { - int matchPos = currentContent.indexOf(oldContent); - updatedContent = currentContent.left(matchPos) - + newContent - + currentContent.mid(matchPos + oldContent.length()); - LOG_MESSAGE(QString("Using exact match for file update: %1 at position %2") - .arg(filePath).arg(matchPos)); - setError("Applied successfully (exact match)"); - } else { - double similarity = 0.0; - QString matchedContent = findBestMatch(currentContent, oldContent, 0.82, &similarity); - if (!matchedContent.isEmpty()) { - int matchPos = currentContent.indexOf(matchedContent); - if (matchPos == -1) { - QString msg = "Internal error: matched content not found in file"; - LOG_MESSAGE(QString("Internal error: matched content disappeared: %1").arg(filePath)); - setError(msg); - return false; - } - updatedContent = currentContent.left(matchPos) - + newContent - + currentContent.mid(matchPos + matchedContent.length()); - LOG_MESSAGE(QString("Using fuzzy match (%1%%) for file update: %2 at position %3") - .arg(qRound(similarity * 100)).arg(filePath).arg(matchPos)); - setError(QString("Applied with fuzzy match (%1%% similarity)").arg(qRound(similarity * 100))); - } else { - QString msg = QString("Content not found. Best match: %1%% (threshold: 82%%). " - "File may have changed.").arg(qRound(similarity * 100)); - LOG_MESSAGE(QString("Old content not found in file (best similarity: %1%%): %2") - .arg(qRound(similarity * 100)).arg(filePath)); - setError(msg); - return false; - } - } - - if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { - QString msg = QString("Cannot write file: %1").arg(file.errorString()); - LOG_MESSAGE(QString("Failed to open file for writing: %1 - %2").arg(filePath, file.errorString())); - setError(msg); - return false; - } - - QTextStream out(&file); - out << updatedContent; - file.close(); - - LOG_MESSAGE(QString("File updated: %1").arg(filePath)); - return true; -} - int ChangesManager::levenshteinDistance(const QString &s1, const QString &s2) const { const int len1 = s1.length(); @@ -1112,138 +943,6 @@ QString ChangesManager::readFileContent(const QString &filePath) const return content; } -bool ChangesManager::performFileEditWithDiff( - const QString &filePath, - const DiffInfo &diffInfo, - bool reverse, - QString *errorMsg) -{ - LOG_MESSAGE(QString("=== performFileEditWithDiff: %1 (reverse: %2) ===") - .arg(filePath).arg(reverse ? "yes" : "no")); - - auto setError = [errorMsg](const QString &msg) { - if (errorMsg) *errorMsg = msg; - }; - - auto editors = Core::EditorManager::visibleEditors(); - LOG_MESSAGE(QString(" Checking %1 visible editor(s)").arg(editors.size())); - - for (auto *editor : editors) { - if (!editor || !editor->document()) { - continue; - } - - QString editorPath = editor->document()->filePath().toFSPathString(); - if (editorPath == filePath) { - LOG_MESSAGE(QString(" Found open editor for: %1").arg(filePath)); - - QByteArray contentBytes = editor->document()->contents(); - QString currentContent = QString::fromUtf8(contentBytes); - - LOG_MESSAGE(QString(" Current content size: %1 bytes").arg(currentContent.size())); - - QString modifiedContent = currentContent; - QString diffErrorMsg; - bool diffSuccess = applyDiffToContent(modifiedContent, diffInfo, reverse, &diffErrorMsg); - - if (!diffSuccess) { - LOG_MESSAGE(QString(" Failed to apply diff: %1").arg(diffErrorMsg)); - setError(diffErrorMsg); - - LOG_MESSAGE(" Attempting fallback to old content-based method..."); - QString oldContent = reverse ? diffInfo.modifiedContent : diffInfo.originalContent; - QString newContent = reverse ? diffInfo.originalContent : diffInfo.modifiedContent; - - return performFileEdit(filePath, oldContent, newContent, errorMsg); - } - - if (auto *textEditor = qobject_cast(editor->document())) { - QTextDocument *doc = textEditor->document(); - - LOG_MESSAGE(" Applying changes to text editor document..."); - - if (!doc) { - LOG_MESSAGE(" Document is invalid"); - setError("Document pointer is null"); - return false; - } - - try { - QTextCursor cursor(doc); - - if (cursor.isNull()) { - LOG_MESSAGE(" Cursor is invalid"); - setError("Cannot create text cursor"); - return false; - } - - cursor.beginEditBlock(); - cursor.select(QTextCursor::Document); - cursor.removeSelectedText(); - cursor.insertText(modifiedContent); - cursor.endEditBlock(); - - LOG_MESSAGE(QString(" ✓ Successfully applied diff to open editor: %1").arg(filePath)); - setError(diffErrorMsg); - return true; - } catch (...) { - LOG_MESSAGE(" Exception during document modification"); - setError("Exception during document modification"); - return false; - } - } - } - } - - LOG_MESSAGE(" File not open in editor, modifying file directly..."); - LOG_MESSAGE(" Note: Undo (Ctrl+Z) will not be available for this file until it is opened"); - - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - QString msg = QString("Cannot open file: %1").arg(file.errorString()); - LOG_MESSAGE(QString(" Failed to open file for reading: %1 - %2") - .arg(filePath, file.errorString())); - setError(msg); - return false; - } - - QString currentContent = QString::fromUtf8(file.readAll()); - file.close(); - - LOG_MESSAGE(QString(" File read successfully (%1 bytes)").arg(currentContent.size())); - - QString modifiedContent = currentContent; - QString diffErrorMsg; - bool diffSuccess = applyDiffToContent(modifiedContent, diffInfo, reverse, &diffErrorMsg); - - if (!diffSuccess) { - LOG_MESSAGE(QString(" Failed to apply diff to file: %1").arg(diffErrorMsg)); - setError(diffErrorMsg); - - LOG_MESSAGE(" Attempting fallback to old content-based method..."); - QString oldContent = reverse ? diffInfo.modifiedContent : diffInfo.originalContent; - QString newContent = reverse ? diffInfo.originalContent : diffInfo.modifiedContent; - - return performFileEdit(filePath, oldContent, newContent, errorMsg); - } - - if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { - QString msg = QString("Cannot write file: %1").arg(file.errorString()); - LOG_MESSAGE(QString(" Failed to open file for writing: %1 - %2") - .arg(filePath, file.errorString())); - setError(msg); - return false; - } - - QTextStream out(&file); - out << modifiedContent; - file.close(); - - LOG_MESSAGE(QString(" ✓ Successfully wrote modified content to file: %1").arg(filePath)); - setError(diffErrorMsg); - return true; -} - ChangesManager::DiffInfo ChangesManager::createDiffInfo( const QString &originalContent, const QString &modifiedContent, @@ -1390,263 +1089,4 @@ ChangesManager::DiffInfo ChangesManager::createDiffInfo( return diffInfo; } -bool ChangesManager::findHunkLocation( - const QStringList &fileLines, - const DiffHunk &hunk, - int &actualStartLine, - QString *debugInfo) const -{ - LOG_MESSAGE(QString(" Searching for hunk location (expected line: %1)").arg(hunk.oldStartLine)); - - QString debug; - - int expectedIdx = hunk.oldStartLine - 1; - - if (expectedIdx >= 0 && expectedIdx < fileLines.size()) { - bool exactMatch = true; - - int checkIdx = expectedIdx - hunk.contextBefore.size(); - if (checkIdx < 0) { - exactMatch = false; - debug += QString(" Context before out of bounds (need %1 lines before line %2)\n") - .arg(hunk.contextBefore.size()).arg(expectedIdx + 1); - } else { - for (int i = 0; i < hunk.contextBefore.size(); ++i) { - if (fileLines[checkIdx + i] != hunk.contextBefore[i]) { - exactMatch = false; - debug += QString(" Context before mismatch at offset %1: expected '%2', got '%3'\n") - .arg(i).arg(hunk.contextBefore[i]).arg(fileLines[checkIdx + i]); - break; - } - } - } - - if (exactMatch) { - for (int i = 0; i < hunk.removedLines.size(); ++i) { - int lineIdx = expectedIdx + i; - if (lineIdx >= fileLines.size() || fileLines[lineIdx] != hunk.removedLines[i]) { - exactMatch = false; - debug += QString(" Removed line mismatch at offset %1: expected '%2', got '%3'\n") - .arg(i) - .arg(hunk.removedLines[i]) - .arg(lineIdx < fileLines.size() ? fileLines[lineIdx] : ""); - break; - } - } - } - - if (exactMatch && !hunk.contextAfter.isEmpty()) { - int afterIdx = expectedIdx + hunk.removedLines.size(); - for (int i = 0; i < hunk.contextAfter.size(); ++i) { - int lineIdx = afterIdx + i; - if (lineIdx >= fileLines.size() || fileLines[lineIdx] != hunk.contextAfter[i]) { - exactMatch = false; - debug += QString(" Context after mismatch at offset %1: expected '%2', got '%3'\n") - .arg(i) - .arg(hunk.contextAfter[i]) - .arg(lineIdx < fileLines.size() ? fileLines[lineIdx] : ""); - break; - } - } - } - - if (exactMatch) { - actualStartLine = expectedIdx; - LOG_MESSAGE(QString(" ✓ Found exact match at expected line %1").arg(hunk.oldStartLine)); - if (debugInfo) *debugInfo = "Exact match at expected location"; - return true; - } else { - debug += " Exact match at expected location failed, trying fuzzy search...\n"; - } - } else { - debug += QString(" Expected location %1 is out of bounds (file has %2 lines)\n") - .arg(hunk.oldStartLine).arg(fileLines.size()); - } - - LOG_MESSAGE(" Trying fuzzy search within ±20 lines..."); - - int searchStart = qMax(0, expectedIdx - 20); - int searchEnd = qMin(fileLines.size(), expectedIdx + 20); - - int bestMatchLine = -1; - int bestMatchScore = 0; - - for (int searchIdx = searchStart; searchIdx < searchEnd; ++searchIdx) { - int matchScore = 0; - int totalChecks = 0; - - int checkIdx = searchIdx - hunk.contextBefore.size(); - if (checkIdx >= 0) { - for (int i = 0; i < hunk.contextBefore.size(); ++i) { - totalChecks++; - if (fileLines[checkIdx + i] == hunk.contextBefore[i]) { - matchScore++; - } - } - } - - for (int i = 0; i < hunk.removedLines.size(); ++i) { - int lineIdx = searchIdx + i; - if (lineIdx < fileLines.size()) { - totalChecks++; - if (fileLines[lineIdx] == hunk.removedLines[i]) { - matchScore++; - } - } - } - - int afterIdx = searchIdx + hunk.removedLines.size(); - for (int i = 0; i < hunk.contextAfter.size(); ++i) { - int lineIdx = afterIdx + i; - if (lineIdx < fileLines.size()) { - totalChecks++; - if (fileLines[lineIdx] == hunk.contextAfter[i]) { - matchScore++; - } - } - } - - if (matchScore > bestMatchScore) { - bestMatchScore = matchScore; - bestMatchLine = searchIdx; - } - } - - int totalPossibleScore = hunk.contextBefore.size() + hunk.removedLines.size() + hunk.contextAfter.size(); - double matchPercentage = totalPossibleScore > 0 ? (double)bestMatchScore / totalPossibleScore * 100.0 : 0.0; - - if (bestMatchLine != -1 && matchPercentage >= 70.0) { - actualStartLine = bestMatchLine; - debug += QString(" ✓ Found fuzzy match at line %1 (score: %2/%3 = %4%%)\n") - .arg(bestMatchLine + 1) - .arg(bestMatchScore) - .arg(totalPossibleScore) - .arg(matchPercentage, 0, 'f', 1); - LOG_MESSAGE(QString(" ✓ Found fuzzy match at line %1 (%2%% confidence)") - .arg(bestMatchLine + 1).arg(matchPercentage, 0, 'f', 1)); - if (debugInfo) *debugInfo = debug; - return true; - } - - debug += QString(" ✗ No suitable match found (best: %1%% at line %2)\n") - .arg(matchPercentage, 0, 'f', 1) - .arg(bestMatchLine != -1 ? bestMatchLine + 1 : -1); - LOG_MESSAGE(QString(" ✗ Hunk location not found (best match: %1%%)").arg(matchPercentage, 0, 'f', 1)); - - if (debugInfo) *debugInfo = debug; - return false; -} - -bool ChangesManager::applyDiffToContent( - QString &content, - const DiffInfo &diffInfo, - bool reverse, - QString *errorMsg) -{ - LOG_MESSAGE(QString("=== Applying %1 to content ===").arg(reverse ? "REVERSE diff" : "diff")); - - auto setError = [errorMsg](const QString &msg) { - if (errorMsg) *errorMsg = msg; - }; - - if (diffInfo.useFallback) { - LOG_MESSAGE(" Using fallback mode (direct content replacement)"); - - QString searchContent = reverse ? diffInfo.modifiedContent : diffInfo.originalContent; - QString replaceContent = reverse ? diffInfo.originalContent : diffInfo.modifiedContent; - - int matchPos = content.indexOf(searchContent); - if (matchPos != -1) { - content = content.left(matchPos) - + replaceContent - + content.mid(matchPos + searchContent.length()); - setError("Applied using fallback mode (direct replacement)"); - LOG_MESSAGE(QString(" ✓ Fallback: Direct replacement successful at position %1").arg(matchPos)); - return true; - } else { - setError("Fallback failed: Original content not found in file"); - LOG_MESSAGE(" ✗ Fallback: Content not found"); - return false; - } - } - - if (diffInfo.hunks.isEmpty()) { - LOG_MESSAGE(" No hunks to apply (content unchanged)"); - setError("No changes to apply"); - return true; - } - - QStringList fileLines = content.split('\n'); - LOG_MESSAGE(QString(" File has %1 lines, applying %2 hunk(s)") - .arg(fileLines.size()).arg(diffInfo.hunks.size())); - - QList hunksToApply = diffInfo.hunks; - - std::sort(hunksToApply.begin(), hunksToApply.end(), - [](const DiffHunk &a, const DiffHunk &b) { - return a.oldStartLine > b.oldStartLine; - }); - - LOG_MESSAGE(" Hunks sorted in descending order for application"); - - int appliedHunks = 0; - int failedHunks = 0; - - for (int hunkIdx = 0; hunkIdx < hunksToApply.size(); ++hunkIdx) { - const DiffHunk &hunk = hunksToApply[hunkIdx]; - - LOG_MESSAGE(QString(" --- Applying hunk %1/%2 ---") - .arg(hunkIdx + 1).arg(hunksToApply.size())); - - int actualStartLine = -1; - QString debugInfo; - - if (!findHunkLocation(fileLines, hunk, actualStartLine, &debugInfo)) { - LOG_MESSAGE(QString(" ✗ Failed to locate hunk %1:\n%2") - .arg(hunkIdx + 1).arg(debugInfo)); - failedHunks++; - continue; - } - - LOG_MESSAGE(QString(" Applying hunk at line %1 (remove %2 lines, add %3 lines)") - .arg(actualStartLine + 1) - .arg(hunk.removedLines.size()) - .arg(hunk.addedLines.size())); - - for (int i = 0; i < hunk.removedLines.size(); ++i) { - if (actualStartLine < fileLines.size()) { - LOG_MESSAGE(QString(" Removing line %1: '%2'") - .arg(actualStartLine + 1) - .arg(fileLines[actualStartLine])); - fileLines.removeAt(actualStartLine); - } - } - - for (int i = 0; i < hunk.addedLines.size(); ++i) { - LOG_MESSAGE(QString(" Inserting line %1: '%2'") - .arg(actualStartLine + i + 1) - .arg(hunk.addedLines[i])); - fileLines.insert(actualStartLine + i, hunk.addedLines[i]); - } - - appliedHunks++; - LOG_MESSAGE(QString(" ✓ Hunk %1 applied successfully").arg(hunkIdx + 1)); - } - - if (failedHunks > 0) { - QString msg = QString("Partially applied: %1 of %2 hunks succeeded") - .arg(appliedHunks).arg(hunksToApply.size()); - setError(msg); - LOG_MESSAGE(QString(" ⚠ %1").arg(msg)); - - content = fileLines.join('\n'); - return false; - } - - content = fileLines.join('\n'); - setError(QString("Successfully applied %1 hunk(s)").arg(appliedHunks)); - LOG_MESSAGE(QString("=== All %1 hunk(s) applied successfully ===").arg(appliedHunks)); - return true; -} - } // namespace QodeAssist::Context diff --git a/context/ChangesManager.h b/context/ChangesManager.h index 91bdcd5..73113b2 100644 --- a/context/ChangesManager.h +++ b/context/ChangesManager.h @@ -81,8 +81,7 @@ public: bool rejectFileEdit(const QString &editId); bool undoFileEdit(const QString &editId); FileEdit getFileEdit(const QString &editId) const; - QList getPendingEdits() const; - + bool applyPendingEditsForRequest(const QString &requestId, QString *errorMsg = nullptr); QList getEditsForRequest(const QString &requestId) const; @@ -106,13 +105,9 @@ private: ChangesManager(const ChangesManager &) = delete; ChangesManager &operator=(const ChangesManager &) = delete; - bool performFileEdit(const QString &filePath, const QString &oldContent, const QString &newContent, QString *errorMsg = nullptr); - bool performFileEditWithDiff(const QString &filePath, const DiffInfo &diffInfo, bool reverse, QString *errorMsg = nullptr); QString readFileContent(const QString &filePath) const; DiffInfo createDiffInfo(const QString &originalContent, const QString &modifiedContent, const QString &filePath); - bool applyDiffToContent(QString &content, const DiffInfo &diffInfo, bool reverse, QString *errorMsg = nullptr); - bool findHunkLocation(const QStringList &fileLines, const DiffHunk &hunk, int &actualStartLine, QString *debugInfo = nullptr) const; // Helper method for fragment-based apply/undo operations bool performFragmentReplacement( diff --git a/context/ContextManager.cpp b/context/ContextManager.cpp index 8ac9714..6c160b0 100644 --- a/context/ContextManager.cpp +++ b/context/ContextManager.cpp @@ -6,25 +6,24 @@ #include #include -#include #include -#include "settings/GeneralSettings.hpp" -#include -#include -#include -#include -#include - #include "Logger.hpp" +#include "ProjectScannerQtCreator.hpp" namespace QodeAssist::Context { ContextManager::ContextManager(QObject *parent) - : QObject(parent) - , m_ignoreManager(new IgnoreManager(this)) + : ContextManager(std::make_unique(), parent) {} +ContextManager::ContextManager(std::unique_ptr scanner, QObject *parent) + : QObject(parent) + , m_scanner(std::move(scanner)) +{} + +ContextManager::~ContextManager() = default; + QString ContextManager::readFile(const QString &filePath) const { QFile file(filePath); @@ -37,7 +36,7 @@ QString ContextManager::readFile(const QString &filePath) const QTextStream in(&file); QString content = in.readAll(); file.close(); - + return content; } @@ -45,9 +44,7 @@ QList ContextManager::getContentFiles(const QStringList &filePaths) { QList files; for (const QString &path : filePaths) { - auto project = ProjectExplorer::ProjectManager::projectForFile( - Utils::FilePath::fromString(path)); - if (project && m_ignoreManager->shouldIgnore(path, project)) { + if (m_scanner->shouldIgnore(path)) { LOG_MESSAGE(QString("Ignoring file in context due to .qodeassistignore: %1").arg(path)); continue; } @@ -58,27 +55,6 @@ QList ContextManager::getContentFiles(const QStringList &filePaths) return files; } -QStringList ContextManager::getProjectSourceFiles(ProjectExplorer::Project *project) const -{ - QStringList sourceFiles; - if (!project) - return sourceFiles; - - auto projectNode = project->rootProjectNode(); - if (!projectNode) - return sourceFiles; - - projectNode->forEachNode( - [&sourceFiles, this](ProjectExplorer::FileNode *fileNode) { - if (fileNode /*&& shouldProcessFile(fileNode->filePath().toString())*/) { - sourceFiles.append(fileNode->filePath().toUrlishString()); - } - }, - nullptr); - - return sourceFiles; -} - ContentFile ContextManager::createContentFile(const QString &filePath) const { ContentFile contentFile; @@ -100,77 +76,26 @@ ProgrammingLanguage ContextManager::getDocumentLanguage(const DocumentInfo &docu bool ContextManager::isSpecifyCompletion(const DocumentInfo &documentInfo) const { - const auto &generalSettings = Settings::generalSettings(); - - Context::ProgrammingLanguage documentLanguage = getDocumentLanguage(documentInfo); - Context::ProgrammingLanguage preset1Language = Context::ProgrammingLanguageUtils::fromString( - generalSettings.preset1Language.displayForIndex(generalSettings.preset1Language())); - - return generalSettings.specifyPreset1() && documentLanguage == preset1Language; + Q_UNUSED(documentInfo) + return false; } -QList> ContextManager::openedFiles(const QStringList excludeFiles) const -{ - auto documents = Core::DocumentModel::openedDocuments(); - - QList> files; - - for (const auto *document : std::as_const(documents)) { - auto textDocument = qobject_cast(document); - if (!textDocument) - continue; - - auto filePath = textDocument->filePath().toUrlishString(); - - auto project = ProjectExplorer::ProjectManager::projectForFile(textDocument->filePath()); - if (project && m_ignoreManager->shouldIgnore(filePath, project)) { - LOG_MESSAGE( - QString("Ignoring file in context due to .qodeassistignore: %1").arg(filePath)); - continue; - } - - if (!excludeFiles.contains(filePath)) { - files.append({filePath, textDocument->plainText()}); - } - } - - return files; -} - -QString ContextManager::openedFilesContext(const QStringList excludeFiles) +QString ContextManager::openedFilesContext(const QStringList &excludeFiles) const { QString context = "User files context:\n"; - auto documents = Core::DocumentModel::openedDocuments(); - - for (const auto *document : documents) { - auto textDocument = qobject_cast(document); - if (!textDocument) - continue; - - auto filePath = textDocument->filePath().toUrlishString(); - if (excludeFiles.contains(filePath)) - continue; - - auto project = ProjectExplorer::ProjectManager::projectForFile(textDocument->filePath()); - if (project && m_ignoreManager->shouldIgnore(filePath, project)) { - LOG_MESSAGE( - QString("Ignoring file in context due to .qodeassistignore: %1").arg(filePath)); - continue; - } - - context += QString("File: %1\n").arg(filePath); - context += textDocument->plainText(); - + for (const auto &file : m_scanner->openedTextFiles(excludeFiles)) { + context += QString("File: %1\n").arg(file.filePath); + context += file.content; context += "\n"; } return context; } -IgnoreManager *ContextManager::ignoreManager() const +bool ContextManager::shouldIgnore(const QString &filePath) const { - return m_ignoreManager; + return m_scanner->shouldIgnore(filePath); } } // namespace QodeAssist::Context diff --git a/context/ContextManager.hpp b/context/ContextManager.hpp index 1213afe..a3a5533 100644 --- a/context/ContextManager.hpp +++ b/context/ContextManager.hpp @@ -4,18 +4,16 @@ #pragma once +#include + #include #include #include "ContentFile.hpp" #include "IContextManager.hpp" -#include "IgnoreManager.hpp" +#include "IProjectScanner.hpp" #include "ProgrammingLanguage.hpp" -namespace ProjectExplorer { -class Project; -} - namespace QodeAssist::Context { class ContextManager : public QObject, public IContextManager @@ -24,22 +22,22 @@ class ContextManager : public QObject, public IContextManager public: explicit ContextManager(QObject *parent = nullptr); - ~ContextManager() override = default; + ContextManager(std::unique_ptr scanner, QObject *parent = nullptr); + ~ContextManager() override; QString readFile(const QString &filePath) const override; QList getContentFiles(const QStringList &filePaths) const override; - QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const override; ContentFile createContentFile(const QString &filePath) const override; ProgrammingLanguage getDocumentLanguage(const DocumentInfo &documentInfo) const override; bool isSpecifyCompletion(const DocumentInfo &documentInfo) const override; - QList> openedFiles(const QStringList excludeFiles = QStringList{}) const; - QString openedFilesContext(const QStringList excludeFiles = QStringList{}); - IgnoreManager *ignoreManager() const; + QString openedFilesContext(const QStringList &excludeFiles = QStringList{}) const; + + bool shouldIgnore(const QString &filePath) const; private: - IgnoreManager *m_ignoreManager; + std::unique_ptr m_scanner; }; } // namespace QodeAssist::Context diff --git a/context/DocumentContextReader.cpp b/context/DocumentContextReader.cpp index cc4db07..d147d0b 100644 --- a/context/DocumentContextReader.cpp +++ b/context/DocumentContextReader.cpp @@ -4,13 +4,12 @@ #include "DocumentContextReader.hpp" -#include -#include #include #include "CodeCompletionSettings.hpp" #include "ChangesManager.h" +#include "EnvBlockFormatter.hpp" const QRegularExpression &getYearRegex() { @@ -108,15 +107,6 @@ QString DocumentContextReader::readWholeFileAfter(int lineNumber, int cursorPosi return getContextBetween(lineNumber, cursorPosition, endLine, -1); } -QString DocumentContextReader::getLanguageAndFileInfo() const -{ - QString language = LanguageServerProtocol::TextDocumentItem::mimeTypeToLanguageId(m_mimeType); - QString fileExtension = QFileInfo(m_filePath).suffix(); - - return QString("Language: %1 (MIME: %2) filepath: %3(%4)\n\n") - .arg(language, m_mimeType, m_filePath, fileExtension); -} - CopyrightInfo DocumentContextReader::findCopyright() { CopyrightInfo result = {-1, -1, false}; @@ -249,12 +239,7 @@ QString DocumentContextReader::getContextBetween( return context; } -CopyrightInfo DocumentContextReader::copyrightInfo() const -{ - return m_copyrightInfo; -} - -PluginLLMCore::ContextData DocumentContextReader::prepareContext( +Templates::ContextData DocumentContextReader::prepareContext( int lineNumber, int cursorPosition, const Settings::CodeCompletionSettings &settings) const { QString contextBefore; @@ -272,7 +257,9 @@ PluginLLMCore::ContextData DocumentContextReader::prepareContext( } QString fileContext; - fileContext.append("\n ").append(getLanguageAndFileInfo()); + fileContext.append("\n") + .append(EnvBlockFormatter::formatFile({m_filePath, m_mimeType})) + .append("\n"); if (settings.useProjectChangesCache()) fileContext.append("Recent Project Changes Context:\n ") diff --git a/context/DocumentContextReader.hpp b/context/DocumentContextReader.hpp index 8361eef..6b04afb 100644 --- a/context/DocumentContextReader.hpp +++ b/context/DocumentContextReader.hpp @@ -7,7 +7,7 @@ #include #include -#include +#include #include namespace QodeAssist::Context { @@ -51,14 +51,11 @@ public: */ QString readWholeFileAfter(int lineNumber, int cursorPosition) const; - QString getLanguageAndFileInfo() const; CopyrightInfo findCopyright(); QString getContextBetween( int startLine, int startCursorPosition, int endLine, int endCursorPosition) const; - CopyrightInfo copyrightInfo() const; - - PluginLLMCore::ContextData prepareContext( + Templates::ContextData prepareContext( int lineNumber, int cursorPosition, const Settings::CodeCompletionSettings &settings) const; private: diff --git a/context/EnvBlockFormatter.cpp b/context/EnvBlockFormatter.cpp new file mode 100644 index 0000000..50c0c2f --- /dev/null +++ b/context/EnvBlockFormatter.cpp @@ -0,0 +1,66 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "EnvBlockFormatter.hpp" + +#include +#include +#include +#include +#include + +namespace QodeAssist::Context::EnvBlockFormatter { + +ProjectEnv currentProject() +{ + ProjectEnv env; + auto *project = ProjectExplorer::ProjectManager::startupProject(); + if (!project) + return env; + + env.name = project->displayName(); + env.sourceRoot = project->projectDirectory().toUrlishString(); + if (auto *target = project->activeTarget()) { + if (auto *buildConfig = target->activeBuildConfiguration()) + env.buildDir = buildConfig->buildDirectory().toUrlishString(); + } + return env; +} + +QString formatProject(const ProjectEnv &env) +{ + if (env.name.isEmpty() && env.sourceRoot.isEmpty()) + return QStringLiteral("# No active project in IDE"); + + QString out = QStringLiteral("# Active project: %1").arg(env.name); + out += QStringLiteral( + "\n# Project source root: %1" + "\n# All new source files, headers, QML and CMake edits MUST be " + "created or modified under this directory. Use absolute paths " + "rooted here, or project-relative paths.") + .arg(env.sourceRoot); + if (!env.buildDir.isEmpty()) { + out += QStringLiteral( + "\n# Build output directory (compiler artifacts only — do NOT " + "create or edit source files here): %1") + .arg(env.buildDir); + } + return out; +} + +QString formatFile(const FileEnv &env) +{ + const QString language + = LanguageServerProtocol::TextDocumentItem::mimeTypeToLanguageId(env.mimeType); + + QString out = QStringLiteral("File information:"); + if (!language.isEmpty()) + out += QStringLiteral("\nLanguage: %1 (MIME: %2)").arg(language, env.mimeType); + else if (!env.mimeType.isEmpty()) + out += QStringLiteral("\nMIME type: %1").arg(env.mimeType); + out += QStringLiteral("\nFile path: %1\n").arg(env.filePath); + return out; +} + +} // namespace QodeAssist::Context::EnvBlockFormatter diff --git a/context/EnvBlockFormatter.hpp b/context/EnvBlockFormatter.hpp new file mode 100644 index 0000000..3382f48 --- /dev/null +++ b/context/EnvBlockFormatter.hpp @@ -0,0 +1,29 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +namespace QodeAssist::Context::EnvBlockFormatter { + +struct ProjectEnv +{ + QString name; + QString sourceRoot; + QString buildDir; +}; + +struct FileEnv +{ + QString filePath; + QString mimeType; +}; + +ProjectEnv currentProject(); + +QString formatProject(const ProjectEnv &env); +QString formatFile(const FileEnv &env); + +} // namespace QodeAssist::Context::EnvBlockFormatter diff --git a/context/IContextManager.hpp b/context/IContextManager.hpp index 2f0a592..4ecebed 100644 --- a/context/IContextManager.hpp +++ b/context/IContextManager.hpp @@ -11,10 +11,6 @@ #include "IDocumentReader.hpp" #include "ProgrammingLanguage.hpp" -namespace ProjectExplorer { -class Project; -} - namespace QodeAssist::Context { class IContextManager @@ -24,7 +20,6 @@ public: virtual QString readFile(const QString &filePath) const = 0; virtual QList getContentFiles(const QStringList &filePaths) const = 0; - virtual QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const = 0; virtual ContentFile createContentFile(const QString &filePath) const = 0; virtual ProgrammingLanguage getDocumentLanguage(const DocumentInfo &documentInfo) const = 0; diff --git a/context/IProjectScanner.hpp b/context/IProjectScanner.hpp new file mode 100644 index 0000000..0b21078 --- /dev/null +++ b/context/IProjectScanner.hpp @@ -0,0 +1,28 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include + +namespace QodeAssist::Context { + +struct OpenedTextFile +{ + QString filePath; + QString content; +}; + +class IProjectScanner +{ +public: + virtual ~IProjectScanner() = default; + + virtual QList openedTextFiles(const QStringList &excludeFiles = {}) const = 0; + virtual bool shouldIgnore(const QString &filePath) const = 0; +}; + +} // namespace QodeAssist::Context diff --git a/context/IgnoreManager.cpp b/context/IgnoreManager.cpp index 0c87180..7f7c7fc 100644 --- a/context/IgnoreManager.cpp +++ b/context/IgnoreManager.cpp @@ -234,19 +234,6 @@ void IgnoreManager::removeIgnorePatterns(ProjectExplorer::Project *project) LOG_MESSAGE(QString("Removed ignore patterns for project: %1").arg(project->displayName())); } -void IgnoreManager::reloadAllPatterns() -{ - QList projects = m_projectIgnorePatterns.keys(); - - for (ProjectExplorer::Project *project : projects) { - if (project) { - reloadIgnorePatterns(project); - } - } - - m_ignoreCache.clear(); -} - QString IgnoreManager::ignoreFilePath(ProjectExplorer::Project *project) const { if (!project) { diff --git a/context/IgnoreManager.hpp b/context/IgnoreManager.hpp index bad2075..2cc7cd0 100644 --- a/context/IgnoreManager.hpp +++ b/context/IgnoreManager.hpp @@ -27,8 +27,6 @@ public: void reloadIgnorePatterns(ProjectExplorer::Project *project); void removeIgnorePatterns(ProjectExplorer::Project *project); - void reloadAllPatterns(); - private slots: void cleanupConnections(); diff --git a/context/ProjectScannerQtCreator.cpp b/context/ProjectScannerQtCreator.cpp new file mode 100644 index 0000000..eae22c9 --- /dev/null +++ b/context/ProjectScannerQtCreator.cpp @@ -0,0 +1,53 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "ProjectScannerQtCreator.hpp" + +#include +#include +#include +#include +#include + +#include "IgnoreManager.hpp" + +namespace QodeAssist::Context { + +ProjectScannerQtCreator::ProjectScannerQtCreator() + : m_ignoreManager(std::make_unique()) +{} + +ProjectScannerQtCreator::~ProjectScannerQtCreator() = default; + +QList ProjectScannerQtCreator::openedTextFiles( + const QStringList &excludeFiles) const +{ + QList files; + + const auto documents = Core::DocumentModel::openedDocuments(); + for (const auto *document : documents) { + const auto *textDocument = qobject_cast(document); + if (!textDocument) + continue; + + const QString filePath = textDocument->filePath().toUrlishString(); + if (excludeFiles.contains(filePath)) + continue; + if (shouldIgnore(filePath)) + continue; + + files.append({filePath, textDocument->plainText()}); + } + + return files; +} + +bool ProjectScannerQtCreator::shouldIgnore(const QString &filePath) const +{ + auto *project = ProjectExplorer::ProjectManager::projectForFile( + Utils::FilePath::fromString(filePath)); + return project && m_ignoreManager->shouldIgnore(filePath, project); +} + +} // namespace QodeAssist::Context diff --git a/context/ProjectScannerQtCreator.hpp b/context/ProjectScannerQtCreator.hpp new file mode 100644 index 0000000..66f0c6a --- /dev/null +++ b/context/ProjectScannerQtCreator.hpp @@ -0,0 +1,28 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include "IProjectScanner.hpp" + +namespace QodeAssist::Context { + +class IgnoreManager; + +class ProjectScannerQtCreator : public IProjectScanner +{ +public: + ProjectScannerQtCreator(); + ~ProjectScannerQtCreator() override; + + QList openedTextFiles(const QStringList &excludeFiles = {}) const override; + bool shouldIgnore(const QString &filePath) const override; + +private: + std::unique_ptr m_ignoreManager; +}; + +} // namespace QodeAssist::Context diff --git a/context/ProjectUtils.cpp b/context/ProjectUtils.cpp index 3576221..3c9ffc8 100644 --- a/context/ProjectUtils.cpp +++ b/context/ProjectUtils.cpp @@ -35,25 +35,6 @@ bool ProjectUtils::isFileInProject(const QString &filePath) return false; } -QString ProjectUtils::findFileInProject(const QString &filename) -{ - QList projects = ProjectExplorer::ProjectManager::projects(); - - for (auto project : projects) { - if (!project) - continue; - - Utils::FilePaths projectFiles = project->files(ProjectExplorer::Project::SourceFiles); - for (const auto &projectFile : std::as_const(projectFiles)) { - if (projectFile.fileName() == filename) { - return projectFile.toFSPathString(); - } - } - } - - return QString(); -} - QString ProjectUtils::getProjectRoot() { QList projects = ProjectExplorer::ProjectManager::projects(); diff --git a/context/ProjectUtils.hpp b/context/ProjectUtils.hpp index 50ef873..b0bfad9 100644 --- a/context/ProjectUtils.hpp +++ b/context/ProjectUtils.hpp @@ -26,17 +26,6 @@ public: */ static bool isFileInProject(const QString &filePath); - /** - * @brief Find a file in open projects by filename - * - * Searches all open projects for a file matching the given filename. - * If multiple files with the same name exist, returns the first match. - * - * @param filename File name to search for (e.g., "main.cpp") - * @return Absolute file path if found, empty string otherwise - */ - static QString findFileInProject(const QString &filename); - /** * @brief Get the project root directory * diff --git a/docs/agent-roles.md b/docs/agent-roles.md deleted file mode 100644 index c8195a5..0000000 --- a/docs/agent-roles.md +++ /dev/null @@ -1,174 +0,0 @@ -# Agent Roles - -Agent Roles allow you to define different AI personas with specialized system prompts for various tasks. Switch between roles instantly in the chat interface to adapt the AI's behavior to your current needs. - -## Overview - -Agent Roles are reusable system prompt configurations that modify how the AI assistant responds. Instead of manually changing system prompts, you can create roles like "Developer", "Code Reviewer", or "Documentation Writer" and switch between them with a single click. - -**Key Features:** -- **Quick Switching**: Change roles from the chat toolbar dropdown -- **Custom Prompts**: Each role has its own specialized system prompt -- **Built-in Roles**: Pre-configured Developer and Code Reviewer roles -- **Persistent**: Roles are saved locally and loaded on startup -- **Extensible**: Create unlimited custom roles for different tasks - -## Default Roles - -QodeAssist comes with three built-in roles: - -### Developer -Experienced Qt/C++ developer with a structured workflow: analyze the problem, propose a solution, wait for approval, then implement. Best for implementation tasks where you want thoughtful, minimal code changes. - -### Code Reviewer -Expert C++/QML code reviewer specializing in C++20 and Qt6. Checks for bugs, memory leaks, thread safety, Qt patterns, and production readiness. Provides direct, specific feedback with code examples. - -### Researcher -Research-oriented developer who investigates problems and explores solutions. Analyzes problems, presents multiple approaches with trade-offs, and recommends the best option. Does not write implementation code — focuses on helping you make informed decisions. - -## Using Agent Roles - -### Switching Roles in Chat - -1. Open the Chat Assistant (side panel, bottom panel, or popup window) -2. Locate the **Role selector** dropdown in the top toolbar (next to the configuration selector) -3. Select a role from the dropdown -4. The AI will now use the selected role's system prompt - -**Note**: Selecting "No Role" uses only the base system prompt without role specialization. - -### Viewing Active Role - -Click the **Context** button (📋) in the chat toolbar to view: -- Base system prompt -- Current agent role and its system prompt -- Active project rules - -## Managing Agent Roles - -### Opening the Role Manager - -Navigate to: `Qt Creator → Preferences → QodeAssist → Chat Assistant` - -Scroll down to the **Agent Roles** section where you can manage all your roles. - -### Creating a New Role - -1. Click **Add...** button -2. Fill in the role details: - - **Name**: Display name shown in the dropdown (e.g., "Documentation Writer") - - **ID**: Unique identifier for the role file (e.g., "doc_writer") - - **Description**: Brief explanation of the role's purpose - - **System Prompt**: The specialized instructions for this role -3. Click **OK** to save - -### Editing a Role - -1. Select a role from the list -2. Click **Edit...** or double-click the role -3. Modify the fields as needed -4. Click **OK** to save changes - -**Note**: Built-in roles cannot be edited directly. Duplicate them to create a modifiable copy. - -### Duplicating a Role - -1. Select a role to duplicate -2. Click **Duplicate...** -3. Modify the copy as needed -4. Click **OK** to save as a new role - -### Deleting a Role - -1. Select a custom role (built-in roles cannot be deleted) -2. Click **Delete** -3. Confirm deletion - -## Creating Effective Roles - -### System Prompt Tips - -- **Be specific**: Clearly define the role's expertise and focus areas -- **Set expectations**: Describe the desired response format and style -- **Include guidelines**: Add specific rules or constraints for responses -- **Use structured prompts**: Break down complex roles into bullet points - -## Storage Location - -Agent roles are stored as JSON files in: - -``` -~/.config/QtProject/qtcreator/qodeassist/agent_roles/ -``` - -**On different platforms:** -- **Linux**: `~/.config/QtProject/qtcreator/qodeassist/agent_roles/` -- **macOS**: `~/Library/Application Support/QtProject/Qt Creator/qodeassist/agent_roles/` -- **Windows**: `%APPDATA%\QtProject\qtcreator\qodeassist\agent_roles\` - -### File Format - -Each role is stored as a JSON file named `{id}.json`: - -```json -{ - "id": "doc_writer", - "name": "Documentation Writer", - "description": "Technical documentation and code comments", - "systemPrompt": "You are a technical documentation specialist...", - "isBuiltin": false -} -``` - -### Manual Editing - -You can: -- Edit JSON files directly in any text editor -- Copy role files between machines -- Share roles with team members -- Version control your roles -- Click **Open Roles Folder...** to quickly access the directory - -## How Roles Work - -When a role is selected, the final system prompt is composed as: - -``` -┌─────────────────────────────────────────────────┐ -│ Final System Prompt = Base Prompt + Role Prompt │ -├─────────────────────────────────────────────────┤ -│ 1. Base System Prompt (from Chat Settings) │ -│ 2. Agent Role System Prompt │ -│ 3. Project Rules (common/ + chat/) │ -│ 4. Linked Files Context │ -└─────────────────────────────────────────────────┘ -``` - -This allows roles to augment rather than replace your base configuration. - -## Best Practices - -1. **Keep roles focused**: Each role should have a clear, specific purpose -2. **Use descriptive names**: Make it easy to identify roles at a glance -3. **Test your prompts**: Verify roles produce the expected behavior -4. **Iterate and improve**: Refine prompts based on AI responses -5. **Share with team**: Export and share useful roles with colleagues - -## Troubleshooting - -### Role Not Appearing in Dropdown -- Restart Qt Creator after adding roles manually -- Check JSON file format validity -- Verify file is in the correct directory - -### Role Behavior Not as Expected -- Review the system prompt for clarity -- Check if base system prompt conflicts with role prompt -- Try a more specific or detailed prompt - -## Related Documentation - -- [Project Rules](project-rules.md) - Project-specific AI behavior customization -- [Chat Assistant Features](../README.md#chat-assistant) - Overview of chat functionality -- [File Context](file-context.md) - Attaching files to chat context - diff --git a/docs/agent-templates-design.md b/docs/agent-templates-design.md new file mode 100644 index 0000000..e81fdaa --- /dev/null +++ b/docs/agent-templates-design.md @@ -0,0 +1,401 @@ +# Agent Templates — Design Note (body model, include, extends) + +Status: IMPLEMENTED, then partially superseded. The `[body]` table + `extends` +model shipped; the **bundled partials described below were removed** — each wire +base now inlines its message serialization, and bases were split into a +wire-only abstract base (provider + endpoint + serialization) plus a thin +concrete agent that carries all policy (model, persona, tags, caching, thinking, +sampling). `{% include %}` survives only for user-supplied partials. Treat the +partials sections here as historical record; the current user-facing guide is +`creating-agents.md`. Dev-facing (not end-user docs). +Scope: how agent TOML profiles describe the request and share structure. + +## Problem this replaces + +The shipped model has each agent embed a `[template].message_format` jinja string +that hand-builds the **whole** request body as text, plus `[template.sampling]` and +`[template.thinking.*]` blocks merged in by `applySampling`. Pains: + +- Massive copy-paste: 9 OpenAI-compatible agents share a byte-identical ~50-line + `message_format`; 4 Claude agents share another; `role` + README `context` are + identical across 18 files. +- `[template.sampling]` / `[template.thinking.overrides]` / + `[template.thinking.request_block.*]` describe **merge machinery**, not the request + body — they don't look like the actual API call. The `overrides` vs `request_block` + split is meaningless (both are deep-merged into the request identically). +- Manual JSON-by-string-concatenation: trailing-comma bookkeeping + (`{% if not loop.is_last %},{% endif %}`) everywhere; a missing comma fails + silently at runtime (`renderBody` returns nullopt, only a `qWarning`). +- `include` is hard-disabled, so there is no way to share a sub-fragment. + +## Agreed model + +### 1. `[body]` is a deep-mergeable table = the request body, 1:1 with the API + +Replace the `message_format` string and the `sampling`/`thinking` blocks with a +single `[body]` TOML table whose keys are the **literal request-body fields**. +Because it is a table (not a string), `extends` / `deepMerge` can override it +field-by-field — variants become a 2-line delta instead of a copied body. + +Field-value rules at build time (per key in `[body]`, applied recursively): +- **string containing jinja** (`{{` or `{%`) → render through inja, splice the + output as **raw JSON** (array / object / string). Empty render → key omitted. +- **string without jinja** (e.g. `"high"`) → literal JSON string, as-is. +- **number / bool / inline-table** → as-is. + +So `messages` / `contents` and `system` / `system_instruction` are just **string +fields holding jinja**; everything else (`max_tokens`, `temperature`, `stream`, +`thinking`, `output_config`, `generationConfig`, …) is a literal value that reads +exactly like the curl body. + +No runtime toggles: thinking / tools / streaming are **fixed per agent**. A thinking +agent literally carries the `thinking` fields; a non-thinking variant is a separate +file. There is no `{% if thinking %}` in the body. `system` uses +`{% if existsIn(ctx, "system_prompt") %}` only because that is about *presence of +data*, not a mode toggle. `enable_thinking` / `enable_tools` are **capability hints** +(used for UI badges and to decide tool-definition injection) — the body is the source +of truth for what is actually sent, so a thinking agent's body must carry the thinking +fields regardless of the flag. + +Outside the body: +- `model` — the TOML `model` is the **default**; a per-agent override chosen in + QodeAssist settings wins. Overrides are stored in `agent_models.json` + (agentName → model) and applied by `AgentFactory` when it builds the agent + (`AgentFactory::effectiveModel`/`setModelOverride`); `Session` still seeds the + payload `model` from the resolved `cfg.model`. URL-model providers (Google) put a + `${MODEL}` placeholder in `endpoint`; `Session` substitutes the resolved model into + the endpoint before sending (same substitution style as `${PROJECT_DIR}`/`${CONFIG_DIR}`), + so the override drives the URL too. +- `tools` — injected by the **provider** when `enable_tools` is set (tool + definitions are dynamic, from `ToolsManager`; they can't be authored in TOML). +- `stream` — always on. Literal `"stream": true` in the body for OpenAI / Claude / + Mistral / Responses / Ollama; encoded in the `endpoint` URL for Google. + +### 2. `include` re-enabled as whitelisted partials + +The message-array rendering (the complex, comma-heavy part) lives in +`sources/agents/partials/*.jinja`, shared via `{% include %}`. The throwing include +callback is replaced by a sandboxed resolver that: +- rejects names containing `..`, a leading `/`, or a scheme/drive; +- resolves only against known roots: bundled `:/agents/partials/` then the user + `partials/` dir; +- parses/caches the partial in the same `inja::Environment`. + +A missing/typo'd partial is a **load-time** error. + +### 3. `extends` shares config down a hierarchy + +`extends` already exists (`resolveExtends` + `deepMerge` + `abstract`/`hidden`); it +keeps doing what it does, now over the structured `[body]` too. Each API-shape base +carries the default developer persona inline in `system_prompt` (the Roles +subsystem was removed 2026-06-12; see below). No shared root base. Between the +API-shape base and the concrete agents sits one thin abstract base **per provider** +(provider_instance + endpoint only) — the designated extension point for user +agents, so a custom agent is `extends` + `name` + `model`: + +``` +openai_base (abstract) → system_prompt + [body] (API shape) + ├─ mistral_base (abstract) → provider, endpoint (per-provider) + │ ├─ mistral_chat → name, model + │ └─ mistral_reasoning → name, model + enable_thinking + ├─ openrouter_base (abstract) ... + └─ openai_chat → name, model (own provider = no mid layer) +anthropic_base (abstract) → system_prompt + provider/endpoint + [body] + └─ claude_sonnet46 → name, model + [body] thinking / output_config +google_base (abstract) → system_prompt + provider + [body] + └─ gemini_chat → endpoint (${MODEL}) + [body.generationConfig] thinkingConfig +``` + +Bundled agents are read-only: the loader rejects a user file that reuses a bundled +`name`. Customisation = a user agent under a new name extending a bundled base (or a +concrete bundled agent); the per-agent model override in settings covers the +model-only case without any file. + +Notes: +- `[body]` is shared whole when identical (the 8 OpenAI-compatible providers); a + variant overrides only the differing field — no duplicated body. +- Arrays (`tags`) are **replaced** on override, not appended (`deepMerge` recurses + objects only). A child that wants base tags + extras restates the full list. +- Division of labour: **include** shares the message-rendering fragment across + unrelated families; **extends** shares config (system_prompt / endpoint / body) + down one inheritance chain. +- With `model` gone, per-model files collapse: agents that previously differed only + by `model` become one agent (the client picks the model). A separate file is only + needed when the body genuinely differs (effort, no-thinking, …). + +### System prompt — a composable template with building blocks + +The old `role` (static text) and `context` (jinja) layers collapse into one +`agent.system` layer in `Session`, rendered through `ContextRenderer`. The agent's +`system_prompt` field IS that template — the persona is whatever it renders to. +Building blocks: + +- `{{ read_file("...") }}` / `file_exists` / `${PROJECT_DIR}` / `${CONFIG_DIR}` — existing + `ContextRenderer` helpers, composable in the same template. Shared persona text + lives in plain markdown under the sandboxed roots (e.g. + `${CONFIG_DIR}/personas/reviewer.md`) and is pulled in with `read_file`. + +So a profile can do `system_prompt = """{{ read_file("${CONFIG_DIR}/personas/reviewer.md") }}"""`, +or just inline the text. A persona-switch is an agent-switch (thin `extends` variant). +The former Roles subsystem (`agent_roles/*.json`, `{{ agent_role(id) }}`, the Roles +settings page, the chat role picker) was removed on 2026-06-12 — the chat bases now +inline the developer persona text directly. There is NO per-agent settings override — +the edit point is the profile's `system_prompt`. Code-completion/FIM agents set no +`system_prompt`. + +## Worked examples + +OpenAI base: +```toml +abstract = true +system_prompt = """""" +provider_instance = "OpenAI (Chat Completions)" +endpoint = "/chat/completions" +enable_tools = true + +[body] +max_tokens = 8192 +temperature = 0.7 +stream = true +messages = """ +[ {% include "partials/openai_messages.jinja" %} ] +""" +``` + +Mistral reasoning child (delta only): +```toml +extends = "OpenAI Base Chat" +name = "Mistral Reasoning Chat" +provider_instance = "Mistral AI" +endpoint = "/v1/chat/completions" +enable_thinking = true + +[body] +reasoning_effort = "medium" +``` + +Claude base (literally the curl body): +```toml +abstract = true +system_prompt = """""" +provider_instance = "Claude" +endpoint = "/v1/messages" +enable_thinking = true +enable_tools = true + +[body] +max_tokens = 16000 +temperature = 1 +stream = true +thinking = { type = "adaptive", display = "summarized" } +output_config = { effort = "high" } +system = """{% if existsIn(ctx, "system_prompt") %}{{ tojson(ctx.system_prompt) }}{% endif %}""" +messages = """ +[ {% include "partials/anthropic_messages.jinja" %} ] +""" +``` + +Sonnet child (delta only): +```toml +extends = "Anthropic Base Chat" +name = "Claude Sonnet" + +[body.output_config] +effort = "medium" +``` + +Google base (`${MODEL}` in endpoint; streaming in the URL): +```toml +abstract = true +system_prompt = """""" +provider_instance = "Google AI" +endpoint = "/models/${MODEL}:streamGenerateContent?alt=sse" +enable_thinking = true +enable_tools = true + +[body] +system_instruction = """{% if existsIn(ctx, "system_prompt") %}{ "parts": [ { "text": {{ tojson(ctx.system_prompt) }} } ] }{% endif %}""" +contents = """ +[ {% include "partials/google_contents.jinja" %} ] +""" + +[body.generationConfig] +maxOutputTokens = 16000 +temperature = 1 +thinkingConfig = { includeThoughts = true, thinkingBudget = 8192 } +``` + +### Partials + +`partials/openai_messages.jinja` dispatches per message: +```jinja +{% if existsIn(ctx, "system_prompt") %} +{ "role": "system", "content": {{ tojson(ctx.system_prompt) }} }, +{% endif %} +{% for msg in ctx.history %} + {% if msg.role == "assistant" %}{% include "partials/openai_assistant.jinja" %} + {% else if length(filter_by_type(msg.content_blocks, "tool_result")) > 0 %}{% include "partials/openai_tool_results.jinja" %} + {% else %}{% include "partials/openai_user.jinja" %} + {% endif %} +{% endfor %} +``` + +`partials/openai_assistant.jinja`: +```jinja +{% set tcalls = filter_by_type(msg.content_blocks, "tool_use") %} +{ + "role": "assistant", + "content": {{ tojson(msg.content) }} + {% if length(tcalls) > 0 %} + , "tool_calls": [ + {% for b in tcalls %} + { "id": {{ tojson(b.id) }}, "type": "function", + "function": { "name": {{ tojson(b.name) }}, "arguments": {{ tojson(tojson(b.input)) }} } }, + {% endfor %} + ] + {% endif %} +}, +``` + +`partials/openai_tool_results.jinja`: +```jinja +{% for b in filter_by_type(msg.content_blocks, "tool_result") %} +{ "role": "tool", "tool_call_id": {{ tojson(b.tool_use_id) }}, "content": {{ tojson(b.content) }} }, +{% endfor %} +``` + +`partials/openai_user.jinja`: +```jinja +{% if existsIn(msg, "images") %} +{ "role": "user", "content": {% include "partials/openai_image_content.jinja" %} }, +{% else %} +{ "role": "user", "content": {{ tojson(msg.content) }} }, +{% endif %} +``` + +`partials/openai_image_content.jinja`: +```jinja +[ + { "type": "text", "text": {{ tojson(msg.content) }} } + {% for img in msg.images %} + , + {% if img.is_url %} + { "type": "image_url", "image_url": { "url": {{ tojson(img.data) }} } } + {% else %} + { "type": "image_url", "image_url": { "url": "data:{{ img.media_type }};base64,{{ img.data }}" } } + {% endif %} + {% endfor %} +] +``` + +`partials/anthropic_messages.jinja`: +```jinja +{% for msg in ctx.history %} +{ + "role": {{ tojson(msg.role) }}, + "content": [ + {% for b in msg.content_blocks %} + {% if b.type == "image" %}{% include "partials/anthropic_image.jinja" %} + {% else %}{{ tojson(b) }}, + {% endif %} + {% endfor %} + ] +}, +{% endfor %} +``` + +`partials/anthropic_image.jinja`: +```jinja +{ + "type": "image", + "source": + {% if b.is_url %} + { "type": "url", "url": {{ tojson(b.data) }} } + {% else %} + { "type": "base64", "media_type": {{ tojson(b.media_type) }}, "data": {{ tojson(b.data) }} } + {% endif %} +}, +``` + +`partials/google_contents.jinja`: +```jinja +{% for msg in ctx.history %} +{ + "role": {% if msg.role == "assistant" %}"model"{% else %}"user"{% endif %}, + "parts": [ {% for b in msg.content_blocks %}{% include "partials/google_part.jinja" %}{% endfor %} ] +}, +{% endfor %} +``` + +`partials/google_part.jinja`: +```jinja +{% if b.type == "text" %} +{ "text": {{ tojson(b.text) }} }, +{% else if b.type == "thinking" %} +{ "text": {{ tojson(b.thinking) }}, "thought": true, "thoughtSignature": {{ tojson(b.signature) }} }, +{% else if b.type == "tool_use" %} +{ "functionCall": { "name": {{ tojson(b.name) }}, "args": {{ tojson(b.input) }} } }, +{% else if b.type == "tool_result" %} +{ "functionResponse": { "name": {{ tojson(b.name) }}, "response": { "result": {{ tojson(b.content) }} } } }, +{% else if b.type == "image" %} + {% if b.is_url %} + { "file_data": { "mime_type": {{ tojson(b.media_type) }}, "file_uri": {{ tojson(b.data) }} } }, + {% else %} + { "inline_data": { "mime_type": {{ tojson(b.media_type) }}, "data": {{ tojson(b.data) }} } }, + {% endif %} +{% else %} +{ "text": "" }, +{% endif %} +``` + +## C++ work + +In `JsonPromptTemplate`: +- Parse `[body]` as a `QJsonObject` (not a string). Walk it recursively and build the + request: render jinja-bearing string values via inja and splice the parsed JSON; + pass literal strings / scalars / inline-tables through; drop keys whose render is + empty. +- **Delete** `m_sampling`, `m_thinking`, and `applySampling` entirely — the body is + the request; there is no separate sampling/thinking merge. +- Drop the `thinkingEnabled` parameter from `buildFullRequest` / + `Provider::prepareRequest` / `Session` — it no longer affects rendering. +- Add a **JSON-aware** trailing-comma stripper before `QJsonDocument::fromJson` + (tracks string/escape state so `,}` / `,]` inside string values are not touched). + This is what lets partials emit an unconditional `,` after every element and drop + all `loop.is_last` bookkeeping. + +In `AgentConfig` / `AgentLoader`: +- Replace `messageFormat` (string) with `body` (`QJsonObject`); merge `role` + + `context` into `system_prompt`. `[template].sampling` / `[template].thinking` are + removed. +- `extends` / `deepMerge` are unchanged; they now also merge `[body]`. +- Validate at load: a referenced partial must resolve; the assembled body must parse + as JSON (render once against a synthetic context with tool_use / tool_result / + image). Catches breakage at startup, not mid-conversation. + +Model selection (per-agent override): +- `AgentFactory` owns an agentName → model map loaded from `agent_models.json` + (`loadModelOverrides`/`saveModelOverrides`). `create()`/`createFromFile()` apply the + override into the built `AgentConfig`; `effectiveModel()` exposes the resolved value; + `setModelOverride()` persists. The settings UI (`AgentDetailPane`) edits it via an + editable Model field; list/roster widgets display `effectiveModel`. +- `Session` substitutes `${MODEL}` in `cfg.endpoint` with the resolved model before + `sendRequest` (covers Google, whose model lives in the URL), and still seeds the + payload `model` from `cfg.model`. The provider keeps injecting `tools` when + `enable_tools` is set. + +In `Session`: +- Collapse the `agent.role` + `agent.context` system-prompt layers into one rendered + `system_prompt` layer. + +## Implementation order + +1. JSON-aware trailing-comma stripper + whitelisted `include` resolver (enables + readable partials). +2. `[body]`-table model in `JsonPromptTemplate` + loader; delete + sampling/thinking/`applySampling`; drop `thinkingEnabled`. +3. `system_prompt` merge in loader + `Session`. +4. Per-agent model override in `AgentFactory` (`agent_models.json`) + `${MODEL}` + endpoint substitution in `Session`; editable Model field in settings; convert + bundled agents to the base/partials/`extends` layout. +5. Load-time validation (partial resolves, body parses). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..34f1e53 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,321 @@ +# QodeAssist Architecture + +This document describes the **current** runtime architecture, after the §10 +rework in `target-architecture.md` was completed. Every runtime LLM path — +code completion, chat (send/stream + compression + token counting), and quick +refactor — flows through one stack: agents, `Session`, and the +`Providers::GenericProvider` layer. There is no legacy parallel path; the old +"Stack A" (root `providers/*`, `pluginllmcore/*`, `ConfigurationManager`, the +provider/model/template settings pages) has been removed. + +For the design rationale, layering contract, and cross-cutting policies, see +[`target-architecture.md`](target-architecture.md). This file documents how the +code is wired today. + +--- + +## 1. Top level: ownership and dependency injection + +The plugin (`qodeassist.cpp`) owns everything via `new` + parent — no +plugin-wide singletons; each feature receives its dependencies explicitly. + +``` +QodeAssistPlugin + • Providers::registerBuiltinProviders() — client_api → provider table + • ProviderInstanceFactory — provider instances from TOML + • ProviderSecretsStore — secrets behind a port + • AgentFactory — agents from TOML + agent_models.json + • SessionManager(agentFactory) — owns the ToolContributorRegistry + toolContributors().add(registerQodeAssistTools) + toolContributors().add(registerSkillTool) + toolContributors().add(McpClientsManager::registerToolsOn) + • m_engine (QQmlEngine) + rootContext: "agentFactory", "sessionManager" — DI for chat (QML) + + Wired into consumers: + • QodeAssistClient ← LLMClientInterface(generalSettings, completeSettings, + agentFactory, sessionManager, documentReader, + performanceLogger) + ← setSessionManager / setAgentFactory (quick refactor) +``` + +Chat lives in QML (`ChatRootView` is a `QML_ELEMENT`), so `AgentFactory` and +`SessionManager` are exposed as **context properties on the engine's root +context** and resolved in `ChatRootView` via +`qmlEngine(this)->rootContext()->contextProperty(...)`. + +--- + +## 2. Core (agent / Session) + +``` +AgentFactory.create(name) + configByName(name) → AgentConfig (TOML, [body] table; model override from + agent_models.json applied here) + buildProviderForAgent: + instance = ProviderInstanceFactory.instanceByName(cfg.providerInstance) + provider = ProviderFactory::create(instance.clientApi) + provider.setUrl(instance.url) + provider.setApiKey(secrets.read(instance.apiKeyRef)) + ▼ +Agent(config, provider) + promptTemplate = JsonPromptTemplate::fromConfig(cfg) — compiles [body] (inja), + validated at load against a synthetic context + provider.setPromptCaching(cfg.cachePrompt, cfg.cacheTtl == "1h") + ▼ +SessionManager — two ways to obtain a Session: + • createSession(agentName, externalHistory?) — chat: attaches a persistent, + externally-owned history + • acquire(agentName) / release(session) — one-shot pipelines: a small + per-agent pool of internal-history + sessions; acquire hands out a + session with cleared history, + cleared system-prompt layers and + cleared client tools + ▼ +Session(agent[, externalHistory]) + ├─ ConversationHistory — messages as polymorphic ContentBlocks + ├─ SystemPromptBuilder — ordered named layers (priority-sorted) + └─ ResponseRouter(client) — adapts client signals → typed ResponseEvent + +Session API: + • send(blocks) — the ONLY dispatch entry point: append a user + message and dispatch. Completion/chat/refactor + differ only in block content + template; tools + on/off comes from the agent's enable_tools. + • cancel() — tears down in-flight; emits cancelled(id) + • history() / systemPrompt() / client() + • setContentLoader(loader) — resolves Stored* attachment/image blocks + • lastError() → ErrorInfo — typed synchronous start-failure detail + +Session signals (three-state, mutually exclusive per request): + • finished(id, stopReason) + • failed(id, ErrorInfo{category, message, providerDetail}) + • cancelled(id) + + event(ResponseEvent) — live delta stream for the chat UI +``` + +`Session::dispatch` renders the agent's `system_prompt` into the `agent.system` +layer, composes all `SystemPromptBuilder` layers into the request system prompt, +and substitutes `${MODEL}` in the endpoint before sending. + +--- + +## 3. Provider layer + +One configuration-driven `GenericProvider` covers every API; it varies only by +the LLMQore client factory and metadata. Request *shape* belongs to the agent's +`JsonPromptTemplate` (the `[body]` table), never to the provider. + +``` +ProviderFactory (sources/providers, namespace functions) + registerType(name, fn) / create(name, parent) / knownNames() + ▲ registerBuiltinProviders() — client_api → provider table +GenericProvider : Providers::Provider + • owns an LLMQore::BaseClient (created by a ClientFactory) + • prepareRequest → PromptTemplate::buildFullRequest; injects tools when + enable_tools; applies ClaudeCacheControl when prompt caching is on + • client() / providerID() / getInstalledModels() +``` + +### client_api → provider table + +| client_api | LLMQore client | ProviderID | +|------------------------------|-----------------------|------------------| +| Claude | ClaudeClient | Claude | +| Google AI | GoogleAIClient | GoogleAI | +| llama.cpp | LlamaCppClient | LlamaCpp | +| Mistral AI | MistralClient | MistralAI | +| Codestral | MistralClient | MistralAI | +| Ollama (Native) | OllamaClient | Ollama | +| Ollama (OpenAI-compatible) | OpenAIClient | OpenAICompatible | +| OpenAI (Chat Completions) | OpenAIClient | OpenAI | +| OpenAI (Responses API) | OpenAIResponsesClient | OpenAIResponses | +| OpenAI Compatible | OpenAIClient | OpenAICompatible | +| OpenRouter | OpenAIClient | OpenRouter | +| LM Studio (Chat Completions) | OpenAIClient | LMStudio | +| LM Studio (Responses API) | OpenAIResponsesClient | OpenAIResponses | + +--- + +## 4. Configuration model + +``` +~/.config/.../qodeassist/config/ + providers/*.toml → ProviderInstance { name, client_api, url, api_key_ref } + agents/*.toml → AgentConfig { schema_version, providerInstance, model, + endpoint, system_prompt, [body], match, + enable_tools, enable_thinking, cache_prompt, + extends, abstract, hidden, tags } + agent_models.json → per-agent model override (applied by AgentFactory) + pipelines → codeCompletion (ordered roster, routed by AgentRouter.pickAgent + on {filePath, projectName}); chatAssistant (allow-list for the + chat picker); chatCompression / quickRefactor (single agent each) + +Editor policy (NOT agent config): + CodeCompletionSettings — triggers, modelOutputHandler, context extraction, + useOpenFilesContext +``` + +`[body]` **is** the request body (deep-mergeable through `extends`; Jinja-bearing +string values render and splice as raw JSON, literals pass through, empty renders +drop the key). `include` resolves only sandboxed partial roots. Profiles validate +at load: a referenced partial must resolve and the assembled body must parse as +JSON against a synthetic context — config errors surface in the agents settings +page, never as a silent runtime drop. The loader also lints: unknown top-level / +`[match]` keys and same-layer duplicate names are warnings; a user file that +reuses a bundled agent's name is rejected (bundled agents cannot be replaced — +users extend them, or the per-provider abstract bases, under a new name); +`abstract` and `hidden` are never inherited through `extends`. Full spec: +[`agent-templates-design.md`](agent-templates-design.md); user-facing guide: +[`creating-agents.md`](creating-agents.md). + +--- + +## 5. Runtime paths + +Agent selection depends on the pipeline. Code completion is the only +context-routed one: `AgentRouter.pickAgent(roster.codeCompletion, {file, +project})` walks the ordered roster and returns the first agent whose `[match]` +fits. Chat filters to the `chatAssistant` allow-list and the user picks; quick +refactor and compression each use a single configured agent. + +### 5a. Code completion + +``` +Qt Creator LSP (getCompletionsCycling) + ▼ +LLMClientInterface + agent = AgentRouter.pickAgent(roster.codeCompletion, {file, project}) + session = sessionManager.acquire(agent) — pooled + systemPrompt layer "completion.context" = fileContext + open-files context + session.send( blocks{ CompletionContent(prefix, suffix) } ) + ▼ on Session::finished: + history().lastAssistantText() → CodeHandler (output-mode) → LSP items + → sessionManager.release(session) +``` + +The completion context travels as a `CompletionContent` block; the template +exposes it as `ctx.prefix` / `ctx.suffix`. FIM vs instruct is purely agent +config (the body), not feature code. Completion never touches the delta stream — +it waits for `finished` and reads the last message. + +### 5b. Chat + +`ChatRootView` owns one persistent `ConversationHistory` for the whole chat view +and injects it into every collaborator. **History is the single source of truth.** + +``` +ChatRootView (QML) — owns ConversationHistory m_history + ChatModel.setHistory(m_history) — ChatModel is a PROJECTION: + subscribes to messageAdded/Updated/cleared/reset, flattens blocks→rows, + overlays file-edit status from ChangesManager, holds a per-message usage map + ChatAgentController — picker filtered to the + chatAssistant allow-list; active agent persisted + ▼ dispatchSend +ClientInterface + session = sessionManager.createSession(activeAgent, m_history) + sessionManager.toolContributors().contribute(client.tools()) — builtin+skills+MCP + session.setContentLoader(ChatSerializer::loadContentFromStorage) + systemPrompt layer "chat.context" = project info + skills + linked files + session.send( blocks{ TextContent + StoredAttachmentContent + StoredImageContent } ) + ▼ consumes Session signals (NOT raw client signals): + event(Usage) → ChatModel.setMessageUsage + token-counter calibration + finished(id) → ChangesManager.applyPendingEditsForRequest + persist; + removeSession (the persistent history survives) + failed(id, ErrorInfo) → surface error; removeSession + +ChatCompressor → acquire(chatCompression agent — single configured) → seed history + from the chat's messages → "compression" layer → send → read summary + from the compression session's own history → release +InputTokenCounter → estimates over ConversationHistory (calibrated by Usage events) +ChatSerializer → persists ConversationHistory via MessageSerializer (v0.3); + imports legacy v0.1/v0.2 files +``` + +`ChatModel`'s QML role surface (roleType / content / attachments / images / +isRedacted / token roles) is unchanged, so the QML delegates were untouched. The +projection's incremental updates avoid model resets on the streaming hot path. + +### 5c. Quick refactor + +``` +QodeAssistClient.requestQuickRefactor → QuickRefactorHandler + agent = pipelines.quickRefactor (single configured agent) + session = sessionManager.acquire(agent) + if useTools: sessionManager.toolContributors().contribute(client.tools()) + systemPrompt layer "refactor" = tagged selection + output + indentation rules + session.send(blocks{instructions}) + ▼ on Session::finished: + history().lastAssistantText() → ResponseCleaner → RefactorResult → editor insert + → sessionManager.release(session) + on Session::failed(ErrorInfo) → RefactorResult{error} +``` + +--- + +## 6. Context layer + +The context services sit behind IDE-agnostic ports; Qt Creator API use lives in +the adapters. + +``` +EditorContext — IDocumentReader (port) ← DocumentReaderQtCreator (TextEditor API) +ProjectContext — IProjectScanner (port) ← ProjectScannerQtCreator (ProjectExplorer + + Core::DocumentModel + the IgnoreManager for .qodeassistignore) +TokenEstimator — TokenUtils (pure) ← InputTokenCounter (thin UI consumer) +``` + +`ContextManager` is now Qt-Creator-free: it delegates open-file enumeration and +ignore filtering to an injected `IProjectScanner` (defaulting to the QtC adapter), +and keeps only filesystem reads + formatting. `ContextManager::shouldIgnore(path)` +replaced the previously exposed `ignoreManager()`. + +--- + +## 7. Cross-cutting + +- **Request lifecycle** — a session has at most one in-flight request; `send()` + while in flight cancels the previous. Every request ends in exactly one of + `finished` / `failed` / `cancelled`. Cancellation is not an error; no consumer + string-matches a message to tell them apart. +- **Typed errors** — `ErrorInfo { category ∈ {Config, Auth, Network, Provider, + Validation, Tool}, message, providerDetail }`. `ResponseRouter` categorizes wire + errors (best-effort) at the boundary; `Session::failed` carries the typed value. +- **Tools** — `SessionManager` owns a `ToolContributorRegistry`; built-in ToolKit, + the skill tool, and MCP client tools register once and are contributed to chat + and quick-refactor session clients uniformly. +- **Threading** — the core runs on the GUI thread; concurrency is the Qt event + loop plus async network I/O. Blocking work hides behind L3 ports. + +--- + +## 8. Tests + +`test/` (GTest + Qt::Test) covers the two engines most affected by the rework: + +- `JsonPromptTemplateTest` — the `[body]` engine: jinja render + JSON splice, + literal passthrough, empty-render key drop, nested literals, and load-time + rejection of bodies that render invalid JSON. +- `ResponseRouterTest` — a fake `BaseClient` replays a recorded provider stream; + asserts the assistant message is stamped with the request id, history is built + correctly (thinking + text + tool use/result), the typed event stream is emitted, + and wire errors are categorized. +- `BundledAgentsTest` — loads every bundled agent through the real loader (extends + + partials resolved from the qrc) and renders each `[body]` against the synthetic + validation context. This is the load-time validation guarantee run in CI: a broken + bundled body, partial, or `extends` chain fails the test instead of surfacing as a + silent runtime drop. + +--- + +## 9. Remaining follow-ups (optional) + +1. **Qt-Creator-free core build + CI** — `AgentFactory` / `ContextRenderer` still + call `Core::ICore::userResourcePath`, so the core targets link `QtCreator::Core`. + A `ResourcePaths` port + adapter would let the core build without Qt Creator and + enable a CI job that fails on a layering-violating include. (The bundled-agent + render check already runs in the QtC-linked test binary — see §8.) +2. **§9 target module layout** — the `core/ ide/ features/ hosts/` physical target + split in `target-architecture.md` is not yet reflected in the directory layout. +``` diff --git a/docs/chat-summarization.md b/docs/chat-summarization.md index a98d030..67c7fb3 100644 --- a/docs/chat-summarization.md +++ b/docs/chat-summarization.md @@ -110,6 +110,4 @@ No additional configuration is required. ## Related Documentation -- [Agent Roles](agent-roles.md) - Switch between AI personas - [File Context](file-context.md) - Attach files to chat -- [Project Rules](project-rules.md) - Customize AI behavior diff --git a/docs/context-architecture.md b/docs/context-architecture.md new file mode 100644 index 0000000..124d610 --- /dev/null +++ b/docs/context-architecture.md @@ -0,0 +1,347 @@ +# QodeAssist — Context Architecture (v1.0) + +Status: design proposal, extends `target-architecture.md` (§7 ContextEngine, +delta #9) and `agent-templates-design.md` (the `ctx.*` template contract). +Scope: everything between "facts exist in the IDE / on disk / in the +conversation" and "bytes leave in the request body" — what context each +pipeline needs, who acquires it, where it lands in the prompt. One assembly +runs per `send()`; tool continuations stay inside LLMQore (§4.3). + +--- + +## 1. Taxonomy — the five kinds of context + +Every piece of context the model ever sees falls into one of five categories. +The categories differ in *acquisition mode*, *volatility*, and therefore +*placement* — conflating them is the root cause of today's problems (§3). + +| # | Category | What it answers | Examples | Volatility | +|---|----------|-----------------|----------|------------| +| C1 | **Identity** | who is the assistant | agent `system_prompt` (persona inline or via `read_file()`), always-on skills, skills catalog | per agent change | +| C2 | **Environment** | where is it working | project name + source root, build dir, language/file info, recent changes | per project / slow | +| C3 | **Task** | what is asked *now* | chat message, attachments, images, invoked-skill body, completion prefix/suffix, refactor selection + instruction | every turn | +| C4 | **Conversation** | what happened so far | history (text, thinking, tool use/results), compression summary | grows every turn | +| C5 | **Pulled** | what the model asked for | tool results (read file, search, build, diagnostics), MCP tool results | inside the turn | + +Two acquisition modes cut across the categories: + +- **Push** — we inject proactively (C1–C3, C4). Push is a *per-pipeline + policy*: completion must push everything (no latency budget for tools); + chat should push little and let the model pull. +- **Pull** — the model requests through tools (C5). Pull needs no assembly + policy at all, but its *results* become C4 and therefore must flow through + the same budget and serialization rules as everything else. + +One more orthogonal property drives placement: **stability**. Provider prompt +caches (Claude `cache_control`) reward byte-stable prefixes. Stable content +belongs early (system), volatile content belongs late (near the last user +message). This single rule decides almost every placement question below. + +--- + +## 2. Context inventory per pipeline + +What each use case (numbering from `target-architecture.md` §1) actually +needs, against the taxonomy: + +| Context item | Cat | U1 completion | U2 chat | U3 refactor | compression | Source port | +|---|---|---|---|---|---|---| +| agent `system_prompt` (persona) | C1 | ✓ | ✓ (persona switch = agent switch) | ✓ | ✓ | AgentProfile + ContextRenderer | +| skills catalog + always-on | C1 | — | ✓ | — | — | SkillsEngine | +| project root / build dir | C2 | — | ✓ | — | — | `IProjectScanner` | +| language + file info | C2 | ✓ | — | ✓ | — | `IDocumentReader` | +| recent project changes | C2 | optional (setting) | — | optional | — | ChangesManager | +| prefix / suffix (FIM) | C3 | ✓ | — | — | — | `IDocumentReader` | +| selection + position markers | C3 | — | — | ✓ | — | `IDocumentReader` | +| user message text | C3 | — | ✓ | ✓ (instruction) | ✓ (directive) | UI | +| attachments / images | C3 | — | ✓ | — | — | chat storage (loader) | +| invoked skill body (`/cmd`) | C3 | — | ✓ | — | — | SkillsEngine | +| linked files (pinned) | C3/C2 | — | ✓ | — | — | `IProjectScanner` + fs | +| open-files sync | C3/C2 | — | ✓ | — | — | `IProjectScanner` | +| history | C4 | — (fresh session) | ✓ | — (fresh) | ✓ (read-only input) | ConversationHistory | +| tool results | C5 | — | ✓ | ✓ (optional) | — | ToolsManager / McpHub | + +--- + +## 3. Problems in the current code this design removes + +1. ~~**Two assembly paths.**~~ — RECLASSIFIED 2026-06-12 as by-design, not a + problem: the first request renders from `ConversationHistory`; tool + continuations are LLMQore's replay of that payload plus appended tool + results. The replay carries the full filtered history of its base payload, + so the feared filter divergence does not materialize in practice (§4.3). +2. **No budget.** History is never trimmed, estimated, or compacted; every + send ships everything, forever. +3. **Volatile content in system.** Linked-file contents live in the + `chat.context` system layer; any file edit between turns invalidates the + provider prompt cache for the whole request. +4. **Invoked skills evaporate.** A `/skill` body is injected into the system + layer for one send only — the next turn the model has lost the skill's + instructions, although the conversation continues to rely on them. +5. **Silent loss.** A failed attachment load drops the block with no trace — + neither the model nor the user learns the image is gone. +6. **Repeated materialization.** Every send re-reads and re-base64s every + stored image/attachment of the whole history from disk. +7. **Placement decided ad hoc.** Each feature hand-formats markdown and picks + a system layer by habit (`completion.context`, `refactor`, `chat.context`); + there is no shared rule for what goes where, and the project-info block is + formatted three different ways. + +--- + +## 4. Architecture — Acquire → Assemble → Shape + +Three stages with hard ownership boundaries: + +```mermaid +flowchart LR + subgraph L3["Acquire — ContextEngine (L3, ports + QtC adapters)"] + EC["EditorContext
prefix/suffix, selection,
language, copyright strip"] + PC["ProjectContext
root, ignore filter,
open files, changes"] + TE["TokenEstimator
calibrated by Usage"] + end + subgraph L4["Features (L4) — decide WHAT"] + F["chat / completion / refactor
set layers, pin providers,
build user blocks"] + end + subgraph L2["Assemble — Session (L2) — decide WHERE & HOW MUCH"] + SPB["SystemPromptBuilder
stable layers only"] + PIN["Pinned providers
re-materialized every dispatch"] + CA["ContextAssembler
history + layers + pinned
+ loader + budget → ctx"] + end + subgraph L1["Shape — JsonPromptTemplate (L1/L2)"] + TPL["[body] jinja over ctx.*"] + end + EC --> F + PC --> F + F --> SPB + F --> PIN + F --> CA + SPB --> CA + PIN --> CA + TE --> CA + CA --> TPL +``` + +- **Acquire (L3)** — `ContextEngine` services behind IDE-agnostic ports read + facts from the IDE/fs. No prompt text, no placement decisions. One shared + `EnvBlockFormatter` renders the project/file info block so it is identical + in every pipeline. +- **Features (L4)** decide *what* context a turn needs: they set their system + layer, pin refreshable providers, and compose user blocks. They never + decide request shape and never concatenate history. +- **Assemble (L2)** — `ContextAssembler` (successor of + `Session::toLegacyContext`) is the **only** producer of the template + context, once per `send()` dispatch; tool continuations replay that payload + inside LLMQore (§4.3). It owns placement policy, budget enforcement, + materialization, and the manifest. +- **Shape (L1)** — the agent's `[body]` table renders `ctx.*` into the wire + request. Templates own *shape per provider*, never content. + +### 4.1 The three injection mechanisms + +| Mechanism | For | Lifetime | Refresh | Persisted | +|---|---|---|---|---| +| **System layers** (`SystemPromptBuilder`) | stable C1/C2: `agent.system`, `env.project`, `skills.catalog`, `refactor`, `compression` | conversation | on send | no | +| **Pinned providers** (new) | refreshable C3/C2: linked files, open-files sync | until unpinned | **every `send()`** | as reference only | +| **User blocks** (`send(blocks)`) | one-shot C3: message, attachments, images, invoked-skill body, completion content | that turn | never (history is immutable) | yes | + +Pinned providers are the new piece: + +``` +session->pinContext(id, [](){ return materialized blocks; }); +session->unpinContext(id); +``` + +The assembler calls every pinned provider at **every `send()`** and splices +the result as text blocks +**prepended to the turn's typed user message** — the last user-role wire +message that does not carry tool results (falling back to the tool-result +carrier, after its leading `tool_result` blocks, and to a synthetic user +message when the history has no user message at all). Prepending into an +existing message rather than inserting a separate one keeps strict +user/assistant alternation, which some provider APIs enforce. + +The fixed anchor and the per-turn refresh split the cache cost fairly: +within a turn's tool loop the pinned blocks are byte-identical (continuations +replay the payload — pure appends, cache hits); the next `send()` re-reads +the files, and a change invalidates the cache only from the turn's anchor, +not from the system prefix. The materialized block's label states its capture +time ("content as of this turn") because a tool may mutate the file mid-loop; +the model sees such changes through the tool results themselves. Pinned +content is never stored in history and never persisted — never duplicated +turn-over-turn. + +Invoked-skill bodies move the opposite way: out of the system layer into the +**user blocks of that turn** (a dedicated block type), so they persist in +history and survive the rest of the conversation (fixes problem 4). + +### 4.2 Placement policy (single table, owned by the assembler) + +| Content | Position in request | Why | +|---|---|---| +| `agent.system` (rendered TOML `system_prompt`) | system, first | static per agent → max cache reuse | +| `env.project`, `skills.catalog` | system, after agent | changes rarely | +| pipeline layers (`refactor`, `compression`, `completion.context`) | system, last | fresh session each time, ordering irrelevant | +| history | messages | as is | +| pinned materializations | text blocks prepended to the turn's typed user message, live content | fixed anchor keeps the prefix cache-stable; content refreshes because tools mutate files at any moment | +| task blocks | last user message | the turn itself | + +`ClaudeCacheControl` breakpoints stay as they are (system / history tail); +this ordering is what makes them effective. + +### 4.3 Tool continuations stay in LLMQore (replay) + +The tool loop deliberately stays in LLMQore — the library is a complete, +standalone agentic client, and the loop (execute tools, count rounds, +schedule the next request, stream) is *mechanism*, which per +`target-architecture.md` design principle 3 belongs in C++ identically for +all providers. Continuation *content* is the library's default replay: the +base payload plus the assistant message and appended tool results. + +An inversion hook (`setContinuationPayloadBuilder`, an optional per-request +callback letting `Session` re-assemble each continuation through +`ContextAssembler`) was implemented and **reverted 2026-06-12**: the problem +it solved was judged contrived. The replay already carries the full filtered +history of its base payload, mid-loop file changes reach the model through +the tool results themselves, and continuation growth within one turn is +bounded by `maxToolContinuations` — budget enforcement at `send()` time +covers the realistic cases. Consequences accepted with the revert: the +manifest logs one entry per `send()` (not per wire request), and pinned +content is byte-stable for the duration of a turn's tool loop (§4.1). + +2026-06-13 the loop's *shape* inside LLMQore was refactored without changing +this decision (see `tool-loop-runner-plan.md`): the loop policy now lives in +`ToolLoopRunner` (per-request round state, limit, continuation decision) and +`BaseClient` slimmed to transport + tool dispatch with public primitives +`continueRequest` / `buildReplayContinuation` / `abortRequest`. Continuation +content is still the replay. QodeAssist sets the round limit via +`client->toolLoop()->setMaxRounds(...)`; the old `setMaxToolContinuations` +stays as a forwarder for compatibility. + +### 4.4 Budget + +`ContextAssembler` consults a `BudgetPolicy` before producing the context: + +``` +input_estimate = TokenEstimator(system + history + pinned + task) +limit = agent context_window − body.max_tokens (output reserve) +``` + +`context_window` comes from provider/model metadata with an optional agent +TOML override. When the estimate exceeds the limit the policy returns a trim +plan executed in deterministic order: + +1. elide bodies of tool results older than the last N rounds + (`[tool result elided — N tokens]` placeholder, pairing preserved); +2. elide materializations of old stored images/attachments (placeholder + block, reference kept in history); +3. below a hard floor — refuse with `ErrorCategory::Validation` and surface + "compress the conversation" (ChatCompressor) in the UI. + +v1.0 ships stages: **estimate + manifest + UI warning** first (no silent +trimming), then stage 1–2 elision, then auto-compression hooks. The +architecture fixes the *seam*; the policy can stay minimal. + +`TokenEstimator` is calibrated per provider/model from `Usage` events +(§8.5 of the target architecture) — chars-per-token ratio updated after every +response; the chat token counter and the budget share this one estimator. + +### 4.5 Materialization and caching + +Stored content (attachments, images) stays reference-only in history; +materialization happens in the assembler through the `ContentLoader`. Two +fixes over today: + +- the loader result is cached per `(storedPath, mtime, size)` — no re-reading + the whole conversation's binaries on every send, and byte-identical turns + keep the provider prompt cache warm; +- a failed load produces an **explicit placeholder block** + (`[attachment unavailable: name.png]`) instead of silently vanishing — + the model can say so, the manifest records it (fixes problem 5). + +### 4.6 Observability: the context manifest + +Every `assemble()` emits one debug-category log entry and a struct on the +event stream: + +``` +manifest { + layers: { agent.system: ~1.9k tok, env.project: ~70, skills.catalog: ~640 } + history: 26 messages, ~14.2k tok (3 tool rounds) + pinned: { linked:src/main.cpp: ~2.1k } + task: ~310 tok, 1 image (cached) + elided: [ tool_result a4f1 (~8k) ] + estimate: ~19.3k / limit 32k +} +``` + +Nothing is dropped silently — every filter (unsigned thinking, orphaned tool +pairs, failed loads, budget elisions) leaves a manifest record. The token +counter UI reads the same struct. + +--- + +## 5. Wire contract — `ctx.*` stays, gains one producer + +`Templates::ContextData` (→ `ctx.system_prompt`, `ctx.history`, +`ctx.prefix/suffix`, `ctx.files_metadata`) remains the contract between the +core and `[body]` templates — it is not legacy, it is the template-facing +view of the assembled context. The change is that exactly one function +produces it (`ContextAssembler::assemble`), for every request, and +`toLegacyContext`/`buildLegacyContext` are renamed into it. Existing +serialization rules carry over unchanged: system messages never enter +history, unsigned thinking is dropped, orphaned tool_use/tool_result pairs +are filtered, `CompletionContent` becomes `prefix`/`suffix`. + +--- + +## 6. Migration plan + +Ordered so every step lands independently and shrinks risk: + +1. **Extract `ContextAssembler`** from `buildLegacyContext` (pure, unit-tested + against fixture histories) + manifest logging + failed-load placeholder + blocks. No behavior change otherwise. — DONE 2026-06-12 + (`sources/Session/ContextAssembler.{hpp,cpp}`, `test/ContextAssemblerTest.cpp`; + manifest logged under the `qodeassist.context` category). +2. **ContentLoader cache** keyed by `(path, mtime, size)`. — DONE 2026-06-12 + (`StoredContentCache` in `ChatSerializer`, owned per-chat by + `ClientInterface`, cleared on chat switch). +3. **Pinned providers**: linked files and open-files sync move out of the + `chat.context` system layer; invoked-skill bodies move into the turn's + user blocks. `chat.context` shrinks to project info + skills catalog. + — DONE 2026-06-12 (`Session::pinContext/unpinContext`, pinned splice in + `ContextAssembler::assemble`; `SkillInvocationContent` block persisted via + `MessageSerializer`, invisible in the chat UI by design; open-files sync is + covered because `ChatRootView` merges open editors into the linked list). +4. **Shared `EnvBlockFormatter`** in ContextEngine; chat/refactor/completion + stop hand-formatting project/file info. — DONE 2026-06-12 + (`context/EnvBlockFormatter.{hpp,cpp}`: pure `formatProject`/`formatFile` + + the `currentProject()` QtC gatherer; chat project block, refactor file + header, and completion's `getLanguageAndFileInfo` all route through it). +5. ~~**Continuation payload callback**~~ — REVERTED 2026-06-12 (implemented, + then judged a solution to a contrived problem; see §4.3). Continuations + are LLMQore's default replay; `ContextAssembler` runs once per `send()`. +6. **TokenEstimator + BudgetPolicy seam** — estimate + warning first, then + elision stages. +7. **ContextEngine port split** (delta #9 of the target architecture) — + `EditorContext` / `ProjectContext` / `TokenEstimator` behind ports, QtC + API only in `ide/context` adapters. + +--- + +## 7. Open questions + +1. ~~**Pinned placement**~~ — RESOLVED 2026-06-12: text blocks prepended to + the last user-role wire message (synthetic user message only when there is + none). A separate synthetic message would break strict role alternation on + some provider APIs; cache behaviour of the two shapes is identical. +2. ~~**Tool-loop relocation cost**~~ — RESOLVED 2026-06-12: relocation + rejected (LLMQore is deliberately a standalone agentic client). The + follow-up `setContinuationPayloadBuilder` inversion hook was also + implemented and reverted the same day — replay is the accepted behaviour + (§4.3). +3. **Budget v1 scope** — warn-only vs. enabling tool-result elision + immediately. Elision changes what the model sees; needs live validation. +4. **Completion and open files** — should completion gain pinned open-files + context (cheap with this design), or stay prefix/suffix-only for latency? diff --git a/docs/core-class-diagram.svg b/docs/core-class-diagram.svg new file mode 100644 index 0000000..d5f1cfa --- /dev/null +++ b/docs/core-class-diagram.svg @@ -0,0 +1 @@ +

pools

builds via

creates

SessionManager

+acquire(agentName) : Session

+release(session)

+toolContributors() : ToolContributorRegistry

Session

+send(blocks, toolPolicy) : RequestID

+cancel()

+history() : ConversationHistory

+systemPrompt() : SystemPromptBuilder

+event(ResponseEvent)

+finished(id, stopReason)

+failed(id, ErrorInfo)

+cancelled(id)

ConversationHistory

+messages() : vector<Message>

+lastAssistantText() : string

+append(Message)

+reset(vector<Message>)

Message

+role Role

+blocks vector<ContentBlock>

SystemPromptBuilder

+setLayer(id, text, priority)

+removeLayer(id)

+compose() : string

ResponseRouter

+attach(BaseClient)

+event(ResponseEvent)

Agent

+config() : AgentConfig

+provider() : Provider

+promptTemplate() : PromptTemplate

AgentFactory

+create(name) : Agent

+configByName(name) : AgentConfig

+effectiveModel(name) : string

AgentRouter

+pickAgent(roster, fileCtx) : string

Provider

+capabilities() : Capabilities

+prepareRequest(request, ctx)

+sendRequest(json) : RequestID

+cancelRequest(RequestID)

GenericProvider

-client BaseClient

PromptTemplate

+buildFullRequest(request, ctx)

JsonPromptTemplate

-bodySpec QJsonObject

-env InjaEnvironment

ToolContributorRegistry

+registerContributor(fn)

+applyTo(ToolsManager)

\ No newline at end of file diff --git a/docs/creating-agents.md b/docs/creating-agents.md new file mode 100644 index 0000000..2feed56 --- /dev/null +++ b/docs/creating-agents.md @@ -0,0 +1,349 @@ +# Creating and Extending Agents + +An *agent* is a TOML profile that tells QodeAssist which provider to call, +which model to use, and exactly what request body to send. All bundled agents +(Settings → QodeAssist → Agents) are built from the same files described here — +anything a bundled agent does, a user agent can do too. + +## Where user agents live + +Drop `*.toml` files into the user agents directory: + +| OS | Path | +|---|---| +| Linux / macOS | `~/.config/QtProject/qtcreator/qodeassist/config/agents/` | +| Windows | `%APPDATA%\QtProject\qtcreator\qodeassist\config\agents\` | + +QodeAssist creates the directory on startup. Files are loaded at plugin +startup; after adding or editing a file, restart Qt Creator. + +Two layers are loaded: + +1. **Bundled** agents shipped inside the plugin — read-only. +2. **User** agents from the directory above (marked with a `user` pill). + +Agent `name`s are global across both layers. A user file that reuses a +bundled agent's `name` is rejected with an error — bundled agents cannot be +replaced; create your own agent under a new name and `extends` what you want +to build on. Two *user* files with the same `name` produce a warning, and +the alphabetically later file wins. + +Load errors and warnings (TOML syntax, unknown keys, missing `extends` +parents, bodies that don't render to valid JSON) are reported in Qt Creator's +**General Messages** pane, prefixed with `[Agents]`. + +## Minimal example + +A custom agent is a thin delta over a bundled **wire base**: extend it, set the +model, override only what differs. The base already carries the provider, the +endpoint and the request-body serialization — you add the policy. + +```toml +schema_version = 1 + +extends = "Claude Base Chat" +name = "My Claude" +model = "claude-sonnet-4-6" +``` + +Override a body field or the persona: + +```toml +schema_version = 1 + +extends = "Claude Base Chat" +name = "My Claude (low temp)" +model = "claude-sonnet-4-6" + +system_prompt = """You are a terse code reviewer.""" + +[body] +temperature = 0.3 +``` + +Point a base at a different OpenAI-compatible provider by overriding the +provider instance and model: + +```toml +schema_version = 1 + +extends = "OpenAI Base Chat" +name = "My DeepSeek" +provider_instance = "OpenAI Compatible" +model = "deepseek-chat" +``` + +Bundled agents are read-only — vary a preset by creating your own under a new +name. If all you want is a different model, you don't even need a file: set the +per-agent model override in the settings UI. + +## Key reference + +| Key | Required | Meaning | +|---|---|---| +| `schema_version` | no (default 1) | Format version; the plugin refuses files newer than it supports. | +| `name` | yes | Unique identifier; shown in the UI, referenced by rosters and `extends`. | +| `description` | no | Tooltip text in the Agents list. | +| `provider_instance` | yes* | Name of a provider instance (see below). | +| `model` | yes* | Default model; can be overridden per agent in settings. | +| `endpoint` | yes* | Path appended to the provider instance URL. May contain `${MODEL}` (e.g. Google: `/models/${MODEL}:streamGenerateContent?alt=sse`). | +| `system_prompt` | no | Jinja template for the system prompt (see building blocks below). FIM agents usually omit it. | +| `tags` | no | Free-form strings shown as pills in the UI for discoverability. | +| `enable_thinking` | no | Capability hint (UI badge). The `[body]` is the source of truth for what is sent. | +| `enable_tools` | no | Lets the provider inject tool definitions into the request. | +| `cache_prompt` / `cache_ttl` | no | Prompt caching (Anthropic); `cache_ttl = "1h"` selects the long TTL. | +| `cache_breakpoints` | no | Which cache points to set when `cache_prompt` is on: any of `"system"`, `"tools"`, `"history"`. Empty/omitted = all three. | +| `extends` | no | Name of a parent agent to inherit from. | +| `abstract` | no | Mark as template-only: it can be extended but is never loaded as a usable agent. Not inherited. | +| `hidden` | no | Loaded and usable, but not listed in selection UIs. Not inherited. | +| `[match]` | no | Routing constraints (see Routing). | +| `[body]` | yes* | The literal request body (see below). | + +\* required after `extends` resolution — a child inherits these from its +parent, so it only states what differs. + +### Required keys checked at load + +A concrete (non-abstract) agent must end up with `name`, +`provider_instance`, `model`, `endpoint`, and a non-empty `[body]`. Unknown +keys anywhere at the top level or in `[match]` produce a warning — this +catches typos like `enable_thinkin`. + +## Provider instances + +`provider_instance` refers to a provider configuration (URL + API key +reference + client API). Bundled instances: + +`Claude`, `Codestral`, `Google AI`, `llama.cpp`, +`LM Studio (Chat Completions)`, `LM Studio (Responses API)`, `Mistral AI`, +`Ollama (Native)`, `Ollama (OpenAI-compatible)`, `OpenAI (Chat Completions)`, +`OpenAI (Responses API)`, `OpenAI Compatible`, `OpenRouter`. + +User-defined instances live next to agents in +`…/qodeassist/config/providers/*.toml` and follow the same +override-by-name layering. + +## `extends` — inheriting from another agent + +A child deep-merges over its parent: scalar keys are replaced, tables (such +as `[body]` and `[body.options]`) merge key-by-key, and **arrays are replaced +whole** (a child that wants the parent's `tags` plus one more must restate +the full list). Chains can be deeper than one level; cycles and missing +parents are load errors. + +`abstract` and `hidden` are never inherited — extending a hidden agent +yields a visible child unless the child says otherwise. + +Every provider ships an **abstract wire base** that carries only the provider +instance, endpoint and the request-body serialization — no model, persona, +tags or sampling. Extending one and setting `model` is all a custom agent +needs: + +| Base | Provider / API | +|---|---| +| `Claude Base Chat` | Claude, Anthropic Messages (`/v1/messages`) | +| `OpenAI Base Chat` | OpenAI, Chat Completions (`/chat/completions`) | +| `OpenAI Responses Base` | OpenAI, Responses API (`/responses`) | +| `Google Base Chat` | Google AI, Gemini `generateContent` | +| `Ollama Base Chat` | Ollama, native `/api/chat` | +| `Ollama FIM Base` | Ollama, native `/api/generate` fill-in-the-middle | + +For any OpenAI-compatible provider (Mistral, OpenRouter, LM Studio, llama.cpp, +DeepSeek, …) extend `OpenAI Base Chat` and override `provider_instance`. + +Each bundled concrete agent (`Claude Sonnet Chat`, `Claude Code Completion`, +`OpenAI Chat Completions`, `OpenAI Responses Chat`, `Google Chat`, +`Ollama Chat`, `Ollama FIM`) is itself a thin delta over one of these bases and +works as a parent too — `extends = "Claude Sonnet Chat"` inherits everything including +the model. + +## `[body]` — the request, literally + +`[body]` is the request body, written exactly like the provider's curl +example. Per key, recursively: + +- **string containing jinja** (`{{` or `{%`) — rendered, and the output is + spliced in as raw JSON. A render that produces nothing drops the key. +- **plain string / number / bool / table** — passed through unchanged. + +```toml +[body] +max_tokens = 16000 +stream = true +thinking = { type = "adaptive", display = "summarized" } +``` + +The message-array serialization (`messages` / `contents` / `input`, plus the +`system` renderer) lives in the **wire base**; a concrete agent that extends a +base inherits it and usually sets only scalar policy fields like the ones +above. A from-scratch agent (no `extends`) must carry the full serialization +itself — copy a bundled base's `[body]` as the starting point. + +There are no runtime toggles: a thinking variant is a separate agent file +that carries the thinking fields in its body. + +Every agent body is dry-run rendered at load against a synthetic +conversation (text, thinking, tool calls, tool results, images), so jinja +syntax errors, unknown callbacks, missing partials, and invalid JSON are +reported at startup — not mid-conversation. Trailing commas emitted by loops +are stripped automatically; don't bother with `loop.is_last` bookkeeping. + +### Template data (`ctx`) + +| Field | Content | +|---|---| +| `ctx.system_prompt` | Rendered system prompt (present only if the agent has one). | +| `ctx.prefix` / `ctx.suffix` | Code around the cursor (FIM/completion sessions). | +| `ctx.files_metadata` | Array of `{ file_path, content }` for attached files. | +| `ctx.history` | Array of messages: `{ role, content, content_blocks, images? }`. | + +`content` is the message's flattened text; `content_blocks` is the +structured form: + +| `type` | Fields | +|---|---| +| `text` | `text` | +| `thinking` | `thinking`, `signature` | +| `redacted_thinking` | `data` | +| `tool_use` | `id`, `name`, `input` (JSON object) | +| `tool_result` | `tool_use_id`, `content`, `name` | +| `image` | `data`, `media_type`, `is_url` | + +### Callbacks available in `[body]` + +| Callback | Purpose | +|---|---| +| `tojson(x)` | Serialize any value as JSON (correct quoting/escaping). Use it for every interpolated value. | +| `filter_by_type(blocks, "tool_use")` | Subset of `content_blocks` with the given type. | +| `filter_skip_role(history, "system")` | History without messages of a role. | +| `strip_signature_suffix(s)` | Remove a trailing `[Signature: …]` marker. | + +### Partials and `{% include %}` + +The message-array serialization is **inlined directly in each bundled wire +base** — there are no bundled partials to include. The `{% include %}` +mechanism still works for *your own* partials: drop a `partials/*.jinja` next +to your agent TOML and include it with +`{% include "partials/my_messages.jinja" %}`. Includes resolve against the +bundled root first, then the user agent's own directory; paths with `..` or a +leading `/` are rejected. + +## `system_prompt` — composable building blocks + +`system_prompt` is itself a jinja template, rendered with: + +| Helper | Purpose | +|---|---| +| `{{ read_file("${PROJECT_DIR}/STYLE.md") }}` | Inline a file. Reads are restricted to the project directory, your QodeAssist user directory (`${CONFIG_DIR}`), and bundled `:/…` resources. | +| `{{ file_exists(p) }}` / `{{ read_dir(p) }}` | Existence check / directory listing (same root restrictions). | +| `{{ head_lines(s, n) }}` | First `n` lines of a string. | +| `basename`, `dirname`, `ext`, `lower`, `upper` | Path/string helpers. | +| `${PROJECT_DIR}`, `${CONFIG_DIR}` | Substituted before rendering. `${CONFIG_DIR}` is your QodeAssist user directory (where agent configs live). | + +Example: + +```toml +system_prompt = """ +{{ read_file("${CONFIG_DIR}/roles/reviewer.md") }} + +{% if file_exists("${PROJECT_DIR}/.qodeassist-style.md") %} +Project conventions: +{{ read_file("${PROJECT_DIR}/.qodeassist-style.md") }} +{% endif %} +""" +``` + +Reads fail **loud**: a path outside those roots — or a `read_file` whose target +is missing — aborts the request with a clear error instead of silently rendering +an empty prompt. For a genuinely optional file, guard it with `file_exists`, +which returns `false` for an allowed-but-absent path; only a path *outside* the +roots is treated as an authoring error and rejected outright. + +The persona is simply what `system_prompt` renders to — inline the text or pull +shared text from a markdown file with `read_file`. The bundled chat agents do +exactly this: their `system_prompt` is `{{ read_file(":/roles/qt-cpp-developer.md") }}`, +reading the shipped role from the plugin resources. To switch personas in the +chat, switch agents: a persona variant is a thin `extends` child that overrides +only `system_prompt` (e.g. pointing `read_file` at any file of your own under +`${CONFIG_DIR}/…` or `${PROJECT_DIR}/…`). `read_file` reads exactly the path +you give it — there is no override convention that swaps a bundled file for a +same-named user file. + +## Routing — `[match]` and the completion roster + +`[match]` drives **code completion** routing only. Completion has an ordered +roster of agents; for the current file the **first roster entry whose `[match]` +accepts** wins. The other pipelines don't route: chat shows an allow-list of +agents and you pick one in the panel; quick refactor and chat compression each +use a single configured agent (set in QodeAssist → General). + +```toml +[match] +file_patterns = ["*.qml", "*.js"] +path_patterns = ["*/tests/*"] +project_names = ["MyProject"] +``` + +- Dimensions are ANDed; an empty dimension is unconstrained; an entirely + empty/absent `[match]` is a catch-all. +- `file_patterns` are case-insensitive globs tested against the file name + and the full path; `path_patterns` against the full path only. +- `project_names` are exact, case-sensitive project names. + +Typical completion setup: a specialized agent (e.g. an `Ollama FIM` variant +with `*.qml`) first, a catch-all agent last. + +## Models + +The TOML `model` is only the default. The settings UI can set a per-agent +override (stored in `agent_models.json`); the resolved model is also +substituted into `${MODEL}` in `endpoint` before sending. + +## Contributing your agent to QodeAssist + +The bundled agent set grows through contributions — if you've made an agent +for a provider or model that others could use, please send it upstream +instead of keeping it local. No C++ is needed: + +1. Develop and verify the agent locally in the user agents directory. +2. In a fork, copy the TOML to `sources/agents/` and register the file in + `sources/agents/agents.qrc`. +3. Keep it a thin delta: extend the matching provider base and set only + `name`, `description`, `model`, `tags` (and `[body]` keys that genuinely + differ). Look at `claude_chat.toml` or `ollama_fim.toml` for the expected + shape. +4. Run the tests (`QodeAssistTest`): `BundledAgentsTest` automatically + loads every bundled agent, resolves its `extends` chain, and dry-renders + its `[body]` — if your TOML passes, it works. +5. Open a pull request. + +Conventions: + +- File name: `__.toml` + (e.g. `openrouter_deepseek_chat.toml`). +- `name` is user-visible and must be unique; include the provider and model + (e.g. `OpenRouter DeepSeek Chat`). +- Specialized completion agents should carry a `[match]` block so routing + can pick them automatically (e.g. `file_patterns = ["*.qml"]`). +- A new OpenAI-compatible provider is TOML-only: add a provider instance file + in `sources/providersConfig/`, then a concrete agent that `extends` + `OpenAI Base Chat` and overrides `provider_instance`. A genuinely new + request/response *format* (a new wire base) is the only thing that needs C++. + +## Troubleshooting + +- **Agent missing from the list** — check General Messages for `[Agents] + error:` lines; the file failed to parse, resolve, or validate. +- **`… has the same name as a bundled agent — bundled agents cannot be + replaced`** — pick a different `name`; use `extends` to inherit from the + bundled agent instead. +- **`Unknown key 'x' … ignored (typo?)`** — the key isn't part of the + schema; compare with the table above. +- **`Agent 'X' extends unknown agent 'Y'`** — the parent's `name` (not file + name) must match exactly; the parent must be bundled or in the same + directory. +- **`[body] failed to render to valid JSON`** — the dry run failed; the log + contains the rendered snippet. Usually a missing `tojson(...)` around an + interpolated string. +- **Edits not picked up** — agents are loaded at startup; restart + Qt Creator. diff --git a/docs/file-context.md b/docs/file-context.md index 04892b8..84c4506 100644 --- a/docs/file-context.md +++ b/docs/file-context.md @@ -5,8 +5,10 @@ QodeAssist provides two powerful ways to include source code files in your chat ## Attached Files Attachments are designed for one-time code analysis and specific queries: -- Files are included only in the current message -- Content is discarded after the message is processed +- Files are sent as part of the current message +- The content is a snapshot taken at send time: it is stored with the chat + and stays in the conversation history exactly as sent, even if the file + changes on disk later - Ideal for: - Getting specific feedback on code changes - Code review requests @@ -20,8 +22,11 @@ Attachments are designed for one-time code analysis and specific queries: Linked files provide persistent context throughout the conversation: - Files remain accessible for the entire chat session -- Content is included in every message exchange -- Files are automatically refreshed - always using latest content from disk +- Files are automatically refreshed — every request re-reads them and sends + the latest content from disk +- The snapshot travels next to your latest message and is never duplicated + into the conversation history, so linked files do not bloat the chat as it + grows - Perfect for: - Long-term refactoring discussions - Complex architectural changes diff --git a/docs/project-rules.md b/docs/project-rules.md deleted file mode 100644 index b8bf9d3..0000000 --- a/docs/project-rules.md +++ /dev/null @@ -1,35 +0,0 @@ -# Project Rules Configuration - -QodeAssist supports project-specific rules to customize AI behavior for your codebase. Create a `.qodeassist/rules/` directory in your project root. - -## Quick Start - -```bash -mkdir -p .qodeassist/rules/{common,completion,chat,quickrefactor} -``` - -## Directory Structure - -``` -.qodeassist/ -└── rules/ - ├── common/ # Applied to all contexts - ├── completion/ # Code completion only - ├── chat/ # Chat assistant only - └── quickrefactor/ # Quick refactor only -``` - -All `.md` files in each directory are automatically loaded and added to the system prompt. - -## Example - -Create `.qodeassist/rules/common/general.md`: - -```markdown -# Project Guidelines -- Use snake_case for private members -- Prefix interfaces with 'I' -- Always document public APIs -- Prefer Qt containers over STL -``` - diff --git a/docs/quick-refactoring.md b/docs/quick-refactoring.md index 1357936..91e9cc4 100644 --- a/docs/quick-refactoring.md +++ b/docs/quick-refactoring.md @@ -206,7 +206,6 @@ The LLM receives: - **Cursor Position**: Marked with `` tag - **Selection Markers**: `` and `` tags - **Your Instructions**: Built-in, custom, or typed -- **Project Rules**: If configured (see [Project Rules](project-rules.md)) ### Context Configuration @@ -270,7 +269,6 @@ Fully local setup for offline or secure environments. ## Related Documentation -- [Project Rules](project-rules.md) - Project-specific AI behavior customization - [File Context](file-context.md) - Attaching files to chat context - [Ignoring Files](ignoring-files.md) - Exclude files from AI context - [Provider Configuration](../README.md#configuration) - Setting up LLM providers diff --git a/docs/target-architecture.md b/docs/target-architecture.md new file mode 100644 index 0000000..18fb6b9 --- /dev/null +++ b/docs/target-architecture.md @@ -0,0 +1,654 @@ +# QodeAssist — Target Architecture (v1.0) + +Status: design baseline, derived from the fixed use-case inventory below. +Scope: the complete plugin, designed "from scratch" — what the architecture +should be if nothing legacy constrained it. The current code (see +`architecture.md`) already converges on this; §10 lists the remaining deltas. + +--- + +## 1. Use-case inventory (requirements baseline) + +Every architectural decision below is justified by one of these. Features not +on this list (Rules system, legacy provider/model/template pickers, Stack A) +are intentionally out of scope. + +| # | Use case | What the user gets | +|---|----------|--------------------| +| U1 | **Code completion** | Inline FIM/instruct suggestions via LSP; auto + manual trigger, multiline, smart-context suppression, accept full / word-by-word | +| U2 | **Chat assistant** | 4 placements (sidebar, bottom pane, editor tab, floating window); streaming text + thinking blocks + tool blocks + file-edit blocks (apply/undo); attachments, linked files, @-mentions, open-files sync; token counter; persisted history; one-click summarization; runtime agent picker | +| U3 | **Quick refactor** | Selection + instruction by hotkey; custom-instructions library; separate agent; optional tools; streamed result inserted into the editor | +| U4 | **Tools** | read/create/edit file, search, find, list, build, diagnostics, terminal, todo, load_skill; per-tool enable | +| U5 | **Skills** | discovery from `.qodeassist/skills`, `.claude/skills`, `~/.claude/skills`; auto-injection, explicit `/` picker, always-on | +| U6 | **MCP** | server mode (expose plugin tools, HTTP/SSE + stdio bridge) and client hub (consume external tools in chat/refactor) | +| U7 | **Providers** | 13 `client_api` types over one GenericProvider; secrets store; local-server autostart; model listing | +| U8 | **Agents** | TOML profiles: abstract wire-base + thin concrete via `extends`, `[body]` table 1:1 with the wire request (message serialization inlined per base), `match` rules (completion routing), `cache_breakpoints`, per-agent model override, per-pipeline agent selection | +| U9 | **Personas** | persona = the agent's `system_prompt`; shared text lives in plain files pulled in via `read_file` — bundled defaults under `:/roles/…`, or any file the user points at under `${PROJECT_DIR}` / `${CONFIG_DIR}` (your QodeAssist user directory); `read_file` reads the literal path given (no override/fallback resolution); switching persona = switching agent (no separate Roles subsystem) | +| U10 | **Configuration UI** | settings pages for everything above; per-project settings; updater + status widget | + +--- + +## 2. Design principles + +1. **One stack.** Every LLM byte — completion, chat, compression, refactor — + flows through the same `Session` pipeline. No parallel legacy path. +2. **Hexagonal core.** The runtime (agents, sessions, providers, templates, + prompt rendering) has zero Qt Creator dependencies. The IDE host composes + that core; IDE-specific facts enter only through ports (document reading, + project scanning, secrets, tool hosting). +3. **Configuration is declarative, code is mechanism.** What is sent (request + `[body]`, system prompt, endpoint, model) lives in TOML/JSON/Jinja and is + user-overridable; *how* it is sent (streaming, retries, tool loop, event + routing) lives in C++ and is identical for all providers. +4. **Agent-driven behavior.** The agent's TOML declares what a conversation + uses (`enable_tools`, `enable_thinking`); features and UI adapt to the + agent config instead of switching on provider names or provider-declared + capability flags. +5. **Single source of truth for conversation state.** `ConversationHistory` + owns the messages; `ChatModel` and persistence are projections of it, never + independent copies. +6. **Per-feature composition roots, no singletons.** Each feature constructs + and owns its dependencies (`new` + parent); shared services are passed + explicitly (constructor/setter, QML context properties for the chat). +7. **Streaming-first event model.** One typed `ResponseEvent` stream is the + only contract between the core and every consumer. Deltas exist for live + UI (chat); one-shot pipelines (completion, refactor) ignore them, + wait for `finished`, and read the final assistant message from history. +8. **Fail at load, not mid-conversation.** Agent profiles are validated when + loaded (partials resolve, assembled body parses as JSON against a synthetic + context), so a config error never surfaces as a silent runtime drop. + +--- + +## 3. Layered model + +```mermaid +flowchart TB + subgraph HOSTS["Hosts — composition roots"] + PLUGIN["Qt Creator plugin
qodeassist.cpp"] + end + + subgraph L5["L5 · Presentation"] + LSP["LSP bridge
inline suggestions"] + QMLUI["ChatView QML
4 placements"] + RW["Refactor widgets"] + SUI["Settings pages"] + end + + subgraph L4["L4 · Features"] + FCOMP["CompletionFeature"] + FCHAT["ChatFeature"] + FREF["RefactorFeature"] + end + + subgraph L3["L3 · Capabilities"] + CTX["ContextEngine
ports + QtC adapters"] + TOOLS["ToolKit"] + SKILLS["SkillsEngine"] + MCPH["McpHub
client + server"] + end + + subgraph L2["L2 · Core runtime — IDE-independent"] + SM["SessionManager"] + SESS["Session"] + AGF["AgentFactory + AgentRouter"] + AG["Agent"] + PROV["GenericProvider"] + TPL["JsonPromptTemplate"] + end + + subgraph L1["L1 · Declarative config"] + PCONF["providers/*.toml"] + ACONF["agents/*.toml + partials/*.jinja"] + ROST["rosters / pipelines"] + PERS["personas/*.md"] + SKCONF["skills/*.md"] + SEC["SecretsStore"] + end + + subgraph L0["L0 · Wire — LLMQore"] + CLIENTS["*Client — SSE streaming"] + TOOLFW["Tool framework"] + MCPT["MCP transports"] + end + + PLUGIN --> L4 + PLUGIN --> SUI + LSP --> FCOMP + QMLUI --> FCHAT + RW --> FREF + FCOMP --> SM + FCHAT --> SM + FREF --> SM + FCOMP --> CTX + FCHAT --> CTX + FREF --> CTX + FCHAT --> SKILLS + FCHAT --> TOOLS + FREF --> TOOLS + TOOLS --> TOOLFW + MCPH --> MCPT + SM --> SESS + SESS --> AG + AGF --> AG + AG --> PROV + AG --> TPL + AGF --> ACONF + AGF --> PCONF + AGF --> SEC + AGF --> ROST + TPL --> PERS + PROV --> CLIENTS + SKILLS --> SKCONF +``` + +### Layer contracts + +| Layer | Contains | May depend on | Must NOT depend on | +|-------|----------|---------------|--------------------| +| **L0 Wire** | LLMQore clients (one per wire protocol: Claude, OpenAI Chat, OpenAI Responses, Google, Ollama, Mistral, llama.cpp), tool framework, MCP transports | Qt Network | anything above | +| **L1 Config** | `ProviderInstance`, `AgentProfile` (+ loader/validator), rosters, personas, skills, secrets port | toml++, inja | Qt Creator, L2+ | +| **L2 Core** | `Agent`, `AgentFactory`, `AgentRouter`, `Provider`/`GenericProvider`, `JsonPromptTemplate`, `Session`, `SessionManager`, `ConversationHistory`, `SystemPromptBuilder`, `ResponseRouter`, `ToolContributorRegistry` | L0, L1 | Qt Creator, QML, features | +| **L3 Capabilities** | `ContextEngine` (ports + QtC adapters), `ToolKit` (built-in tools), `SkillsEngine`, `McpHub` | L0–L2, QtC APIs *only in adapters* | features, UI | +| **L4 Features** | `CompletionFeature`, `ChatFeature` (send/stream, compression, token counting, file edits), `RefactorFeature` | L2, L3 | each other | +| **L5 Presentation** | LSP bridge, ChatView QML, refactor widgets, settings pages | its feature | core internals | +| **Hosts** | plugin shell | everything (composition only) | — | + +The hard rule that makes testability free: **L0–L2 build into +targets with no Qt Creator linkage.** Tests link L0–L2 directly; +the plugin adds L3 adapters, L4, L5. + +--- + +## 4. Core domain model + +Rendered copy: [core-class-diagram.svg](core-class-diagram.svg) (regenerate +when the diagram below changes). + +```mermaid +classDiagram + direction TB + class SessionManager { + +acquire(agentName) Session + +release(session) + +toolContributors() ToolContributorRegistry + } + class Session { + +send(blocks) + +cancel() + +history() ConversationHistory + +systemPrompt() SystemPromptBuilder + +event(ResponseEvent) + +finished(id, stopReason) + +failed(id, ErrorInfo) + +cancelled(id) + } + class ConversationHistory { + +messages() vector~Message~ + +lastAssistantText() string + +append(Message) + +reset(vector~Message~) + } + class Message { + +role Role + +blocks vector~ContentBlock~ + } + class SystemPromptBuilder { + +setLayer(id, text, priority) + +removeLayer(id) + +compose() string + } + class ResponseRouter { + +attach(BaseClient) + +event(ResponseEvent) + } + class Agent { + +config() AgentConfig + +provider() Provider + +promptTemplate() PromptTemplate + } + class AgentFactory { + +create(name) Agent + +configByName(name) AgentConfig + +effectiveModel(name) string + } + class AgentRouter { + +pickAgent(roster, fileCtx) string + } + class Provider { + <> + +prepareRequest(request, ctx) + +sendRequest(json) RequestID + +cancelRequest(RequestID) + } + class GenericProvider { + -client BaseClient + } + class PromptTemplate { + <> + +buildFullRequest(request, ctx) + } + class JsonPromptTemplate { + -bodySpec QJsonObject + -env InjaEnvironment + } + class ToolContributorRegistry { + +registerContributor(fn) + +applyTo(ToolsManager) + } + + SessionManager o-- Session : pools + SessionManager --> AgentFactory : builds via + SessionManager --> ToolContributorRegistry + Session *-- ConversationHistory + Session *-- SystemPromptBuilder + Session *-- ResponseRouter + Session --> Agent + ConversationHistory o-- Message + Agent *-- Provider + Agent *-- PromptTemplate + AgentFactory ..> Agent : creates + AgentFactory --> AgentRouter + GenericProvider --|> Provider + JsonPromptTemplate --|> PromptTemplate +``` + +Responsibilities, one line each: + +- **Agent** — immutable bundle of *what to call*: resolved config + provider + + compiled prompt template. No request state. +- **Session** — one conversation's runtime: owns history, system-prompt + layers, pinned context providers, response routing, the in-flight request, + and the content of each dispatched request (tool continuations replay it + inside LLMQore; see `context-architecture.md` §4.3). + `send(blocks)` is the *only* entry point: every pipeline appends a user + message and dispatches; there are no per-pipeline send variants. What + differs between completion, chat, and refactor is the agent's template and + the consumption mode (deltas vs final message), never the Session API. +- **SessionManager** — creates/pools sessions per agent; the single place + features go to get one. Pooling (not per-message construction) covers the + "fresh agent + provider + secrets read per request" latency cost. It reuses + only the expensive parts (agent, provider, compiled template, secrets read): + `acquire` hands out a session with cleared history and system-prompt + layers, so one-shot pipelines never see a previous exchange. +- **AgentRouter** — the agent picker for *auto-routed* pipelines. Only code + completion routes by context: `pickAgent(roster.codeCompletion, {file, + project})` walks the ordered roster and returns the first agent whose match + rules fit. Chat is user-driven (the picker filters to the `chatAssistant` + allow-list; the user chooses); compression and quick refactor each use a + single configured agent. No feature-local routing logic beyond these. +- **GenericProvider** — one class for all 13 client APIs; varies only by + LLMQore client factory + metadata. Request *shape* belongs to the template, + never to the provider. +- **JsonPromptTemplate** — compiles the agent's `[body]` table; renders + Jinja-bearing string values, splices raw JSON, drops empty keys; validated + at load time. +- **SystemPromptBuilder** — ordered named layers (`agent.system`, + `chat.context`, `refactor`, `compression`); features mutate only their own + layer. +- **ResponseRouter / ResponseEvent** — adapts LLMQore client signals into one + typed stream: `TextDelta`, `ThinkingDelta`, `ToolCallStart/End`, + `ToolResult`, `Usage`, `Error`, `MessageStop`. +- **ToolContributorRegistry** — contributors (built-in ToolKit, SkillTool, + McpHub) register once; `SessionManager` applies them to every new session's + `ToolsManager`. This is how MCP tools reach chat *and* refactor (U6) without + feature code knowing about MCP. + +--- + +## 5. Runtime flows + +### 5.1 Chat (U2) — the richest path + +```mermaid +sequenceDiagram + autonumber + actor U as User + participant V as ChatView QML + participant F as ChatFeature + participant SM as SessionManager + participant S as Session + participant T as JsonPromptTemplate + participant P as GenericProvider + participant C as LLMQore Client + participant R as ResponseRouter + + U->>V: message + attachments + V->>F: sendMessage(text, files, images) + F->>SM: acquire(activeAgent) + SM-->>F: Session (pooled) + F->>S: systemPrompt().setLayer("chat.context", project + skills + linked files) + F->>S: send(userBlocks) + S->>T: buildFullRequest(history, system, ctx) + T-->>S: request JSON (body is 1:1 with the API) + S->>P: sendRequest(json) + P->>C: HTTP POST, SSE stream + loop streaming + C-->>R: chunk / thinking / tool_use / usage + R-->>S: ResponseEvent + S-->>F: event(ResponseEvent) + F-->>V: ChatModel projection update + end + opt tool call requested + S->>S: execute tool via ToolsManager + S->>P: continue with tool_result + end + C-->>R: finalized + R-->>S: MessageStop + Usage + S-->>F: finished() + F->>SM: release(session) +``` + +State ownership in chat: `Session.history()` is the truth. `ChatModel` is a +QML projection built from history events (`messageAdded`, `messageUpdated`); +`ChatSerializer`/`ChatHistoryStore` persist *history*, and restoring a chat +seeds a new session's history — never the other way around. File-edit blocks, +apply/undo, and the token counter are ChatFeature concerns layered on the +event stream. + +### 5.2 Completion (U1) + +``` +LSP getCompletionsCycling + → CompletionFeature + agent = AgentRouter.pickAgent(roster.codeCompletion, {file, project}) + session = SessionManager.acquire(agent) + ctx = ContextEngine: prefix/suffix + open-files context (policy from + CodeCompletionSettings — editor policy, not agent config) + session.send(blocks{completion context}) + on finished → history().lastAssistantText() + → CodeHandler (output-mode post-processing) → LSP items +``` + +No special Session method: the completion context travels as the content of +an ordinary user message (a structured block carrying prefix/suffix + file +context), and the template context exposes it as `ctx.prefix` / `ctx.suffix`. +FIM vs instruct is *agent config* (template + body), not feature code: a FIM +agent's body renders `prefix`/`suffix` into FIM fields; an instruct agent's +body renders the same exchange as a chat-shaped request. The feature is +identical for both — and since completion has no incremental UI, it never +touches the delta stream: it waits for `finished` and reads the last message. + +### 5.3 Quick refactor (U3) + +``` +Hotkey → RefactorFeature + agent = pipelines.quickRefactor (single configured agent) + session = SessionManager.acquire(agent) + session.systemPrompt().setLayer("refactor", tagged selection + output rules) + session.send(blocks{instruction}) + on finished → history().lastAssistantText() + → ResponseCleaner → RefactorResult → editor insert (accept/reject) +``` + +Same consumption mode as completion: the feature listens to +`Session::finished`/`failed` only (events at most drive a progress spinner +and cancel) and reads the result from history — it never connects to raw +client signals. Tool calls during refactor run inside the session's tool +loop; history's last assistant message is whatever the model produced after +the final tool round. + +### 5.4 Compression (U2) + +Compression is ChatFeature reusing the same path with the single +`pipelines.chatCompression` agent and a `"compression"` system layer; the +summary starts a new history. + +--- + +## 6. Configuration model + +```mermaid +erDiagram + AGENT_PROFILE ||--o| AGENT_PROFILE : extends + AGENT_PROFILE }o--|| PROVIDER_INSTANCE : provider_instance + AGENT_PROFILE }o--o{ PARTIAL : includes + AGENT_PROFILE }o--o{ PERSONA : read_file + ROSTER }o--o{ AGENT_PROFILE : ranks + MODEL_OVERRIDE |o--|| AGENT_PROFILE : overrides_model + PROVIDER_INSTANCE }o--|| CLIENT_API : client_api + PROVIDER_INSTANCE }o--o| SECRET : api_key_ref + PROVIDER_INSTANCE ||--o| LAUNCH_CONFIG : autostarts + + AGENT_PROFILE { + string name + bool abstract + string system_prompt "jinja; inline text or read_file()" + json body "request body, 1:1 with API" + string endpoint "may contain MODEL placeholder" + string model "default; override wins" + bool enable_tools "capability hint" + bool enable_thinking "capability hint" + json match "file, path, project patterns" + } + PROVIDER_INSTANCE { + string name + string client_api + string url + string api_key_ref + } + PERSONA { + string path "plain markdown file" + } + ROSTER { + string pipeline "completion, chat, compression, refactor" + list agents "ordered candidates" + } +``` + +Rules of the config layer (full spec: `agent-templates-design.md`): + +- `[body]` **is** the request body — field-by-field, deep-mergeable through + `extends`; Jinja-bearing strings render and splice as raw JSON, literals + pass through. No separate sampling/thinking merge machinery. +- Message serialization is inlined in each abstract **wire base**; there are no + bundled partials. `{% include %}` still resolves sandboxed roots (bundled + `:/agents/`, then the user agent's dir) for user-supplied partials; a missing + partial is a load-time error. +- Two-level hierarchy: an abstract **wire base** per provider (provider + + endpoint + serialization only — no model/persona/tags/sampling) and a thin + concrete agent carrying all policy. +- Per-agent model override lives in `agent_models.json` and is applied by + `AgentFactory`; `${MODEL}` in `endpoint` covers URL-model providers. +- Personas are not a subsystem: the profile's `system_prompt` is the persona. + Shared text lives in plain markdown under the sandboxed roots and is pulled + in with `{{ read_file(...) }}`; a persona-switch is an agent-switch — the + only system-prompt edit point is the profile. +- Secrets never appear in TOML; `api_key_ref` resolves through the + `SecretsStore` port (QtC keychain in the plugin). + +--- + +## 7. Capabilities layer + +**ContextEngine** replaces the monolithic ContextManager with three focused +services behind IDE-agnostic ports: + +| Service | Port (L2-visible) | QtC adapter | +|---------|-------------------|-------------| +| `EditorContext` — current doc, selection, prefix/suffix | `IDocumentReader` | TextEditor API | +| `ProjectContext` — root, file listing, ignore filtering (`.qodeassistignore`), open files, changes | `IProjectScanner` | ProjectExplorer API | +| `TokenEstimator` — input estimates, calibrated by server usage | — (pure) | — | + +**ToolKit** registers the built-in tools (U4) with the +`ToolContributorRegistry`; each tool declares a permission class (read / +write / execute) so per-tool enablement (settings) and confirmation policy +(terminal commands) live in one place. + +**SkillsEngine** (U5): discovery + watching of the three skill roots; exposes +`catalogText()` (names + descriptions for the system prompt), +`alwaysOnBodies()`, and the `load_skill` tool; the `/` picker injects a +skill's body into a single message. + +**McpHub** (U6): client side connects configured servers and contributes +their tools through the same registry (tools reach every session uniformly); +server side exposes ToolKit over HTTP/SSE + stdio bridge. + +--- + +## 8. Cross-cutting policies + +Architecture is the rules as much as the boxes. These policies bind every +layer and are part of the contract: + +### 8.1 Threading + +The core runs on the GUI thread; concurrency is the Qt event loop plus async +network I/O — no shared-state threading anywhere in L1–L4. Work that can +block (project scans, token estimation over large trees) hides behind L3 +ports; an adapter may use worker threads internally but delivers results as +queued signals. Core types are therefore deliberately not thread-safe. + +### 8.2 Request lifecycle + +A session has at most one in-flight request; `send()` while in flight cancels +the previous request first. Every request terminates in exactly one of three +states — `finished(stopReason)`, `failed(error)`, `cancelled()` — and +cancellation is *not* an error: no consumer may string-match a message to +tell them apart. + +### 8.3 Errors + +Runtime errors are typed, not strings: `ErrorInfo { category, message, +providerDetail }` with categories `Config | Auth | Network | Provider | +Validation | Tool`. The category drives UI affordances (Auth → open provider +settings, Network → offer retry); free text is for logs only. Load-time +errors (principle 8) surface in the agents settings page, never as a failed +send. + +### 8.4 Timeouts and retries + +Transfer timeouts are per-pipeline policy (completion short, chat/refactor +from settings), applied by the feature — never baked into agent profiles. A +streaming request is never silently retried after the first byte; automatic +retry with capped backoff is allowed only for connection-phase failures. +Anything beyond that is an explicit user action. + +### 8.5 Observability + +One `RequestID` correlates feature → session → provider → client → events → +logs. Each layer logs under its own category (`qodeassist.session`, +`qodeassist.provider`, `qodeassist.tools`, …); request bodies are logged only +at debug level, and secrets are redacted unconditionally. `Usage` events are +the single source feeding the token counter, `TokenEstimator` calibration, +and the performance log. + +### 8.6 Config compatibility + +Agent profiles carry a `schema_version`; the loader migrates old user +configs forward or rejects them with an actionable message — silent +reinterpretation is forbidden. Bundled profiles are read-only resources that +user profiles shadow by name. Persisted chat history is versioned the same +way. + +### 8.7 Security + +Secrets exist only behind the `SecretsStore` port; they never reach TOML, +logs, or persisted chats. Tool permission classes (read / write / execute) +centralize the confirmation policy. The MCP server is opt-in and binds +loopback by default; skill and partial roots are sandboxed — nothing resolves +outside its declared directory. + +### 8.8 Testing + +The test pyramid follows the layers: + +| Layer | Strategy | +|-------|----------| +| L1 | loader/validator unit tests; golden-file snapshots of every bundled profile's rendered body against a synthetic context — the same check as load-time validation, run in CI | +| L2 | `Session` / `ResponseRouter` replay tests over recorded SSE fixtures per provider; fake `BaseClient`, no network | +| L3 | contract tests against the ports; QtC adapters covered only by plugin integration | + +Layering is enforced mechanically, not by review: each layer is its own +CMake target, and the core targets do not link Qt Creator — a violating +include fails the build. + +--- + +## 9. Module / target layout + +``` +core/ # no Qt Creator linkage — tests link this + config/ # L1: ProviderInstance, AgentProfile, loaders, + # validators, rosters, personas, secrets port + providers/ # L2: Provider, GenericProvider, ProviderFactory, + # ClaudeCacheControl + prompt/ # L2: JsonPromptTemplate, ContextRenderer, partials + agents/ # L2: Agent, AgentFactory, AgentRouter + session/ # L2: Session, SessionManager, ConversationHistory, + # SystemPromptBuilder, ResponseRouter, events + skills/ # L3 (IDE-free part): SkillsEngine, loaders +ide/ # Qt Creator adapters only + context/ # EditorContext, ProjectContext adapters, ignore + tools/ # built-in ToolKit (build, issues, editor edits…) + mcp/ # McpHub managers +features/ + completion/ # LSP bridge + CompletionFeature + CodeHandler + chat/ # ChatFeature: ClientInterface, ChatModel(projection), + # Compressor, TokenCounter, FileEditController, + # serializer/store + refactor/ # RefactorFeature + custom instructions +ui/ + ChatView qml/, widgets/, settings pages +hosts/ + plugin/ # qodeassist.cpp — composition root, actions, panes +tests/ + config/ # loader cases + golden rendered-body snapshots + session/ # SSE replay fixtures per provider, fake client +external/ + llmqore/ inja/ tomlplusplus/ +``` + +Dependency direction is strictly downward in the table of §3; `features/*` +never include each other; `ui/*` talks only to its feature; `hosts/*` are the +only places allowed to know about everything. + +--- + +## 10. Deltas from the current working tree + +What "from scratch" changes relative to today's code — the migration +checklist to call the architecture done: + +1. **Stack A physical teardown** — delete root `providers/*`, + `pluginllmcore/*`, `ConfigurationManager`, legacy provider/model/template + settings pages, and the Stack A registration + MCP loop in + `qodeassist.cpp`. Runtime already has no consumers. +2. **Single history owner** — make `ChatModel` a projection of + `Session::history()` (subscribe to history signals) instead of a parallel + message store with seed-on-send; `ChatCompressor` reads history, not the + model. +3. **Single send path** — delete `Session::sendCompletion(ContextData)`; + the completion context becomes user-message content sent through the one + `send()` (the completion handler already reads its result from history's + last message). Move `QuickRefactorHandler` off raw `BaseClient` signals + (`requestCompleted`/`requestFinalized`/`requestFailed`) onto + `Session::finished`/`failed` + `history().lastAssistantText()`. +4. **Three-state request lifecycle** — add `cancelled` to `Session`; today + `cancel()` emits `failed(id, "Cancelled by user")` and consumers must + string-match to tell cancellation from failure (§8.2). +5. **Typed errors** — replace `lastError` strings and the `failed(QString)` + payload with `ErrorInfo` categories (§8.3). +6. **Agent selection by pipeline shape** — completion is the only context-routed + pipeline (`AgentRouter.pickAgent(roster.codeCompletion, {file, project})`); + chat picker filters to the `chatAssistant` allow-list; quick refactor and + compression each read a single configured agent (no routing). +7. **MCP tools on session clients** — register MCP-contributed tools through + `ToolContributorRegistry` so chat/refactor sessions get them (today they + are registered only on dead Stack A providers). +8. **Session pooling** — `SessionManager.acquire/release` with a small pool + per agent, replacing per-message agent + provider + secrets construction. +9. **ContextManager split** — extract `EditorContext` / `ProjectContext` / + `TokenEstimator` behind ports; move QtC API use into `ide/context`. +10. **`[body]` model completion** — finish `agent-templates-design.md` + (body-table rendering, sandboxed `include`, load-time validation, model + override + `${MODEL}`, `schema_version` gate), delete sampling/thinking + merge machinery. +11. **Message type unification** — one `Message`/`ContentBlock` shape from + history to QML (roles, text, thinking, tool use/result, images); delete + the parallel `ChatModel::Message` struct. +12. **Test scaffolding** — golden rendered-body snapshots + SSE replay + fixtures (§8.8); CI builds the core targets without Qt Creator so a + layering violation fails the build. +13. **Stale docs cleanup** — `project-rules.md` describes the removed Rules + system; mark or delete. diff --git a/docs/tool-loop-runner-plan.md b/docs/tool-loop-runner-plan.md new file mode 100644 index 0000000..e27d397 --- /dev/null +++ b/docs/tool-loop-runner-plan.md @@ -0,0 +1,192 @@ +# ToolLoopRunner — implementation plan + +Status: plan for "variant C" (2026-06-13). Supersedes step 5 of +`context-architecture.md` §6. + +Context that shapes this plan: +- The tool loop STAYS in LLMQore — the library remains a complete standalone + agentic client. Variant C changes its *shape*, not its home: the loop + becomes a named class, `BaseClient` slims toward transport. +- 2026-06-12 the variant-A hook (`setContinuationPayloadBuilder` + Session + feeding assembler-built continuation bodies) was implemented and then + REVERTED by the project owner: the frozen-replay problem was judged + contrived (replay carries the full filtered history of its base payload; + mid-loop file changes reach the model via tool results; growth is bounded + by `maxToolContinuations`). The reverted llmqore diff is saved at + `/tmp/llmqore-continuation-builder.patch`. +- Therefore this plan has two tracks. **Track 1 (the actual ask): the + structural refactoring.** Track 2 (host payload source) is OPTIONAL, + parked, and only happens if the 2026-06-12 verdict is explicitly reversed. +- The context-architecture steps 1–4 implementation (ContextAssembler, + content cache, pinned providers, EnvBlockFormatter, ~1200 lines incl. + tests) is parked in `stash@{0}` ("new context refactor") on + `dev-release-1-0`. It is NOT required for track 1. + +--- + +## 1. Current anatomy (llmqore @ 0348ac8) + +- `BaseClient` mixes two responsibilities: + - **transport** — HTTP/SSE per request, `ActiveRequest { stream, buffers, + url, mode, usage, … }`, accumulation in protocol subclasses; + - **loop policy** — `ActiveRequest.originalPayload`, + `ActiveRequest.continuationCount`, `m_maxToolContinuations`, + `checkContinuationLimit`, `handleToolContinuation`. +- Loop entry: protocol clients call `executeToolsFromMessage(id)` at their + message-end detection points (11 call sites across 7 clients); it forwards + `tool_use` blocks to `ToolsManager::executeToolCall`. +- `BaseClient::tools()` wires `ToolsManager::toolExecutionComplete(id, + results)` → `handleToolContinuation`: round-limit check → continuation + body via the protocol-virtual `buildContinuationPayload(originalPayload, + message, toolResults)` → `finalizeTurn` → `sendRequest(id, storedUrl, + payload, storedMode)`. + +## 2. Target design + +### 2.1 ToolLoopRunner (new, llmqore) + +```cpp +class LLMQORE_EXPORT ToolLoopRunner : public QObject +{ + Q_OBJECT +public: + explicit ToolLoopRunner(BaseClient *client); + + int maxRounds() const noexcept; + void setMaxRounds(int limit) noexcept; + +private: + void onToolsCompleted(const RequestID &id, + const QHash &results); + void onRequestClosed(const RequestID &id); + + struct LoopState + { + int rounds = 0; + }; + + BaseClient *m_client = nullptr; + QHash m_loops; + int m_maxRounds = 10; +}; +``` + +The whole loop policy on one screen: + +```cpp +void ToolLoopRunner::onToolsCompleted(const RequestID &id, + const QHash &results) +{ + auto &loop = m_loops[id]; + if (++loop.rounds > m_maxRounds) { + m_client->abortRequest(id, "Tool continuation limit reached"); + m_loops.remove(id); + return; + } + + const QJsonObject payload = m_client->buildReplayContinuation(id, results); + if (payload.isEmpty()) { + m_client->abortRequest(id, "Failed to build continuation payload"); + m_loops.remove(id); + return; + } + + m_client->continueRequest(id, payload); +} +``` + +- `LoopState` is keyed by request id — several concurrent requests on one + client (two chat panels on one provider) never collide. +- Cleanup: `onRequestClosed` (connected to `requestFailed` + + `requestFinalized`) drops the state. + +### 2.2 BaseClient becomes transport + tool dispatch + +Gains (transport primitives; `continueRequest` public — it is also the seam +any future host-driven mode would use; failure path via runner friendship): + +```cpp +ToolLoopRunner *toolLoop(); // owned, created with tools() +void continueRequest(const RequestID &id, const QJsonObject &payload); + // finalizeTurn + resend stored url/mode +QJsonObject buildReplayContinuation(const RequestID &id, + const QHash &results); + // originalPayload + protocol virtual +``` + +Loses (moves to the runner): `handleToolContinuation`, +`checkContinuationLimit`, `m_maxToolContinuations`, +`ActiveRequest::continuationCount`. The `toolExecutionComplete` connection +in `tools()` retargets to the runner. + +Keeps: `executeToolsFromMessage` (the 11 protocol call sites stay +untouched), the protocol-virtual `buildContinuationPayload` (it IS the +replay serialization), `originalPayload` storage, +`setMaxToolContinuations`/`maxToolContinuations` as thin forwarders to +`toolLoop()` — existing consumers (QodeAssist `ClientInterface`, +`QuickRefactorHandler`, third parties) compile unchanged. + +## 3. Track 1 — structural refactoring (the plan) + +Bit-identical behavior throughout; QodeAssist only needs a submodule bump. + +**Phase 1 — transport primitives.** Add `continueRequest` + +`buildReplayContinuation` + public `abortRequest` (now also the body of +`cancelRequest`). — DONE 2026-06-13. + +**Phase 2 — extract the runner.** New `ToolLoopRunner` class; move round +state + limit; retarget the `toolExecutionComplete` connection; delete +`handleToolContinuation` / `checkContinuationLimit` / +`ActiveRequest::continuationCount`; forwarders for +`setMaxToolContinuations`. — DONE 2026-06-13 +(`include/LLMQore/ToolLoopRunner.hpp`, `source/core/ToolLoopRunner.cpp`, +`tests/tst_ToolLoopRunner.cpp` — 7 cases: replay flow, round limit, missing +replay data, two interleaved ids, cleanup on finalize/cancel, forwarders; +`continueRequest` is virtual as the test seam; llmqore architecture docs +updated: overview, request-lifecycle diagram, tools). + +Deliberate behavior delta (an improvement, worth knowing while testing): an +empty payload from the protocol's `buildContinuationPayload` now aborts the +request with "Missing data for tool continuation" instead of silently +sending an empty body. + +**Phase 3 — submodule bump (after the user runs llmqore tests).** +QodeAssist: bump the submodule pointer, verify live in the plugin (Ollama + +tools, Claude + tools); update `context-architecture.md` +§4.3/§6.5 to point here; update project memory. + +## 4. Track 2 — host payload source (PARKED) + +Only if the 2026-06-12 "проблема надумана" verdict is explicitly reversed. +Variant C makes it a ~40-line addition, so nothing is lost by parking: + +- `ToolLoopRunner::setPayloadSource(id, std::function)`; registered source is authoritative for its id (empty + result → abort, never silent fallback to replay). +- Host prerequisite: restore the context work from `stash@{0}` + (ContextAssembler + `Session::makePayload`); expect conflicts in + `Session.cpp` with the newer `dev-release-1-0` refactor commits + ("Remove override tools in Session send" etc.). +- Session registers the source after `provider->sendRequest` (same-thread, + race-free; `QPointer` guard). +- Assembler continuation rules: pinned blocks anchor to the turn's TYPED + user message (recorded 2026-06-12), manifest per round. + +## 5. Risks + +| Risk | Mitigation | +|---|---| +| Behavior drift while moving the loop | phases are mechanical; same `buildContinuationPayload` virtuals; llmqore tests + plugin smoke before/after | +| Two sessions, one client | `LoopState` keyed by request id | +| Qt 5 compatibility (0348ac8) | runner uses only signals/`QHash`/`std::function` — no Qt 6-only API | +| Cancel mid-tool-execution | unchanged: `cancelRequest` → `failRequest` → `onRequestClosed` clears state; `ToolsManager::cleanupRequest` handles in-flight tools | +| Google (model in URL) | `continueRequest` reuses stored per-request url/mode — same as today | + +## 6. Deliberately not doing + +- Not moving the loop or tool execution out of llmqore + (`feedback_llmqore_boundary`). +- Not touching the 11 `executeToolsFromMessage` call sites or the protocol + `buildContinuationPayload` implementations. +- No Auto/Manual mode flags. +- Track 2 is not started without an explicit decision. diff --git a/logger/Logger.cpp b/logger/Logger.cpp index 51ea5b3..f92b98c 100644 --- a/logger/Logger.cpp +++ b/logger/Logger.cpp @@ -22,11 +22,6 @@ void Logger::setLoggingEnabled(bool enable) m_loggingEnabled = enable; } -bool Logger::isLoggingEnabled() const -{ - return m_loggingEnabled; -} - void Logger::log(const QString &message, bool silent) { if (!m_loggingEnabled) @@ -39,21 +34,4 @@ void Logger::log(const QString &message, bool silent) Core::MessageManager::writeFlashing(prefixedMessage); } } - -void Logger::logMessages(const QStringList &messages, bool silent) -{ - if (!m_loggingEnabled) - return; - - QStringList prefixedMessages; - for (const QString &message : messages) { - prefixedMessages << (QLatin1String("[QodeAssist] ") + message); - } - - if (silent) { - Core::MessageManager::writeSilently(prefixedMessages); - } else { - Core::MessageManager::writeFlashing(prefixedMessages); - } -} } // namespace QodeAssist diff --git a/logger/Logger.hpp b/logger/Logger.hpp index 8e787df..6536b10 100644 --- a/logger/Logger.hpp +++ b/logger/Logger.hpp @@ -17,10 +17,8 @@ public: static Logger &instance(); void setLoggingEnabled(bool enable); - bool isLoggingEnabled() const; void log(const QString &message, bool silent = true); - void logMessages(const QStringList &messages, bool silent = true); private: Logger(); @@ -32,6 +30,5 @@ private: }; #define LOG_MESSAGE(msg) QodeAssist::Logger::instance().log(msg) -#define LOG_MESSAGES(msgs) QodeAssist::Logger::instance().logMessages(msgs) } // namespace QodeAssist diff --git a/mcp/McpClientsManager.cpp b/mcp/McpClientsManager.cpp index 0c9975a..88dd99b 100644 --- a/mcp/McpClientsManager.cpp +++ b/mcp/McpClientsManager.cpp @@ -15,9 +15,8 @@ #include #include +#include #include -#include -#include #include namespace QodeAssist::Mcp { @@ -176,18 +175,14 @@ QList McpClientsManager::connections() const return m_connections; } -QList McpClientsManager::toolsCapableProviders() const +void McpClientsManager::registerToolsOn(::LLMQore::ToolsManager *tools) const { - QList out; - auto &pm = PluginLLMCore::ProvidersManager::instance(); - for (const QString &name : pm.providersNames()) { - auto *p = pm.getProviderByName(name); - if (!p) - continue; - if (p->capabilities().testFlag(PluginLLMCore::ProviderCapability::Tools)) - out.append(p); + if (!tools) + return; + for (auto *c : m_connections) { + if (c) + c->registerToolsOn(tools); } - return out; } QJsonObject McpClientsManager::builtinServers() @@ -319,8 +314,6 @@ void McpClientsManager::loadFromDisk() newConfigs.append(McpServerConfig::fromJson(it.key(), it.value().toObject())); } - const auto providers = toolsCapableProviders(); - const bool masterEnabled = Settings::mcpSettings().enableMcpClients(); QList keep; @@ -350,7 +343,6 @@ void McpClientsManager::loadFromDisk() existing->deleteLater(); } c = new McpServerConnection(cfg, this); - c->setProviders(providers); connect( c, &McpServerConnection::stateChanged, diff --git a/mcp/McpClientsManager.hpp b/mcp/McpClientsManager.hpp index 31aa079..4f32da5 100644 --- a/mcp/McpClientsManager.hpp +++ b/mcp/McpClientsManager.hpp @@ -35,6 +35,8 @@ public: bool removeServer(const QString &name); void reload(); + void registerToolsOn(::LLMQore::ToolsManager *tools) const; + signals: void serversChanged(); void writeFailed(const QString &reason); @@ -50,7 +52,6 @@ private: void setupWatcher(); void updateWatchedPaths(); - QList toolsCapableProviders() const; static QJsonObject builtinServers(); QJsonObject readRoot() const; bool writeRoot(const QJsonObject &root); diff --git a/mcp/McpServerConnection.cpp b/mcp/McpServerConnection.cpp index 736cad7..766e8de 100644 --- a/mcp/McpServerConnection.cpp +++ b/mcp/McpServerConnection.cpp @@ -23,7 +23,6 @@ #include #include -#include #include namespace QodeAssist::Mcp { @@ -35,13 +34,6 @@ QString transportToString(McpTransportKind k) return k == McpTransportKind::Http ? QStringLiteral("http") : QStringLiteral("stdio"); } -bool providerSupportsTools(PluginLLMCore::Provider *p) -{ - if (!p) - return false; - return p->capabilities().testFlag(PluginLLMCore::ProviderCapability::Tools); -} - } // namespace McpServerConfig McpServerConfig::fromJson(const QString &name, const QJsonObject &obj) @@ -133,15 +125,6 @@ McpServerConnection::~McpServerConnection() disconnectFromServer(); } -void McpServerConnection::setProviders(const QList &providers) -{ - m_providers.clear(); - for (auto *p : providers) { - if (providerSupportsTools(p)) - m_providers.append(p); - } -} - ::LLMQore::Mcp::McpTransport *McpServerConnection::createTransport() { if (m_config.transport == McpTransportKind::Http) { @@ -293,40 +276,20 @@ void McpServerConnection::fetchAndRegisterTools() [this](const QList<::LLMQore::Mcp::ToolInfo> &tools) { if (m_listToolsWatchdog) m_listToolsWatchdog->stop(); - if (m_providers.isEmpty()) { - LOG_MESSAGE(QString("MCP client [%1]: no tools-capable providers to " - "register %2 tools into") - .arg(m_config.name) - .arg(tools.size())); - setState( - McpConnectionState::Connected, - QStringLiteral("Connected (%1 tools)").arg(tools.size())); - return; - } + m_tools.clear(); for (const auto &info : tools) { if (info.name.isEmpty()) continue; - m_toolIds.append(info.name); - for (const auto &p : m_providers) { - if (!p) - continue; - auto *tm = p->toolsManager(); - if (!tm) - continue; - auto *remote = new ::LLMQore::Mcp::McpRemoteTool( - m_client.data(), info, tm); - tm->addTool(remote); - } + m_tools.append(info); } - LOG_MESSAGE(QString("MCP client [%1]: registered %2 tools across %3 providers") + LOG_MESSAGE(QString("MCP client [%1]: discovered %2 tools") .arg(m_config.name) - .arg(tools.size()) - .arg(m_providers.size())); + .arg(m_tools.size())); setState( McpConnectionState::Connected, - QStringLiteral("Connected (%1 tools)").arg(tools.size())); + QStringLiteral("Connected (%1 tools)").arg(m_tools.size())); }) .onFailed(this, [this](const std::exception &e) { if (m_listToolsWatchdog) @@ -337,21 +300,19 @@ void McpServerConnection::fetchAndRegisterTools() }); } +void McpServerConnection::registerToolsOn(::LLMQore::ToolsManager *tools) +{ + if (!tools || !m_client || m_state != McpConnectionState::Connected) + return; + for (const auto &info : m_tools) { + auto *remote = new ::LLMQore::Mcp::McpRemoteTool(m_client.data(), info, tools); + tools->addTool(remote); + } +} + void McpServerConnection::unregisterTools() { - if (m_toolIds.isEmpty()) - return; - - for (const auto &p : m_providers) { - if (!p) - continue; - auto *tm = p->toolsManager(); - if (!tm) - continue; - for (const QString &id : m_toolIds) - tm->removeTool(id); - } - m_toolIds.clear(); + m_tools.clear(); } void McpServerConnection::disconnectFromServer() diff --git a/mcp/McpServerConnection.hpp b/mcp/McpServerConnection.hpp index 4b31adc..dc52956 100644 --- a/mcp/McpServerConnection.hpp +++ b/mcp/McpServerConnection.hpp @@ -14,15 +14,17 @@ #include #include +#include + +namespace LLMQore { +class ToolsManager; +} + namespace LLMQore::Mcp { class McpClient; class McpTransport; } // namespace LLMQore::Mcp -namespace QodeAssist::PluginLLMCore { -class Provider; -} - namespace QodeAssist::Mcp { enum class McpTransportKind { Http, Stdio }; @@ -61,10 +63,16 @@ public: const McpServerConfig &config() const { return m_config; } McpConnectionState state() const { return m_state; } QString statusText() const { return m_statusText; } - int toolCount() const { return m_toolIds.size(); } - QStringList toolNames() const { return m_toolIds; } + QStringList toolNames() const + { + QStringList names; + names.reserve(m_tools.size()); + for (const auto &tool : m_tools) + names << tool.name; + return names; + } - void setProviders(const QList &providers); + void registerToolsOn(::LLMQore::ToolsManager *tools); void connectToServer(); void disconnectFromServer(); @@ -75,7 +83,6 @@ signals: private: void setState(McpConnectionState state, const QString &text = {}); void fetchAndRegisterTools(); - void registerTools(const QList<::LLMQore::Mcp::McpClient *> & /*unused*/); void unregisterTools(); ::LLMQore::Mcp::McpTransport *createTransport(); @@ -87,8 +94,7 @@ private: QPointer<::LLMQore::Mcp::McpTransport> m_transport; QPointer m_listToolsWatchdog; - QList> m_providers; - QStringList m_toolIds; + QList<::LLMQore::Mcp::ToolInfo> m_tools; }; } // namespace QodeAssist::Mcp diff --git a/pluginllmcore/CMakeLists.txt b/pluginllmcore/CMakeLists.txt deleted file mode 100644 index 55a85d8..0000000 --- a/pluginllmcore/CMakeLists.txt +++ /dev/null @@ -1,29 +0,0 @@ -add_library(PluginLLMCore STATIC - RequestType.hpp - Provider.hpp Provider.cpp - ProvidersManager.hpp ProvidersManager.cpp - ContextData.hpp - IPromptProvider.hpp - IProviderRegistry.hpp - PromptProviderChat.hpp - PromptProviderFim.hpp - PromptTemplate.hpp - PromptTemplateManager.hpp PromptTemplateManager.cpp - ProviderID.hpp - RulesLoader.hpp RulesLoader.cpp - ResponseCleaner.hpp -) - -target_link_libraries(PluginLLMCore - PUBLIC - Qt::Core - Qt::Network - QtCreator::Core - QtCreator::Utils - QtCreator::ExtensionSystem - LLMQore - PRIVATE - QodeAssistLogger -) - -target_include_directories(PluginLLMCore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/pluginllmcore/ContextData.hpp b/pluginllmcore/ContextData.hpp deleted file mode 100644 index 11fea58..0000000 --- a/pluginllmcore/ContextData.hpp +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include - -namespace QodeAssist::PluginLLMCore { - -struct ImageAttachment -{ - QString data; // Base64 encoded data or URL - QString mediaType; // e.g., "image/png", "image/jpeg", "image/webp", "image/gif" - bool isUrl = false; // true if data is URL, false if base64 - - bool operator==(const ImageAttachment &) const = default; -}; - -struct ToolCall -{ - QString id; - QString name; - QJsonObject arguments; - - bool operator==(const ToolCall &) const = default; -}; - -struct Message -{ - QString role; - QString content; - QString signature; - bool isThinking = false; - bool isRedacted = false; - std::optional> images; - - QVector toolCalls; - QString toolCallId; - QString toolName; - - // clang-format off - bool operator==(const Message&) const = default; - // clang-format on -}; - -struct FileMetadata -{ - QString filePath; - QString content; - bool operator==(const FileMetadata &) const = default; -}; - -struct ContextData -{ - std::optional systemPrompt; - std::optional prefix; - std::optional suffix; - std::optional fileContext; - std::optional> history; - std::optional> filesMetadata; - - bool operator==(const ContextData &) const = default; -}; - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/IPromptProvider.hpp b/pluginllmcore/IPromptProvider.hpp deleted file mode 100644 index 120e3e1..0000000 --- a/pluginllmcore/IPromptProvider.hpp +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (C) 2025 Povilas Kanapickas -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include "PromptTemplate.hpp" -#include - -namespace QodeAssist::PluginLLMCore { - -class IPromptProvider -{ -public: - virtual ~IPromptProvider() = default; - - virtual PromptTemplate *getTemplateByName(const QString &templateName) const = 0; - - virtual QStringList templatesNames() const = 0; - - virtual QStringList getTemplatesForProvider(ProviderID id) const = 0; -}; - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/IProviderRegistry.hpp b/pluginllmcore/IProviderRegistry.hpp deleted file mode 100644 index d5df8d1..0000000 --- a/pluginllmcore/IProviderRegistry.hpp +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (C) 2025 Povilas Kanapickas -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include "Provider.hpp" - -namespace QodeAssist::PluginLLMCore { - -class IProviderRegistry -{ -public: - virtual ~IProviderRegistry() = default; - - virtual Provider *getProviderByName(const QString &providerName) = 0; - - virtual QStringList providersNames() const = 0; -}; - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/PromptProviderChat.hpp b/pluginllmcore/PromptProviderChat.hpp deleted file mode 100644 index 84a73c7..0000000 --- a/pluginllmcore/PromptProviderChat.hpp +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (C) 2025 Povilas Kanapickas -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include "IPromptProvider.hpp" -#include "PromptTemplate.hpp" -#include "PromptTemplateManager.hpp" - -namespace QodeAssist::PluginLLMCore { - -class PromptProviderChat : public IPromptProvider -{ -public: - explicit PromptProviderChat(PromptTemplateManager &templateManager) - : m_templateManager(templateManager) - {} - - ~PromptProviderChat() = default; - - PromptTemplate *getTemplateByName(const QString &templateName) const override - { - return m_templateManager.getChatTemplateByName(templateName); - } - - QStringList templatesNames() const override { return m_templateManager.chatTemplatesNames(); } - - QStringList getTemplatesForProvider(ProviderID id) const override - { - return m_templateManager.getChatTemplatesForProvider(id); - } - -private: - PromptTemplateManager &m_templateManager; -}; - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/PromptProviderFim.hpp b/pluginllmcore/PromptProviderFim.hpp deleted file mode 100644 index a8c96c6..0000000 --- a/pluginllmcore/PromptProviderFim.hpp +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (C) 2025 Povilas Kanapickas -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include "IPromptProvider.hpp" -#include "PromptTemplateManager.hpp" - -namespace QodeAssist::PluginLLMCore { - -class PromptProviderFim : public IPromptProvider -{ -public: - explicit PromptProviderFim(PromptTemplateManager &templateManager) - : m_templateManager(templateManager) - {} - - ~PromptProviderFim() = default; - - PromptTemplate *getTemplateByName(const QString &templateName) const override - { - return m_templateManager.getFimTemplateByName(templateName); - } - - QStringList templatesNames() const override { return m_templateManager.fimTemplatesNames(); } - - QStringList getTemplatesForProvider(ProviderID id) const override - { - return m_templateManager.getFimTemplatesForProvider(id); - } - -private: - PromptTemplateManager &m_templateManager; -}; - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/PromptTemplate.hpp b/pluginllmcore/PromptTemplate.hpp deleted file mode 100644 index 89e0d33..0000000 --- a/pluginllmcore/PromptTemplate.hpp +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include - -#include "ContextData.hpp" -#include "ProviderID.hpp" - -namespace QodeAssist::PluginLLMCore { - -enum class TemplateType { Chat, FIM, FIMOnChat }; - -class PromptTemplate -{ -public: - virtual ~PromptTemplate() = default; - virtual TemplateType type() const = 0; - virtual QString name() const = 0; - virtual QStringList stopWords() const = 0; - virtual void prepareRequest(QJsonObject &request, const ContextData &context) const = 0; - virtual QString description() const = 0; - virtual bool isSupportProvider(ProviderID id) const = 0; - - virtual QString endpoint() const { return {}; } - - virtual bool supportsToolHistory() const { return false; } -}; -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/PromptTemplateManager.cpp b/pluginllmcore/PromptTemplateManager.cpp deleted file mode 100644 index 0e314f3..0000000 --- a/pluginllmcore/PromptTemplateManager.cpp +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "PromptTemplateManager.hpp" - -#include - -namespace QodeAssist::PluginLLMCore { - -PromptTemplateManager &PromptTemplateManager::instance() -{ - static PromptTemplateManager instance; - return instance; -} - -QStringList PromptTemplateManager::fimTemplatesNames() const -{ - return m_fimTemplates.keys(); -} - -QStringList PromptTemplateManager::chatTemplatesNames() const -{ - return m_chatTemplates.keys(); -} - -QStringList PromptTemplateManager::getFimTemplatesForProvider(ProviderID id) -{ - QStringList templateList; - - for (const auto tmpl : m_fimTemplates) { - if (tmpl->isSupportProvider(id)) { - templateList.append(tmpl->name()); - } - } - - return templateList; -} - -QStringList PromptTemplateManager::getChatTemplatesForProvider(ProviderID id) -{ - QStringList templateList; - - for (const auto tmpl : m_chatTemplates) { - if (tmpl->isSupportProvider(id)) { - templateList.append(tmpl->name()); - } - } - - return templateList; -} - -PromptTemplateManager::~PromptTemplateManager() -{ - qDeleteAll(m_fimTemplates); -} - -PromptTemplate *PromptTemplateManager::getFimTemplateByName(const QString &templateName) -{ - if (!m_fimTemplates.contains(templateName)) { - QMessageBox::warning( - nullptr, - QObject::tr("Template Not Found"), - QObject::tr("Template '%1' was not found or has been updated. Please re-set new one.") - .arg(templateName)); - return m_fimTemplates.first(); - } - return m_fimTemplates[templateName]; -} - -PromptTemplate *PromptTemplateManager::getChatTemplateByName(const QString &templateName) -{ - if (!m_chatTemplates.contains(templateName)) { - QMessageBox::warning( - nullptr, - QObject::tr("Template Not Found"), - QObject::tr("Template '%1' was not found or has been updated. Please re-set new one.") - .arg(templateName)); - return m_chatTemplates.first(); - } - return m_chatTemplates[templateName]; -} - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/PromptTemplateManager.hpp b/pluginllmcore/PromptTemplateManager.hpp deleted file mode 100644 index 3040d83..0000000 --- a/pluginllmcore/PromptTemplateManager.hpp +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -#include "PromptTemplate.hpp" - -namespace QodeAssist::PluginLLMCore { - -class PromptTemplateManager -{ -public: - static PromptTemplateManager &instance(); - ~PromptTemplateManager(); - - template - void registerTemplate() - { - static_assert(std::is_base_of::value, "T must inherit from PromptTemplate"); - T *template_ptr = new T(); - QString name = template_ptr->name(); - m_fimTemplates[name] = template_ptr; - if (template_ptr->type() == TemplateType::Chat) { - m_chatTemplates[name] = template_ptr; - } - } - - PromptTemplate *getFimTemplateByName(const QString &templateName); - PromptTemplate *getChatTemplateByName(const QString &templateName); - - QStringList fimTemplatesNames() const; - QStringList chatTemplatesNames() const; - - QStringList getFimTemplatesForProvider(ProviderID id); - QStringList getChatTemplatesForProvider(ProviderID id); - -private: - PromptTemplateManager() = default; - PromptTemplateManager(const PromptTemplateManager &) = delete; - PromptTemplateManager &operator=(const PromptTemplateManager &) = delete; - - QMap m_fimTemplates; - QMap m_chatTemplates; -}; - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/Provider.cpp b/pluginllmcore/Provider.cpp deleted file mode 100644 index e9be5a1..0000000 --- a/pluginllmcore/Provider.cpp +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "Provider.hpp" - -#include -#include - -#include - -#include - -namespace QodeAssist::PluginLLMCore { - -Provider::Provider(QObject *parent) - : QObject(parent) -{} - -LLMQore::RequestID Provider::sendRequest( - const QUrl &url, const QJsonObject &payload, const QString &endpoint) -{ - auto *c = client(); - - c->setUrl(url.toString()); - c->setApiKey(apiKey()); - - auto requestId = c->sendMessage(payload, endpoint); - - LOG_MESSAGE( - QString("%1: Sending request %2 to %3%4").arg(name(), requestId, url.toString(), endpoint)); - LOG_MESSAGE( - QString("%1: Payload:\n%2") - .arg(name(), QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Indented)))); - - return requestId; -} - -void Provider::cancelRequest(const LLMQore::RequestID &requestId) -{ - LOG_MESSAGE(QString("%1: Cancelling request %2").arg(name(), requestId)); - client()->cancelRequest(requestId); -} - -::LLMQore::ToolsManager *Provider::toolsManager() const -{ - return client()->tools(); -} - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/Provider.hpp b/pluginllmcore/Provider.hpp deleted file mode 100644 index 5063d57..0000000 --- a/pluginllmcore/Provider.hpp +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include -#include -#include - -#include "ContextData.hpp" -#include "PromptTemplate.hpp" -#include "LLMQore/BaseClient.hpp" -#include "RequestType.hpp" - -namespace LLMQore { -class BaseClient; -class ToolsManager; -} - -class QJsonObject; - -namespace QodeAssist::PluginLLMCore { - -enum class ProviderCapability { - Tools = 0x1, - Thinking = 0x2, - Image = 0x4, - ModelListing = 0x8, -}; -Q_DECLARE_FLAGS(ProviderCapabilities, ProviderCapability) -Q_DECLARE_OPERATORS_FOR_FLAGS(ProviderCapabilities) - -class Provider : public QObject -{ - Q_OBJECT -public: - explicit Provider(QObject *parent = nullptr); - - virtual ~Provider() = default; - - virtual QString name() const = 0; - virtual QString url() const = 0; - virtual void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) - = 0; - virtual QFuture> getInstalledModels(const QString &url) = 0; - virtual ProviderID providerID() const = 0; - virtual ProviderCapabilities capabilities() const { return {}; } - - virtual ::LLMQore::BaseClient *client() const = 0; - virtual QString apiKey() const = 0; - - virtual LLMQore::RequestID sendRequest( - const QUrl &url, const QJsonObject &payload, const QString &endpoint); - void cancelRequest(const LLMQore::RequestID &requestId); - ::LLMQore::ToolsManager *toolsManager() const; -}; - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/ProviderID.hpp b/pluginllmcore/ProviderID.hpp deleted file mode 100644 index 6fefb23..0000000 --- a/pluginllmcore/ProviderID.hpp +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -namespace QodeAssist::PluginLLMCore { - -enum class ProviderID { - Any, - Ollama, - LMStudio, - Claude, - OpenAI, - OpenAICompatible, - OpenAIResponses, - MistralAI, - OpenRouter, - GoogleAI, - LlamaCpp, - Qwen, - DeepSeek -}; -} diff --git a/pluginllmcore/ProvidersManager.cpp b/pluginllmcore/ProvidersManager.cpp deleted file mode 100644 index a2b0898..0000000 --- a/pluginllmcore/ProvidersManager.cpp +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "ProvidersManager.hpp" - -namespace QodeAssist::PluginLLMCore { - -ProvidersManager &ProvidersManager::instance() -{ - static ProvidersManager instance; - return instance; -} - -QStringList ProvidersManager::providersNames() const -{ - return m_providers.keys(); -} - -ProvidersManager::~ProvidersManager() -{ - qDeleteAll(m_providers); -} - -Provider *ProvidersManager::getProviderByName(const QString &providerName) -{ - if (!m_providers.contains(providerName)) - return m_providers.first(); - return m_providers[providerName]; -} - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/ProvidersManager.hpp b/pluginllmcore/ProvidersManager.hpp deleted file mode 100644 index 21dced4..0000000 --- a/pluginllmcore/ProvidersManager.hpp +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include "IProviderRegistry.hpp" -#include - -namespace QodeAssist::PluginLLMCore { - -class ProvidersManager : public IProviderRegistry -{ -public: - static ProvidersManager &instance(); - ~ProvidersManager(); - - template - void registerProvider() - { - static_assert(std::is_base_of::value, "T must inherit from Provider"); - T *provider = new T(); - QString name = provider->name(); - m_providers[name] = provider; - } - - Provider *getProviderByName(const QString &providerName) override; - - QStringList providersNames() const override; - -private: - ProvidersManager() = default; - ProvidersManager(const ProvidersManager &) = delete; - ProvidersManager &operator=(const ProvidersManager &) = delete; - - QMap m_providers; -}; - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/RequestType.hpp b/pluginllmcore/RequestType.hpp deleted file mode 100644 index 5dffc48..0000000 --- a/pluginllmcore/RequestType.hpp +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include - -#pragma once - -namespace QodeAssist::PluginLLMCore { - -enum RequestType { CodeCompletion, Chat, Embedding, QuickRefactoring }; - -} diff --git a/pluginllmcore/RulesLoader.cpp b/pluginllmcore/RulesLoader.cpp deleted file mode 100644 index 4947cf7..0000000 --- a/pluginllmcore/RulesLoader.cpp +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "RulesLoader.hpp" - -#include -#include - -#include -#include -#include - -namespace QodeAssist::PluginLLMCore { - -QString RulesLoader::loadRules(const QString &projectPath, RulesContext context) -{ - if (projectPath.isEmpty()) { - return QString(); - } - - QString combined; - QString basePath = projectPath + "/.qodeassist/rules"; - - switch (context) { - case RulesContext::Completions: - combined += loadAllMarkdownFiles(basePath + "/completions"); - break; - case RulesContext::Chat: - combined += loadAllMarkdownFiles(basePath + "/common"); - combined += loadAllMarkdownFiles(basePath + "/chat"); - break; - case RulesContext::QuickRefactor: - combined += loadAllMarkdownFiles(basePath + "/common"); - combined += loadAllMarkdownFiles(basePath + "/quickrefactor"); - break; - } - - return combined; -} - -QString RulesLoader::loadRulesForProject(ProjectExplorer::Project *project, RulesContext context) -{ - if (!project) { - return QString(); - } - - QString projectPath = getProjectPath(project); - return loadRules(projectPath, context); -} - -ProjectExplorer::Project *RulesLoader::getActiveProject() -{ - auto currentEditor = Core::EditorManager::currentEditor(); - if (currentEditor && currentEditor->document()) { - Utils::FilePath filePath = currentEditor->document()->filePath(); - auto project = ProjectExplorer::ProjectManager::projectForFile(filePath); - if (project) { - return project; - } - } - - return ProjectExplorer::ProjectManager::startupProject(); -} - -QString RulesLoader::loadAllMarkdownFiles(const QString &dirPath) -{ - QString combined; - QDir dir(dirPath); - - if (!dir.exists()) { - return QString(); - } - - QStringList mdFiles = dir.entryList({"*.md"}, QDir::Files, QDir::Name); - - for (const QString &fileName : mdFiles) { - QFile file(dir.filePath(fileName)); - if (file.open(QIODevice::ReadOnly | QIODevice::Text)) { - combined += file.readAll(); - combined += "\n\n"; - } - } - - return combined; -} - -QString RulesLoader::getProjectPath(ProjectExplorer::Project *project) -{ - if (!project) { - return QString(); - } - - return project->projectDirectory().toUrlishString(); -} - -QVector RulesLoader::getRuleFiles(const QString &projectPath, RulesContext context) -{ - if (projectPath.isEmpty()) { - return QVector(); - } - - QVector result; - QString basePath = projectPath + "/.qodeassist/rules"; - - // Always include common rules - result.append(collectMarkdownFiles(basePath + "/common", "common")); - - // Add context-specific rules - switch (context) { - case RulesContext::Completions: - result.append(collectMarkdownFiles(basePath + "/completions", "completions")); - break; - case RulesContext::Chat: - result.append(collectMarkdownFiles(basePath + "/chat", "chat")); - break; - case RulesContext::QuickRefactor: - result.append(collectMarkdownFiles(basePath + "/quickrefactor", "quickrefactor")); - break; - } - - return result; -} - -QVector RulesLoader::getRuleFilesForProject( - ProjectExplorer::Project *project, RulesContext context) -{ - if (!project) { - return QVector(); - } - - QString projectPath = getProjectPath(project); - return getRuleFiles(projectPath, context); -} - -QString RulesLoader::loadRuleFileContent(const QString &filePath) -{ - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - return QString(); - } - - return file.readAll(); -} - -QVector RulesLoader::collectMarkdownFiles( - const QString &dirPath, const QString &category) -{ - QVector result; - QDir dir(dirPath); - - if (!dir.exists()) { - return result; - } - - QStringList mdFiles = dir.entryList({"*.md"}, QDir::Files, QDir::Name); - - for (const QString &fileName : mdFiles) { - QString fullPath = dir.filePath(fileName); - result.append({fullPath, fileName, category}); - } - - return result; -} - -} // namespace QodeAssist::PluginLLMCore diff --git a/pluginllmcore/RulesLoader.hpp b/pluginllmcore/RulesLoader.hpp deleted file mode 100644 index 9b17323..0000000 --- a/pluginllmcore/RulesLoader.hpp +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -namespace ProjectExplorer { -class Project; -} - -namespace QodeAssist::PluginLLMCore { - -enum class RulesContext { Completions, Chat, QuickRefactor }; - -struct RuleFileInfo -{ - QString filePath; - QString fileName; - QString category; // "common", "chat", "completions", "quickrefactor" -}; - -class RulesLoader -{ -public: - static QString loadRules(const QString &projectPath, RulesContext context); - static QString loadRulesForProject(ProjectExplorer::Project *project, RulesContext context); - static ProjectExplorer::Project *getActiveProject(); - - // New methods for getting rule files info - static QVector getRuleFiles(const QString &projectPath, RulesContext context); - static QVector getRuleFilesForProject(ProjectExplorer::Project *project, RulesContext context); - static QString loadRuleFileContent(const QString &filePath); - -private: - static QString loadAllMarkdownFiles(const QString &dirPath); - static QVector collectMarkdownFiles(const QString &dirPath, const QString &category); - static QString getProjectPath(ProjectExplorer::Project *project); -}; - -} // namespace QodeAssist::PluginLLMCore diff --git a/providers/ClaudeProvider.cpp b/providers/ClaudeProvider.cpp deleted file mode 100644 index 4c1bedf..0000000 --- a/providers/ClaudeProvider.cpp +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "ClaudeProvider.hpp" - -#include -#include -#include - -#include - -#include "ClaudeCacheControl.hpp" -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" -#include "tools/ToolsRegistration.hpp" - -namespace QodeAssist::Providers { - -ClaudeProvider::ClaudeProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::ClaudeClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString ClaudeProvider::name() const -{ - return "Claude"; -} - -QString ClaudeProvider::apiKey() const -{ - return Settings::providerSettings().claudeApiKey(); -} - -QString ClaudeProvider::url() const -{ - return "https://api.anthropic.com"; -} - -void ClaudeProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - request["max_tokens"] = settings.maxTokens(); - if (settings.useTopP()) - request["top_p"] = settings.topP(); - if (settings.useTopK()) - request["top_k"] = settings.topK(); - request["stream"] = true; - }; - - auto applyThinkingMode = [&request](const auto &settings) { - const QString model = request.value("model").toString().toLower(); - const bool useAdaptiveThinking = model.contains("opus-4-8") || model.contains("opus-4-7") - || model.contains("opus-4-6") || model.contains("sonnet-4-6"); - - QJsonObject thinkingObj; - if (useAdaptiveThinking) { - thinkingObj["type"] = "adaptive"; - - const int budget = settings.thinkingBudgetTokens(); - QString effort = "high"; - if (budget < 8000) - effort = "low"; - else if (budget < 24000) - effort = "medium"; - - QJsonObject outputConfig; - outputConfig["effort"] = effort; - request["output_config"] = outputConfig; - } else { - thinkingObj["type"] = "enabled"; - thinkingObj["budget_tokens"] = settings.thinkingBudgetTokens(); - } - request["thinking"] = thinkingObj; - request["max_tokens"] = settings.thinkingMaxTokens(); - request["temperature"] = 1.0; - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - request["temperature"] = Settings::codeCompletionSettings().temperature(); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - const auto &qrSettings = Settings::quickRefactorSettings(); - applyModelParams(qrSettings); - - if (isThinkingEnabled) { - applyThinkingMode(qrSettings); - } else { - request["temperature"] = qrSettings.temperature(); - } - } else { - const auto &chatSettings = Settings::chatAssistantSettings(); - applyModelParams(chatSettings); - - if (isThinkingEnabled) { - applyThinkingMode(chatSettings); - } else { - request["temperature"] = chatSettings.temperature(); - } - } - - if (isToolsEnabled) { - auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE(QString("Added %1 tools to Claude request").arg(toolsDefinitions.size())); - } - } - - const auto &ps = Settings::providerSettings(); - const bool cachingOn = ps.claudeEnablePromptCaching() - && type != PluginLLMCore::RequestType::CodeCompletion; - m_client->setUseExtendedCacheTTL(cachingOn && ps.claudeUseExtendedCacheTTL()); - if (cachingOn) { - ClaudeCacheControl::apply(request, ps.claudeUseExtendedCacheTTL()); - } -} - -QFuture> ClaudeProvider::getInstalledModels(const QString &baseUrl) -{ - m_client->setUrl(baseUrl); - m_client->setApiKey(apiKey()); - return m_client->listModels(); -} - -PluginLLMCore::ProviderID ClaudeProvider::providerID() const -{ - return PluginLLMCore::ProviderID::Claude; -} - -PluginLLMCore::ProviderCapabilities ClaudeProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Thinking - | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::ModelListing; -} - -::LLMQore::BaseClient *ClaudeProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/ClaudeProvider.hpp b/providers/ClaudeProvider.hpp deleted file mode 100644 index d0c6015..0000000 --- a/providers/ClaudeProvider.hpp +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include - -namespace QodeAssist::Providers { - -class ClaudeProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit ClaudeProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - -private: - ::LLMQore::ClaudeClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/CodestralProvider.cpp b/providers/CodestralProvider.cpp deleted file mode 100644 index 624bcef..0000000 --- a/providers/CodestralProvider.cpp +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "CodestralProvider.hpp" - -#include "settings/ProviderSettings.hpp" - -namespace QodeAssist::Providers { - -CodestralProvider::CodestralProvider(QObject *parent) - : MistralAIProvider(parent) -{} - -QString CodestralProvider::name() const -{ - return "Codestral"; -} - -QString CodestralProvider::apiKey() const -{ - return Settings::providerSettings().codestralApiKey(); -} - -QString CodestralProvider::url() const -{ - return "https://codestral.mistral.ai"; -} - -PluginLLMCore::ProviderCapabilities CodestralProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image; -} - -} // namespace QodeAssist::Providers diff --git a/providers/CodestralProvider.hpp b/providers/CodestralProvider.hpp deleted file mode 100644 index 8d5b4f6..0000000 --- a/providers/CodestralProvider.hpp +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include "MistralAIProvider.hpp" - -namespace QodeAssist::Providers { - -class CodestralProvider : public MistralAIProvider -{ -public: - explicit CodestralProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - QString apiKey() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/DeepSeekProvider.cpp b/providers/DeepSeekProvider.cpp deleted file mode 100644 index ccf9dca..0000000 --- a/providers/DeepSeekProvider.cpp +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "DeepSeekProvider.hpp" - -#include -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "tools/ToolsRegistration.hpp" - -#include -#include -#include - -namespace QodeAssist::Providers { - -DeepSeekProvider::DeepSeekProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::OpenAIClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString DeepSeekProvider::name() const -{ - return "DeepSeek"; -} - -QString DeepSeekProvider::apiKey() const -{ - return Settings::providerSettings().deepSeekApiKey(); -} - -QString DeepSeekProvider::url() const -{ - return "https://api.deepseek.com"; -} - -QFuture> DeepSeekProvider::getInstalledModels(const QString &url) -{ - m_client->setUrl(url); - m_client->setApiKey(apiKey()); - return m_client->listModels(); -} - -PluginLLMCore::ProviderID DeepSeekProvider::providerID() const -{ - return PluginLLMCore::ProviderID::DeepSeek; -} - -PluginLLMCore::ProviderCapabilities DeepSeekProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools - | PluginLLMCore::ProviderCapability::Thinking - | PluginLLMCore::ProviderCapability::ModelListing; -} - -void DeepSeekProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - request["max_tokens"] = settings.maxTokens(); - request["temperature"] = settings.temperature(); - - if (settings.useTopP()) - request["top_p"] = settings.topP(); - if (settings.useFrequencyPenalty()) - request["frequency_penalty"] = settings.frequencyPenalty(); - if (settings.usePresencePenalty()) - request["presence_penalty"] = settings.presencePenalty(); - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - applyModelParams(Settings::quickRefactorSettings()); - } else { - applyModelParams(Settings::chatAssistantSettings()); - } - - if (isToolsEnabled) { - auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE(QString("Added %1 tools to DeepSeek request").arg(toolsDefinitions.size())); - } - } -} - -::LLMQore::BaseClient *DeepSeekProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/DeepSeekProvider.hpp b/providers/DeepSeekProvider.hpp deleted file mode 100644 index ab09232..0000000 --- a/providers/DeepSeekProvider.hpp +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Providers { - -class DeepSeekProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit DeepSeekProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - -private: - ::LLMQore::OpenAIClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/GoogleAIProvider.cpp b/providers/GoogleAIProvider.cpp deleted file mode 100644 index b47cfa0..0000000 --- a/providers/GoogleAIProvider.cpp +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "GoogleAIProvider.hpp" - -#include - -#include -#include "tools/ToolsRegistration.hpp" -#include -#include -#include - -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" - -namespace QodeAssist::Providers { - -GoogleAIProvider::GoogleAIProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::GoogleAIClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString GoogleAIProvider::name() const -{ - return "Google AI"; -} - -QString GoogleAIProvider::apiKey() const -{ - return Settings::providerSettings().googleAiApiKey(); -} - -QString GoogleAIProvider::url() const -{ - return "https://generativelanguage.googleapis.com/v1beta"; -} - -void GoogleAIProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - QJsonObject generationConfig; - generationConfig["maxOutputTokens"] = settings.maxTokens(); - generationConfig["temperature"] = settings.temperature(); - - if (settings.useTopP()) - generationConfig["topP"] = settings.topP(); - if (settings.useTopK()) - generationConfig["topK"] = settings.topK(); - - request["generationConfig"] = generationConfig; - }; - - auto applyThinkingMode = [&request](const auto &settings) { - QJsonObject generationConfig; - generationConfig["maxOutputTokens"] = settings.thinkingMaxTokens(); - - if (settings.useTopP()) - generationConfig["topP"] = settings.topP(); - if (settings.useTopK()) - generationConfig["topK"] = settings.topK(); - - generationConfig["temperature"] = 1.0; - - QJsonObject thinkingConfig; - thinkingConfig["includeThoughts"] = true; - int budgetTokens = settings.thinkingBudgetTokens(); - if (budgetTokens != -1) { - thinkingConfig["thinkingBudget"] = budgetTokens; - } - - generationConfig["thinkingConfig"] = thinkingConfig; - request["generationConfig"] = generationConfig; - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - const auto &qrSettings = Settings::quickRefactorSettings(); - - if (isThinkingEnabled) { - applyThinkingMode(qrSettings); - } else { - applyModelParams(qrSettings); - } - } else { - const auto &chatSettings = Settings::chatAssistantSettings(); - - if (isThinkingEnabled) { - applyThinkingMode(chatSettings); - } else { - applyModelParams(chatSettings); - } - } - - if (isToolsEnabled) { - auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE(QString("Added %1 tools to Google AI request").arg(toolsDefinitions.size())); - } - } -} - -QFuture> GoogleAIProvider::getInstalledModels(const QString &baseUrl) -{ - m_client->setUrl(baseUrl); - m_client->setApiKey(apiKey()); - return m_client->listModels(); -} - -PluginLLMCore::ProviderID GoogleAIProvider::providerID() const -{ - return PluginLLMCore::ProviderID::GoogleAI; -} - -PluginLLMCore::ProviderCapabilities GoogleAIProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Thinking - | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::ModelListing; -} - -LLMQore::RequestID GoogleAIProvider::sendRequest( - const QUrl &url, const QJsonObject &payload, const QString &endpoint) -{ - QJsonObject cleaned = payload; - if (cleaned.contains("model")) { - m_client->setModel(cleaned["model"].toString()); - cleaned.remove("model"); - } - cleaned.remove("stream"); - - return PluginLLMCore::Provider::sendRequest(url, cleaned, endpoint); -} - -::LLMQore::BaseClient *GoogleAIProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/GoogleAIProvider.hpp b/providers/GoogleAIProvider.hpp deleted file mode 100644 index beb866d..0000000 --- a/providers/GoogleAIProvider.hpp +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include - -namespace QodeAssist::Providers { - -class GoogleAIProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit GoogleAIProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - LLMQore::RequestID sendRequest( - const QUrl &url, const QJsonObject &payload, const QString &endpoint) override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - -private: - ::LLMQore::GoogleAIClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/LMStudioProvider.cpp b/providers/LMStudioProvider.cpp deleted file mode 100644 index a93a77c..0000000 --- a/providers/LMStudioProvider.cpp +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "LMStudioProvider.hpp" - -#include - -#include "providers/ProviderUrlUtils.hpp" -#include "tools/ToolsRegistration.hpp" -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" - -#include -#include -#include - -namespace QodeAssist::Providers { - -LMStudioProvider::LMStudioProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::OpenAIClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString LMStudioProvider::name() const -{ - return "LM Studio (Chat Completions)"; -} - -QString LMStudioProvider::apiKey() const -{ - return {}; -} - -QString LMStudioProvider::url() const -{ - return "http://localhost:1234"; -} - -QFuture> LMStudioProvider::getInstalledModels(const QString &url) -{ - m_client->setUrl(ensureOpenAIV1Base(url)); - m_client->setApiKey(apiKey()); - return m_client->listModels(); -} - -PluginLLMCore::ProviderID LMStudioProvider::providerID() const -{ - return PluginLLMCore::ProviderID::LMStudio; -} - -PluginLLMCore::ProviderCapabilities LMStudioProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::ModelListing - | PluginLLMCore::ProviderCapability::Thinking; -} - -void LMStudioProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - request["max_tokens"] = settings.maxTokens(); - request["temperature"] = settings.temperature(); - - if (settings.useTopP()) - request["top_p"] = settings.topP(); - if (settings.useTopK()) - request["top_k"] = settings.topK(); - if (settings.useFrequencyPenalty()) - request["frequency_penalty"] = settings.frequencyPenalty(); - if (settings.usePresencePenalty()) - request["presence_penalty"] = settings.presencePenalty(); - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - applyModelParams(Settings::quickRefactorSettings()); - } else { - applyModelParams(Settings::chatAssistantSettings()); - } - - if (isToolsEnabled) { - auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE(QString("Added %1 tools to LMStudio request").arg(toolsDefinitions.size())); - } - } -} - -LLMQore::RequestID LMStudioProvider::sendRequest( - const QUrl &url, const QJsonObject &payload, const QString &endpoint) -{ - return PluginLLMCore::Provider::sendRequest( - QUrl(ensureOpenAIV1Base(url.toString())), payload, endpoint); -} - -::LLMQore::BaseClient *LMStudioProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/LMStudioProvider.hpp b/providers/LMStudioProvider.hpp deleted file mode 100644 index 197250b..0000000 --- a/providers/LMStudioProvider.hpp +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Providers { - -class LMStudioProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit LMStudioProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - - LLMQore::RequestID sendRequest( - const QUrl &url, const QJsonObject &payload, const QString &endpoint) override; - -private: - ::LLMQore::OpenAIClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/LMStudioResponsesProvider.cpp b/providers/LMStudioResponsesProvider.cpp deleted file mode 100644 index 7a5f176..0000000 --- a/providers/LMStudioResponsesProvider.cpp +++ /dev/null @@ -1,146 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "LMStudioResponsesProvider.hpp" - -#include - -#include "logger/Logger.hpp" -#include "providers/ProviderUrlUtils.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "tools/ToolsRegistration.hpp" - -#include -#include -#include - -namespace QodeAssist::Providers { - -LMStudioResponsesProvider::LMStudioResponsesProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::OpenAIResponsesClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString LMStudioResponsesProvider::name() const -{ - return "LM Studio (Responses API)"; -} - -QString LMStudioResponsesProvider::apiKey() const -{ - return {}; -} - -QString LMStudioResponsesProvider::url() const -{ - return "http://localhost:1234"; -} - -void LMStudioResponsesProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - request["max_output_tokens"] = settings.maxTokens(); - - if (settings.useTopP()) { - request["top_p"] = settings.topP(); - } - }; - - auto applyThinkingMode = [&request](const auto &settings) { - QString effortStr = settings.openAIResponsesReasoningEffort.stringValue().toLower(); - if (effortStr.isEmpty()) { - effortStr = "medium"; - } - - QJsonObject reasoning; - reasoning["effort"] = effortStr; - request["reasoning"] = reasoning; - request["max_output_tokens"] = settings.thinkingMaxTokens(); - request["store"] = true; - - QJsonArray include; - include.append("reasoning.encrypted_content"); - request["include"] = include; - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - const auto &qrSettings = Settings::quickRefactorSettings(); - applyModelParams(qrSettings); - - if (isThinkingEnabled) { - applyThinkingMode(qrSettings); - } - } else { - const auto &chatSettings = Settings::chatAssistantSettings(); - applyModelParams(chatSettings); - - if (isThinkingEnabled) { - applyThinkingMode(chatSettings); - } - } - - if (isToolsEnabled) { - const auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE(QString("Added %1 tools to LM Studio Responses request") - .arg(toolsDefinitions.size())); - } - } - - request["stream"] = true; -} - -QFuture> LMStudioResponsesProvider::getInstalledModels(const QString &baseUrl) -{ - m_client->setUrl(ensureOpenAIV1Base(baseUrl)); - m_client->setApiKey(apiKey()); - return m_client->listModels(); -} - -LLMQore::RequestID LMStudioResponsesProvider::sendRequest( - const QUrl &url, const QJsonObject &payload, const QString &endpoint) -{ - return PluginLLMCore::Provider::sendRequest( - QUrl(ensureOpenAIV1Base(url.toString())), payload, endpoint); -} - -PluginLLMCore::ProviderID LMStudioResponsesProvider::providerID() const -{ - return PluginLLMCore::ProviderID::OpenAIResponses; -} - -PluginLLMCore::ProviderCapabilities LMStudioResponsesProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Thinking - | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::ModelListing; -} - -::LLMQore::BaseClient *LMStudioResponsesProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/LMStudioResponsesProvider.hpp b/providers/LMStudioResponsesProvider.hpp deleted file mode 100644 index c2b47e4..0000000 --- a/providers/LMStudioResponsesProvider.hpp +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Providers { - -class LMStudioResponsesProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit LMStudioResponsesProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - - LLMQore::RequestID sendRequest( - const QUrl &url, const QJsonObject &payload, const QString &endpoint) override; - -private: - ::LLMQore::OpenAIResponsesClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/LlamaCppProvider.cpp b/providers/LlamaCppProvider.cpp deleted file mode 100644 index 301bb32..0000000 --- a/providers/LlamaCppProvider.cpp +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "LlamaCppProvider.hpp" - -#include -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" -#include "tools/ToolsRegistration.hpp" - -#include -#include -#include - -namespace QodeAssist::Providers { - -LlamaCppProvider::LlamaCppProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::LlamaCppClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString LlamaCppProvider::name() const -{ - return "llama.cpp"; -} - -QString LlamaCppProvider::apiKey() const -{ - return Settings::providerSettings().llamaCppApiKey(); -} - -QString LlamaCppProvider::url() const -{ - return "http://localhost:8080"; -} - -void LlamaCppProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - request["max_tokens"] = settings.maxTokens(); - request["temperature"] = settings.temperature(); - - if (settings.useTopP()) - request["top_p"] = settings.topP(); - if (settings.useTopK()) - request["top_k"] = settings.topK(); - if (settings.useFrequencyPenalty()) - request["frequency_penalty"] = settings.frequencyPenalty(); - if (settings.usePresencePenalty()) - request["presence_penalty"] = settings.presencePenalty(); - }; - - auto applyThinkingMode = [&request]() { - QJsonObject chatTemplateKwargs = request["chat_template_kwargs"].toObject(); - chatTemplateKwargs["enable_thinking"] = true; - request["chat_template_kwargs"] = chatTemplateKwargs; - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - applyModelParams(Settings::quickRefactorSettings()); - if (isThinkingEnabled) { - applyThinkingMode(); - LOG_MESSAGE(QString("LlamaCppProvider: Thinking mode enabled for QuickRefactoring")); - } - } else { - applyModelParams(Settings::chatAssistantSettings()); - if (isThinkingEnabled) { - applyThinkingMode(); - LOG_MESSAGE(QString("LlamaCppProvider: Thinking mode enabled for Chat")); - } - } - - if (isToolsEnabled) { - auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE(QString("Added %1 tools to llama.cpp request").arg(toolsDefinitions.size())); - } - } -} - -QFuture> LlamaCppProvider::getInstalledModels(const QString &baseUrl) -{ - m_client->setUrl(baseUrl); - m_client->setApiKey(Settings::providerSettings().llamaCppApiKey()); - return m_client->listModels(); -} - -PluginLLMCore::ProviderID LlamaCppProvider::providerID() const -{ - return PluginLLMCore::ProviderID::LlamaCpp; -} - -PluginLLMCore::ProviderCapabilities LlamaCppProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Thinking - | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::ModelListing; -} - -::LLMQore::BaseClient *LlamaCppProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/LlamaCppProvider.hpp b/providers/LlamaCppProvider.hpp deleted file mode 100644 index 6f42126..0000000 --- a/providers/LlamaCppProvider.hpp +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include - -namespace QodeAssist::Providers { - -class LlamaCppProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit LlamaCppProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - -private: - ::LLMQore::LlamaCppClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/MistralAIProvider.cpp b/providers/MistralAIProvider.cpp deleted file mode 100644 index d166713..0000000 --- a/providers/MistralAIProvider.cpp +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "MistralAIProvider.hpp" - -#include -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" -#include "tools/ToolsRegistration.hpp" - -#include -#include -#include - -namespace QodeAssist::Providers { - -MistralAIProvider::MistralAIProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::MistralClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString MistralAIProvider::name() const -{ - return "Mistral AI"; -} - -QString MistralAIProvider::apiKey() const -{ - return Settings::providerSettings().mistralAiApiKey(); -} - -QString MistralAIProvider::url() const -{ - return "https://api.mistral.ai"; -} - -QFuture> MistralAIProvider::getInstalledModels(const QString &url) -{ - m_client->setUrl(url); - m_client->setApiKey(apiKey()); - return m_client->listModels(); -} - -PluginLLMCore::ProviderID MistralAIProvider::providerID() const -{ - return PluginLLMCore::ProviderID::MistralAI; -} - -PluginLLMCore::ProviderCapabilities MistralAIProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::ModelListing - | PluginLLMCore::ProviderCapability::Thinking; -} - -void MistralAIProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - request["max_tokens"] = settings.maxTokens(); - request["temperature"] = settings.temperature(); - - if (settings.useTopP()) - request["top_p"] = settings.topP(); - if (settings.useTopK()) - request["top_k"] = settings.topK(); - if (settings.useFrequencyPenalty()) - request["frequency_penalty"] = settings.frequencyPenalty(); - if (settings.usePresencePenalty()) - request["presence_penalty"] = settings.presencePenalty(); - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - applyModelParams(Settings::quickRefactorSettings()); - } else { - applyModelParams(Settings::chatAssistantSettings()); - } - - if (isToolsEnabled) { - auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE(QString("Added %1 tools to Mistral request").arg(toolsDefinitions.size())); - } - } -} - -::LLMQore::BaseClient *MistralAIProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/MistralAIProvider.hpp b/providers/MistralAIProvider.hpp deleted file mode 100644 index 71295ad..0000000 --- a/providers/MistralAIProvider.hpp +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Providers { - -class MistralAIProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit MistralAIProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - -private: - ::LLMQore::MistralClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/OllamaCompatProvider.cpp b/providers/OllamaCompatProvider.cpp deleted file mode 100644 index c960d3d..0000000 --- a/providers/OllamaCompatProvider.cpp +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "OllamaCompatProvider.hpp" - -#include - -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "tools/ToolsRegistration.hpp" - -#include -#include -#include - -namespace QodeAssist::Providers { - -OllamaCompatProvider::OllamaCompatProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::OpenAIClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString OllamaCompatProvider::name() const -{ - return "Ollama (OpenAI-compatible)"; -} - -QString OllamaCompatProvider::apiKey() const -{ - return Settings::providerSettings().ollamaBasicAuthApiKey(); -} - -QString OllamaCompatProvider::url() const -{ - return "http://localhost:11434"; -} - -void OllamaCompatProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - request["max_tokens"] = settings.maxTokens(); - request["temperature"] = settings.temperature(); - - if (settings.useTopP()) - request["top_p"] = settings.topP(); - if (settings.useTopK()) - request["top_k"] = settings.topK(); - if (settings.useFrequencyPenalty()) - request["frequency_penalty"] = settings.frequencyPenalty(); - if (settings.usePresencePenalty()) - request["presence_penalty"] = settings.presencePenalty(); - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - applyModelParams(Settings::quickRefactorSettings()); - } else { - applyModelParams(Settings::chatAssistantSettings()); - } - - if (isToolsEnabled) { - auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE( - QString("Added %1 tools to OllamaCompat request").arg(toolsDefinitions.size())); - } - } -} - -QFuture> OllamaCompatProvider::getInstalledModels(const QString &baseUrl) -{ - QString url = baseUrl; - if (!url.endsWith(QStringLiteral("/v1"))) - url += QStringLiteral("/v1"); - m_client->setUrl(url); - m_client->setApiKey(apiKey()); - return m_client->listModels(); -} - -LLMQore::RequestID OllamaCompatProvider::sendRequest( - const QUrl &url, const QJsonObject &payload, const QString &endpoint) -{ - const QString effectiveEndpoint - = endpoint.isEmpty() ? QStringLiteral("/v1/chat/completions") : endpoint; - return PluginLLMCore::Provider::sendRequest(url, payload, effectiveEndpoint); -} - -PluginLLMCore::ProviderID OllamaCompatProvider::providerID() const -{ - return PluginLLMCore::ProviderID::OpenAICompatible; -} - -PluginLLMCore::ProviderCapabilities OllamaCompatProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::ModelListing - | PluginLLMCore::ProviderCapability::Thinking; -} - -::LLMQore::BaseClient *OllamaCompatProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/OllamaCompatProvider.hpp b/providers/OllamaCompatProvider.hpp deleted file mode 100644 index 83b2959..0000000 --- a/providers/OllamaCompatProvider.hpp +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Providers { - -class OllamaCompatProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit OllamaCompatProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - - LLMQore::RequestID sendRequest( - const QUrl &url, const QJsonObject &payload, const QString &endpoint) override; - -private: - ::LLMQore::OpenAIClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/OllamaProvider.cpp b/providers/OllamaProvider.cpp deleted file mode 100644 index f3658cc..0000000 --- a/providers/OllamaProvider.cpp +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "OllamaProvider.hpp" - -#include - -#include -#include -#include - -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" -#include "tools/ToolsRegistration.hpp" - -namespace QodeAssist::Providers { - -OllamaProvider::OllamaProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::OllamaClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString OllamaProvider::name() const -{ - return "Ollama (Native)"; -} - -QString OllamaProvider::apiKey() const -{ - return Settings::providerSettings().ollamaBasicAuthApiKey(); -} - -QString OllamaProvider::url() const -{ - return "http://localhost:11434"; -} - -void OllamaProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applySettings = [&request](const auto &settings) { - QJsonObject options; - options["num_predict"] = settings.maxTokens(); - options["temperature"] = settings.temperature(); - options["stop"] = request.take("stop"); - - if (settings.useTopP()) - options["top_p"] = settings.topP(); - if (settings.useTopK()) - options["top_k"] = settings.topK(); - if (settings.useFrequencyPenalty()) - options["frequency_penalty"] = settings.frequencyPenalty(); - if (settings.usePresencePenalty()) - options["presence_penalty"] = settings.presencePenalty(); - - request["options"] = options; - request["keep_alive"] = settings.ollamaLivetime(); - }; - - auto applyThinkingMode = [&request]() { - request["enable_thinking"] = true; - QJsonObject options = request["options"].toObject(); - options["temperature"] = 1.0; - request["options"] = options; - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applySettings(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - const auto &qrSettings = Settings::quickRefactorSettings(); - applySettings(qrSettings); - - if (isThinkingEnabled) { - applyThinkingMode(); - LOG_MESSAGE(QString("OllamaProvider: Thinking mode enabled for QuickRefactoring")); - } - } else { - const auto &chatSettings = Settings::chatAssistantSettings(); - applySettings(chatSettings); - - if (isThinkingEnabled) { - applyThinkingMode(); - LOG_MESSAGE(QString("OllamaProvider: Thinking mode enabled for Chat")); - } - } - - if (isToolsEnabled) { - auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE( - QString("OllamaProvider: Added %1 tools to request").arg(toolsDefinitions.size())); - } - } -} - -QFuture> OllamaProvider::getInstalledModels(const QString &baseUrl) -{ - m_client->setUrl(baseUrl); - m_client->setApiKey(Settings::providerSettings().ollamaBasicAuthApiKey()); - return m_client->listModels(); -} - -PluginLLMCore::ProviderID OllamaProvider::providerID() const -{ - return PluginLLMCore::ProviderID::Ollama; -} - -PluginLLMCore::ProviderCapabilities OllamaProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Thinking - | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::ModelListing; -} - -::LLMQore::BaseClient *OllamaProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/OllamaProvider.hpp b/providers/OllamaProvider.hpp deleted file mode 100644 index a4ae748..0000000 --- a/providers/OllamaProvider.hpp +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include - -namespace QodeAssist::Providers { - -class OllamaProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit OllamaProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - -private: - ::LLMQore::OllamaClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/OpenAICompatProvider.cpp b/providers/OpenAICompatProvider.cpp deleted file mode 100644 index 6f9fe62..0000000 --- a/providers/OpenAICompatProvider.cpp +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "OpenAICompatProvider.hpp" -#include - -#include "tools/ToolsRegistration.hpp" -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" - -#include -#include -#include - -namespace QodeAssist::Providers { - -OpenAICompatProvider::OpenAICompatProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::OpenAIClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString OpenAICompatProvider::name() const -{ - return "OpenAI Compatible"; -} - -QString OpenAICompatProvider::apiKey() const -{ - return Settings::providerSettings().openAiCompatApiKey(); -} - -QString OpenAICompatProvider::url() const -{ - return "http://localhost:1234/v1"; -} - -void OpenAICompatProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - request["max_tokens"] = settings.maxTokens(); - request["temperature"] = settings.temperature(); - - if (settings.useTopP()) - request["top_p"] = settings.topP(); - if (settings.useTopK()) - request["top_k"] = settings.topK(); - if (settings.useFrequencyPenalty()) - request["frequency_penalty"] = settings.frequencyPenalty(); - if (settings.usePresencePenalty()) - request["presence_penalty"] = settings.presencePenalty(); - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - applyModelParams(Settings::quickRefactorSettings()); - } else { - applyModelParams(Settings::chatAssistantSettings()); - } - - if (isToolsEnabled) { - auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE( - QString("Added %1 tools to OpenAICompat request").arg(toolsDefinitions.size())); - } - } -} - -QFuture> OpenAICompatProvider::getInstalledModels(const QString &) -{ - return QtFuture::makeReadyFuture(QList{}); -} - -PluginLLMCore::ProviderID OpenAICompatProvider::providerID() const -{ - return PluginLLMCore::ProviderID::OpenAICompatible; -} - -PluginLLMCore::ProviderCapabilities OpenAICompatProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::Thinking; -} - -::LLMQore::BaseClient *OpenAICompatProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/OpenAICompatProvider.hpp b/providers/OpenAICompatProvider.hpp deleted file mode 100644 index ed68ea0..0000000 --- a/providers/OpenAICompatProvider.hpp +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Providers { - -class OpenAICompatProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit OpenAICompatProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - -private: - ::LLMQore::OpenAIClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/OpenAIProvider.cpp b/providers/OpenAIProvider.cpp deleted file mode 100644 index 322f666..0000000 --- a/providers/OpenAIProvider.cpp +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "OpenAIProvider.hpp" - -#include -#include "tools/ToolsRegistration.hpp" -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" - -#include -#include -#include - -namespace QodeAssist::Providers { - -OpenAIProvider::OpenAIProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::OpenAIClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString OpenAIProvider::name() const -{ - return "OpenAI (Chat Completions)"; -} - -QString OpenAIProvider::apiKey() const -{ - return Settings::providerSettings().openAiApiKey(); -} - -QString OpenAIProvider::url() const -{ - return "https://api.openai.com/v1"; -} - -void OpenAIProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - QString model = request.value("model").toString().toLower(); - bool useNewParameter = model.contains("gpt-4o") || model.contains("gpt-4-turbo") - || model.contains("o1-") || model.contains("gpt-5") - || model.startsWith("o1") || model.contains("o3"); - - bool isReasoningModel = model.contains("o1-") || model.contains("gpt-5") - || model.startsWith("o1") || model.contains("o3"); - - if (useNewParameter) { - request["max_completion_tokens"] = settings.maxTokens(); - } else { - request["max_tokens"] = settings.maxTokens(); - } - - if (!isReasoningModel) { - request["temperature"] = settings.temperature(); - - if (settings.useTopP()) - request["top_p"] = settings.topP(); - if (settings.useTopK()) - request["top_k"] = settings.topK(); - - } else { - request["temperature"] = 1.0; - } - - if (settings.useFrequencyPenalty()) - request["frequency_penalty"] = settings.frequencyPenalty(); - if (settings.usePresencePenalty()) - request["presence_penalty"] = settings.presencePenalty(); - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - applyModelParams(Settings::quickRefactorSettings()); - } else { - applyModelParams(Settings::chatAssistantSettings()); - } - - if (isToolsEnabled) { - auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE(QString("Added %1 tools to OpenAI request").arg(toolsDefinitions.size())); - } - } -} - -QFuture> OpenAIProvider::getInstalledModels(const QString &baseUrl) -{ - m_client->setUrl(baseUrl); - m_client->setApiKey(apiKey()); - return m_client->listModels().then([](const QList &allModels) { - QList filtered; - for (const QString &modelId : allModels) { - if (!modelId.contains("dall-e") && !modelId.contains("whisper") - && !modelId.contains("tts") && !modelId.contains("davinci") - && !modelId.contains("babbage") && !modelId.contains("omni")) { - filtered.append(modelId); - } - } - return filtered; - }); -} - -PluginLLMCore::ProviderID OpenAIProvider::providerID() const -{ - return PluginLLMCore::ProviderID::OpenAI; -} - -PluginLLMCore::ProviderCapabilities OpenAIProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::ModelListing - | PluginLLMCore::ProviderCapability::Thinking; -} - -::LLMQore::BaseClient *OpenAIProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/OpenAIProvider.hpp b/providers/OpenAIProvider.hpp deleted file mode 100644 index 570c50d..0000000 --- a/providers/OpenAIProvider.hpp +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Providers { - -class OpenAIProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit OpenAIProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - -private: - ::LLMQore::OpenAIClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/OpenAIResponsesProvider.cpp b/providers/OpenAIResponsesProvider.cpp deleted file mode 100644 index 455b61d..0000000 --- a/providers/OpenAIResponsesProvider.cpp +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "OpenAIResponsesProvider.hpp" -#include -#include "tools/ToolsRegistration.hpp" - -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" - -#include -#include -#include - -namespace QodeAssist::Providers { - -OpenAIResponsesProvider::OpenAIResponsesProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::OpenAIResponsesClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString OpenAIResponsesProvider::name() const -{ - return "OpenAI (Responses API)"; -} - -QString OpenAIResponsesProvider::apiKey() const -{ - return Settings::providerSettings().openAiApiKey(); -} - -QString OpenAIResponsesProvider::url() const -{ - return "https://api.openai.com/v1"; -} - -void OpenAIResponsesProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - request["max_output_tokens"] = settings.maxTokens(); - - if (settings.useTopP()) { - request["top_p"] = settings.topP(); - } - }; - - auto applyThinkingMode = [&request](const auto &settings) { - QString effortStr = settings.openAIResponsesReasoningEffort.stringValue().toLower(); - if (effortStr.isEmpty()) { - effortStr = "medium"; - } - - QJsonObject reasoning; - reasoning["effort"] = effortStr; - request["reasoning"] = reasoning; - request["max_output_tokens"] = settings.thinkingMaxTokens(); - request["store"] = true; - - QJsonArray include; - include.append("reasoning.encrypted_content"); - request["include"] = include; - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - const auto &qrSettings = Settings::quickRefactorSettings(); - applyModelParams(qrSettings); - - if (isThinkingEnabled) { - applyThinkingMode(qrSettings); - } - } else { - const auto &chatSettings = Settings::chatAssistantSettings(); - applyModelParams(chatSettings); - - if (isThinkingEnabled) { - applyThinkingMode(chatSettings); - } - } - - if (isToolsEnabled) { - const auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE(QString("Added %1 tools to OpenAI Responses request") - .arg(toolsDefinitions.size())); - } - } - - request["stream"] = true; -} - -QFuture> OpenAIResponsesProvider::getInstalledModels(const QString &baseUrl) -{ - m_client->setUrl(baseUrl); - m_client->setApiKey(apiKey()); - return m_client->listModels().then([](const QList &models) { - QList filtered; - static const QStringList modelPrefixes = {"gpt-5", "o1", "o2", "o3", "o4"}; - for (const QString &modelId : models) { - for (const QString &prefix : modelPrefixes) { - if (modelId.contains(prefix)) { - filtered.append(modelId); - break; - } - } - } - return filtered; - }); -} - -PluginLLMCore::ProviderID OpenAIResponsesProvider::providerID() const -{ - return PluginLLMCore::ProviderID::OpenAIResponses; -} - -PluginLLMCore::ProviderCapabilities OpenAIResponsesProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Thinking - | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::ModelListing; -} - -::LLMQore::BaseClient *OpenAIResponsesProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/OpenAIResponsesProvider.hpp b/providers/OpenAIResponsesProvider.hpp deleted file mode 100644 index 3d9a1d0..0000000 --- a/providers/OpenAIResponsesProvider.hpp +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Providers { - -class OpenAIResponsesProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit OpenAIResponsesProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - -private: - ::LLMQore::OpenAIResponsesClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/OpenRouterAIProvider.cpp b/providers/OpenRouterAIProvider.cpp deleted file mode 100644 index 6f528ca..0000000 --- a/providers/OpenRouterAIProvider.cpp +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "OpenRouterAIProvider.hpp" - -#include "settings/ProviderSettings.hpp" - -#include -#include -#include -#include - -namespace QodeAssist::Providers { - -OpenRouterProvider::OpenRouterProvider(QObject *parent) - : OpenAICompatProvider(parent) -{} - -QString OpenRouterProvider::name() const -{ - return "OpenRouter"; -} - -QString OpenRouterProvider::apiKey() const -{ - return Settings::providerSettings().openRouterApiKey(); -} - -QString OpenRouterProvider::url() const -{ - return "https://openrouter.ai/api"; -} - -PluginLLMCore::ProviderID OpenRouterProvider::providerID() const -{ - return PluginLLMCore::ProviderID::OpenRouter; -} - -} // namespace QodeAssist::Providers diff --git a/providers/OpenRouterAIProvider.hpp b/providers/OpenRouterAIProvider.hpp deleted file mode 100644 index 092caa0..0000000 --- a/providers/OpenRouterAIProvider.hpp +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include "providers/OpenAICompatProvider.hpp" - -namespace QodeAssist::Providers { - -class OpenRouterProvider : public OpenAICompatProvider -{ -public: - explicit OpenRouterProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - QString apiKey() const override; - PluginLLMCore::ProviderID providerID() const override; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/ProviderUrlUtils.hpp b/providers/ProviderUrlUtils.hpp deleted file mode 100644 index eaf56a0..0000000 --- a/providers/ProviderUrlUtils.hpp +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -namespace QodeAssist::Providers { - -// LM Studio presents its OpenAI-compatible API as /v1/..., while the -// OpenAI-style clients expect the base URL to already include /v1. Accept the -// configured URL either with or without the /v1 suffix and return it normalized. -inline QString ensureOpenAIV1Base(const QString &url) -{ - QString base = url.trimmed(); - while (base.endsWith(QLatin1Char('/'))) - base.chop(1); - if (!base.endsWith(QStringLiteral("/v1"))) - base += QStringLiteral("/v1"); - return base; -} - -} // namespace QodeAssist::Providers diff --git a/providers/Providers.hpp b/providers/Providers.hpp deleted file mode 100644 index 9358622..0000000 --- a/providers/Providers.hpp +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include "pluginllmcore/ProvidersManager.hpp" -#include "providers/ClaudeProvider.hpp" -#include "providers/DeepSeekProvider.hpp" -#include "providers/CodestralProvider.hpp" -#include "providers/GoogleAIProvider.hpp" -#include "providers/LMStudioProvider.hpp" -#include "providers/LMStudioResponsesProvider.hpp" -#include "providers/LlamaCppProvider.hpp" -#include "providers/MistralAIProvider.hpp" -#include "providers/OllamaCompatProvider.hpp" -#include "providers/OllamaProvider.hpp" -#include "providers/OpenAICompatProvider.hpp" -#include "providers/OpenAIProvider.hpp" -#include "providers/OpenAIResponsesProvider.hpp" -#include "providers/OpenRouterAIProvider.hpp" -#include "providers/QwenProvider.hpp" -#include "providers/QwenResponsesProvider.hpp" - -namespace QodeAssist::Providers { - -inline void registerProviders() -{ - auto &providerManager = PluginLLMCore::ProvidersManager::instance(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); - providerManager.registerProvider(); -} - -} // namespace QodeAssist::Providers diff --git a/providers/QwenProvider.cpp b/providers/QwenProvider.cpp deleted file mode 100644 index b82b4bc..0000000 --- a/providers/QwenProvider.cpp +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "QwenProvider.hpp" - -#include -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "tools/ToolsRegistration.hpp" - -#include -#include -#include - -namespace QodeAssist::Providers { - -QwenProvider::QwenProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::OpenAIClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString QwenProvider::name() const -{ - return "Qwen (OpenAI)"; -} - -QString QwenProvider::apiKey() const -{ - return Settings::providerSettings().qwenApiKey(); -} - -QString QwenProvider::url() const -{ - return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; -} - -QFuture> QwenProvider::getInstalledModels(const QString &url) -{ - m_client->setUrl(url); - m_client->setApiKey(apiKey()); - return m_client->listModels(); -} - -PluginLLMCore::ProviderID QwenProvider::providerID() const -{ - return PluginLLMCore::ProviderID::Qwen; -} - -PluginLLMCore::ProviderCapabilities QwenProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Image - | PluginLLMCore::ProviderCapability::Thinking - | PluginLLMCore::ProviderCapability::ModelListing; -} - -void QwenProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - request["max_tokens"] = settings.maxTokens(); - request["temperature"] = settings.temperature(); - - if (settings.useTopP()) - request["top_p"] = settings.topP(); - if (settings.useTopK()) - request["top_k"] = settings.topK(); - if (settings.useFrequencyPenalty()) - request["frequency_penalty"] = settings.frequencyPenalty(); - if (settings.usePresencePenalty()) - request["presence_penalty"] = settings.presencePenalty(); - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - applyModelParams(Settings::quickRefactorSettings()); - } else { - applyModelParams(Settings::chatAssistantSettings()); - } - - if (isToolsEnabled) { - auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE(QString("Added %1 tools to Qwen request").arg(toolsDefinitions.size())); - } - } -} - -::LLMQore::BaseClient *QwenProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/QwenProvider.hpp b/providers/QwenProvider.hpp deleted file mode 100644 index a7d5d49..0000000 --- a/providers/QwenProvider.hpp +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Providers { - -class QwenProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit QwenProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - -private: - ::LLMQore::OpenAIClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/providers/QwenResponsesProvider.cpp b/providers/QwenResponsesProvider.cpp deleted file mode 100644 index 1c7a31c..0000000 --- a/providers/QwenResponsesProvider.cpp +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "QwenResponsesProvider.hpp" - -#include -#include "logger/Logger.hpp" -#include "settings/ChatAssistantSettings.hpp" -#include "settings/CodeCompletionSettings.hpp" -#include "settings/GeneralSettings.hpp" -#include "settings/ProviderSettings.hpp" -#include "settings/QuickRefactorSettings.hpp" -#include "tools/ToolsRegistration.hpp" - -#include -#include -#include - -namespace QodeAssist::Providers { - -QwenResponsesProvider::QwenResponsesProvider(QObject *parent) - : PluginLLMCore::Provider(parent) - , m_client(new ::LLMQore::OpenAIResponsesClient(QString(), QString(), QString(), this)) -{ - Tools::registerQodeAssistTools(m_client->tools()); -} - -QString QwenResponsesProvider::name() const -{ - return "Qwen (OpenAI Response)"; -} - -QString QwenResponsesProvider::apiKey() const -{ - return Settings::providerSettings().qwenApiKey(); -} - -QString QwenResponsesProvider::url() const -{ - return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; -} - -void QwenResponsesProvider::prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) -{ - if (!prompt->isSupportProvider(providerID())) { - LOG_MESSAGE(QString("Template %1 doesn't support %2 provider").arg(name(), prompt->name())); - } - - prompt->prepareRequest(request, context); - - auto applyModelParams = [&request](const auto &settings) { - request["max_output_tokens"] = settings.maxTokens(); - - if (settings.useTopP()) { - request["top_p"] = settings.topP(); - } - }; - - auto applyThinkingMode = [&request](const auto &settings) { - QString effortStr = settings.openAIResponsesReasoningEffort.stringValue().toLower(); - if (effortStr.isEmpty()) { - effortStr = "medium"; - } - - QJsonObject reasoning; - reasoning["effort"] = effortStr; - request["reasoning"] = reasoning; - request["max_output_tokens"] = settings.thinkingMaxTokens(); - request["store"] = true; - - QJsonArray include; - include.append("reasoning.encrypted_content"); - request["include"] = include; - }; - - if (type == PluginLLMCore::RequestType::CodeCompletion) { - applyModelParams(Settings::codeCompletionSettings()); - } else if (type == PluginLLMCore::RequestType::QuickRefactoring) { - const auto &qrSettings = Settings::quickRefactorSettings(); - applyModelParams(qrSettings); - - if (isThinkingEnabled) { - applyThinkingMode(qrSettings); - } - } else { - const auto &chatSettings = Settings::chatAssistantSettings(); - applyModelParams(chatSettings); - - if (isThinkingEnabled) { - applyThinkingMode(chatSettings); - } - } - - if (isToolsEnabled) { - const auto toolsDefinitions = m_client->tools()->getToolsDefinitions(); - if (!toolsDefinitions.isEmpty()) { - request["tools"] = toolsDefinitions; - LOG_MESSAGE( - QString("Added %1 tools to Qwen Responses request").arg(toolsDefinitions.size())); - } - } - - request["stream"] = true; -} - -QFuture> QwenResponsesProvider::getInstalledModels(const QString &url) -{ - m_client->setUrl(url); - m_client->setApiKey(apiKey()); - return m_client->listModels(); -} - -PluginLLMCore::ProviderID QwenResponsesProvider::providerID() const -{ - return PluginLLMCore::ProviderID::OpenAIResponses; -} - -PluginLLMCore::ProviderCapabilities QwenResponsesProvider::capabilities() const -{ - return PluginLLMCore::ProviderCapability::Tools | PluginLLMCore::ProviderCapability::Thinking - | PluginLLMCore::ProviderCapability::Image | PluginLLMCore::ProviderCapability::ModelListing; -} - -::LLMQore::BaseClient *QwenResponsesProvider::client() const -{ - return m_client; -} - -} // namespace QodeAssist::Providers diff --git a/providers/QwenResponsesProvider.hpp b/providers/QwenResponsesProvider.hpp deleted file mode 100644 index 271716a..0000000 --- a/providers/QwenResponsesProvider.hpp +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Providers { - -class QwenResponsesProvider : public PluginLLMCore::Provider -{ - Q_OBJECT -public: - explicit QwenResponsesProvider(QObject *parent = nullptr); - - QString name() const override; - QString url() const override; - void prepareRequest( - QJsonObject &request, - PluginLLMCore::PromptTemplate *prompt, - PluginLLMCore::ContextData context, - PluginLLMCore::RequestType type, - bool isToolsEnabled, - bool isThinkingEnabled) override; - QFuture> getInstalledModels(const QString &url) override; - PluginLLMCore::ProviderID providerID() const override; - PluginLLMCore::ProviderCapabilities capabilities() const override; - - ::LLMQore::BaseClient *client() const override; - QString apiKey() const override; - -private: - ::LLMQore::OpenAIResponsesClient *m_client; -}; - -} // namespace QodeAssist::Providers diff --git a/qodeassist.cpp b/qodeassist.cpp index c678a7e..c94cbbe 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -34,8 +34,8 @@ #include #include -#include "ConfigurationManager.hpp" #include "QodeAssistClient.hpp" +#include "QuickRefactorHandler.hpp" #include "UpdateStatusWidget.hpp" #include "Version.hpp" #include "chat/ChatEditor.hpp" @@ -43,33 +43,26 @@ #include "chat/ChatOutputPane.h" #include "chat/NavigationPanel.hpp" #include "context/DocumentReaderQtCreator.hpp" -#include "pluginllmcore/PromptProviderFim.hpp" -#include "pluginllmcore/ProvidersManager.hpp" #include "logger/RequestPerformanceLogger.hpp" #include "mcp/McpClientsManager.hpp" #include "mcp/McpServerManager.hpp" #include "sources/skills/SkillsManager.hpp" #include "tools/ToolsRegistration.hpp" -#include "providers/Providers.hpp" #include "settings/ChatAssistantSettings.hpp" #include "settings/GeneralSettings.hpp" #include "settings/ProjectSettingsPanel.hpp" -#ifdef QODEASSIST_EXPERIMENTAL #include "settings/AgentsSettingsPage.hpp" #include "settings/ProvidersSettingsPage.hpp" -#include "sources/settings/AgentPipelinesPage.hpp" -#endif #include "settings/QuickRefactorSettings.hpp" #include "settings/SettingsConstants.hpp" -#ifdef QODEASSIST_EXPERIMENTAL #include "ProviderInstanceFactory.hpp" #include "ProviderLauncher.hpp" #include "ProviderSecretsStore.hpp" #include -#endif -#include "templates/Templates.hpp" +#include +#include #include "widgets/CustomInstructionsManager.hpp" #include "widgets/QuickRefactorDialog.hpp" #include @@ -78,6 +71,7 @@ #include #include #include +#include #include #include #include @@ -99,7 +93,6 @@ class QodeAssistPlugin final : public ExtensionSystem::IPlugin public: QodeAssistPlugin() : m_updater(new PluginUpdater(this)) - , m_promptProvider(PluginLLMCore::PromptTemplateManager::instance()) {} ~QodeAssistPlugin() final @@ -146,9 +139,6 @@ public: loadTranslations(); - Providers::registerProviders(); - Templates::registerTemplates(); - CustomInstructionsManager::instance().loadInstructions(); Utils::Icon QCODEASSIST_ICON( @@ -185,15 +175,36 @@ public: m_sessionFileRegistry = new Chat::SessionFileRegistry{this}; m_skillsManager = new Skills::SkillsManager{this}; + Settings::setupProjectPanel(); + + Providers::registerBuiltinProviders(); + m_providerInstanceFactory = new Providers::ProviderInstanceFactory(this); + m_providerSecretsStore = new Providers::ProviderSecretsStore(this); + m_providerLauncher = new Providers::ProviderLauncher(this); + m_providersPageNavigator = new Settings::ProvidersPageNavigator(this); + m_providersOptionsPage = Settings::createProvidersSettingsPage( + m_providerInstanceFactory, + m_providerSecretsStore, + m_providerLauncher, + m_providersPageNavigator); + + m_agentFactory = new AgentFactory(m_providerInstanceFactory, m_providerSecretsStore, this); + m_sessionManager = new SessionManager(m_agentFactory, 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); - } - } + auto &contributors = m_sessionManager->toolContributors(); + contributors.add([](::LLMQore::ToolsManager *tools) { + Tools::registerQodeAssistTools(tools); + }); + contributors.add([skills = m_skillsManager](::LLMQore::ToolsManager *tools) { + if (skills) + Tools::registerSkillTool(tools, skills); + }); + contributors.add([](::LLMQore::ToolsManager *tools) { + Mcp::McpClientsManager::instance().registerToolsOn(tools); + }); } + m_engine->rootContext()->setContextProperty("agentFactory", m_agentFactory); + m_engine->rootContext()->setContextProperty("sessionManager", m_sessionManager); if (Settings::chatAssistantSettings().enableChatInBottomToolBar()) { m_chatOutputPane = new Chat::ChatOutputPane{ @@ -206,29 +217,12 @@ public: m_chatEditorFactory = new Chat::ChatEditorFactory{ m_engine, m_sessionFileRegistry, m_skillsManager}; - Settings::setupProjectPanel(); - ConfigurationManager::instance().init(); - -#ifdef QODEASSIST_EXPERIMENTAL - m_providerInstanceFactory = new Providers::ProviderInstanceFactory(this); - m_providerSecretsStore = new Providers::ProviderSecretsStore(this); - m_providerLauncher = new Providers::ProviderLauncher(this); - m_providersPageNavigator = new Settings::ProvidersPageNavigator(this); - m_providersOptionsPage = Settings::createProvidersSettingsPage( - m_providerInstanceFactory, - m_providerSecretsStore, - m_providerLauncher, - m_providersPageNavigator); - - m_agentFactory = new AgentFactory(m_providerInstanceFactory, m_providerSecretsStore, this); m_agentsPageNavigator = new Settings::AgentsPageNavigator(this); m_agentsOptionsPage = Settings::createAgentsSettingsPage( m_agentFactory, m_agentsPageNavigator); - m_agentPipelinesPageNavigator = new Settings::AgentPipelinesPageNavigator(this); - m_agentPipelinesOptionsPage = Settings::createAgentPipelinesSettingsPage( - m_agentFactory, m_agentPipelinesPageNavigator, m_agentsPageNavigator); -#endif + Settings::generalSettings().setAgentPipelinesContext( + m_agentFactory, m_agentsPageNavigator); m_mcpServerManager = new Mcp::McpServerManager(this); m_mcpServerManager->init(); @@ -248,8 +242,10 @@ public: quickRefactorAction.addOnTriggered(this, [this] { if (auto editor = TextEditor::TextEditorWidget::currentTextEditorWidget()) { if (m_qodeAssistClient && m_qodeAssistClient->reachable()) { - QuickRefactorDialog - dialog(Core::ICore::dialogParent(), m_lastRefactorInstructions); + const bool refactorReady + = !QuickRefactorHandler::configuredAgent(m_agentFactory).isEmpty(); + QuickRefactorDialog dialog( + Core::ICore::dialogParent(), m_lastRefactorInstructions, refactorReady); if (dialog.exec() == QDialog::Accepted) { QString instructions = dialog.instructions(); @@ -348,10 +344,12 @@ public: m_qodeAssistClient = new QodeAssistClient(new LLMClientInterface( Settings::generalSettings(), Settings::codeCompletionSettings(), - PluginLLMCore::ProvidersManager::instance(), - &m_promptProvider, + *m_agentFactory, + *m_sessionManager, m_documentReader, m_performanceLogger)); + m_qodeAssistClient->setSessionManager(m_sessionManager); + m_qodeAssistClient->setAgentFactory(m_agentFactory); } bool delayedInitialize() final @@ -509,7 +507,6 @@ private: } QPointer m_qodeAssistClient; - PluginLLMCore::PromptProviderFim m_promptProvider; Context::DocumentReaderQtCreator m_documentReader; RequestPerformanceLogger m_performanceLogger; QPointer m_chatOutputPane; @@ -524,18 +521,15 @@ private: QPointer m_mcpServerManager; QPointer m_engine; QPointer m_skillsManager; -#ifdef QODEASSIST_EXPERIMENTAL QPointer m_providerInstanceFactory; QPointer m_providerSecretsStore; QPointer m_providerLauncher; QPointer m_providersPageNavigator; std::unique_ptr m_providersOptionsPage; QPointer m_agentFactory; + QPointer m_sessionManager; QPointer m_agentsPageNavigator; std::unique_ptr m_agentsOptionsPage; - QPointer m_agentPipelinesPageNavigator; - std::unique_ptr m_agentPipelinesOptionsPage; -#endif }; } // namespace QodeAssist::Internal diff --git a/settings/AgentDetailPane.cpp b/settings/AgentDetailPane.cpp index dba1b20..476cadf 100644 --- a/settings/AgentDetailPane.cpp +++ b/settings/AgentDetailPane.cpp @@ -4,10 +4,14 @@ #include "AgentDetailPane.hpp" +#include "AgentModelDialog.hpp" #include "SectionBox.hpp" #include "SettingsTheme.hpp" #include "SettingsUiBuilders.hpp" +#include + +#include #include #include @@ -21,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -79,18 +84,24 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) m_path->setFont(monospaceFont(11)); m_path->setTextInteractionFlags(Qt::TextSelectableByMouse); QPalette pp = m_path->palette(); - pp.setColor(QPalette::WindowText, pp.color(QPalette::Mid)); + pp.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); m_path->setPalette(pp); m_openBtn = new QPushButton(tr("Open in editor"), this); m_dupBtn = new QPushButton(tr("Duplicate…"), this); m_deleteBtn = new QPushButton(tr("Delete"), this); - connect(m_openBtn, &QPushButton::clicked, this, - [this] { if (m_current) emit openInEditorRequested(*m_current); }); - connect(m_dupBtn, &QPushButton::clicked, this, - [this] { if (m_current) emit customizeRequested(*m_current); }); - connect(m_deleteBtn, &QPushButton::clicked, this, - [this] { if (m_current) emit deleteRequested(*m_current); }); + connect(m_openBtn, &QPushButton::clicked, this, [this] { + if (m_current) + emit openInEditorRequested(*m_current); + }); + connect(m_dupBtn, &QPushButton::clicked, this, [this] { + if (m_current) + emit customizeRequested(*m_current); + }); + connect(m_deleteBtn, &QPushButton::clicked, this, [this] { + if (m_current) + emit deleteRequested(*m_current); + }); auto *actions = new QHBoxLayout; actions->setContentsMargins(0, 0, 0, 0); @@ -152,50 +163,80 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) idForm = FormBuilder(idGrid, row + 1); } idForm.row(tr("Description:"), m_descriptionEdit); - idForm.row(tr("Tags:"), m_tagsValue, - tr("Comma-separated. Free-form — used to filter and " - "group the agent list.")); + idForm.row( + tr("Tags:"), + m_tagsValue, + tr("Comma-separated. Free-form — used to filter and " + "group the agent list.")); identity->bodyLayout()->addLayout(idGrid); - auto *roleSection = new SectionBox(tr("System role"), this); - auto *roleHint = makeHintLabel( - tr("Prepended to every request as the system message.")); - m_roleText = new QPlainTextEdit(this); - m_roleText->setReadOnly(true); - m_roleText->setMinimumHeight(120); - roleSection->bodyLayout()->addWidget(roleHint); - roleSection->bodyLayout()->addWidget(m_roleText); - - auto *contextSection = new SectionBox(tr("Context"), this); - auto *contextHint = makeHintLabel( - tr("Jinja2 template rendered with ContextManager bindings into the " - "agent.context system-prompt layer. Empty = no context block.")); - m_contextText = new QPlainTextEdit(this); - m_contextText->setReadOnly(true); - m_contextText->setFont(monospaceFont(11)); - m_contextText->setMinimumHeight(120); - contextSection->bodyLayout()->addWidget(contextHint); - contextSection->bodyLayout()->addWidget(m_contextText); - auto *connection = new SectionBox(tr("Connection"), this); m_providerCombo = new QComboBox(this); m_providerCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents); m_providerCombo->setEnabled(false); + connect(m_providerCombo, &QComboBox::activated, this, [this](int index) { + onChangeProvider(index); + }); + + m_providerResetBtn = new QPushButton(tr("Reset"), this); + m_providerResetBtn->setToolTip( + tr("Remove the provider override and restore the agent's default")); + m_providerResetBtn->setVisible(false); + connect(m_providerResetBtn, &QPushButton::clicked, this, [this] { onResetProvider(); }); + + auto *providerHolder = new QWidget(this); + auto *providerRow = new QHBoxLayout(providerHolder); + providerRow->setContentsMargins(0, 0, 0, 0); + providerRow->setSpacing(6); + providerRow->addWidget(m_providerCombo, 1); + providerRow->addWidget(m_providerResetBtn); + m_endpointValue = makeReadOnlyLine(true); + m_modelValue = makeReadOnlyLine(true); + m_modelValue->setPlaceholderText(tr("(set a model)")); + + m_modelChangeBtn = new QPushButton(tr("Change…"), this); + m_modelChangeBtn->setToolTip(tr("Pick a model from the provider or type one")); + m_modelChangeBtn->setEnabled(false); + connect(m_modelChangeBtn, &QPushButton::clicked, this, [this] { onChangeModel(); }); + + m_modelResetBtn = new QPushButton(tr("Reset"), this); + m_modelResetBtn->setToolTip(tr("Remove the model override and restore the agent's default")); + m_modelResetBtn->setVisible(false); + connect(m_modelResetBtn, &QPushButton::clicked, this, [this] { onResetModel(); }); + + auto *modelHolder = new QWidget(this); + auto *modelRow = new QHBoxLayout(modelHolder); + modelRow->setContentsMargins(0, 0, 0, 0); + modelRow->setSpacing(6); + modelRow->addWidget(m_modelValue, 1); + modelRow->addWidget(m_modelChangeBtn); + modelRow->addWidget(m_modelResetBtn); auto *connGrid = new QGridLayout; connGrid->setContentsMargins(0, 0, 0, 0); connGrid->setHorizontalSpacing(8); connGrid->setVerticalSpacing(4); FormBuilder(connGrid) - .row(tr("Provider:"), m_providerCombo, - tr("The provider instance this agent uses. URL is " - "inherited from the instance.")) - .row(tr("Endpoint:"), m_endpointValue, - tr("Appended to the provider's URL. Blank uses the " - "provider default.")) - .row(tr("Model:"), m_modelValue); + .row( + tr("Provider:"), + providerHolder, + tr("The provider instance this agent uses. URL is " + "inherited from the instance. Switching it is saved as a " + "per-agent override; Reset restores the agent's default.")) + .row( + tr("Endpoint:"), + m_endpointValue, + tr("Appended to the provider's URL. Blank uses the " + "provider default.")) + .row( + tr("Model:"), + modelHolder, + tr("Model sent to the provider. Click Change… to pick from the " + "provider's available models or type one. The choice is saved " + "as a per-agent override in settings; Reset restores the " + "agent's default.")); connection->bodyLayout()->addLayout(connGrid); m_effectiveUrl = new QLabel(this); @@ -215,34 +256,13 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) matchGrid->setContentsMargins(0, 0, 0, 0); matchGrid->setHorizontalSpacing(8); matchGrid->setVerticalSpacing(4); - FormBuilder(matchGrid).row(tr("File patterns:"), m_filePatternsValue, - tr("Globs, comma-separated. Empty matches every file.")); + FormBuilder(matchGrid).row( + tr("File patterns:"), + m_filePatternsValue, + tr("Globs, comma-separated. Empty matches every file.")); match->bodyLayout()->addWidget(matchHint); match->bodyLayout()->addLayout(matchGrid); - auto *templ = new SectionBox(tr("Template"), this); - auto *templHint = makeHintLabel( - tr("Jinja2 template (via inja) rendered to the request body. " - "Built-in context: ctx.prefix, ctx.suffix, ctx.history, " - "ctx.system_prompt, agent.model.")); - m_messageFormat = new QPlainTextEdit(this); - m_messageFormat->setReadOnly(true); - m_messageFormat->setFont(monospaceFont(11)); - m_messageFormat->setMinimumHeight(140); - - templ->bodyLayout()->addWidget(templHint); - auto *mfLabel = new QLabel(tr("message_format:"), this); - templ->bodyLayout()->addWidget(mfLabel); - templ->bodyLayout()->addWidget(m_messageFormat); - - m_diagnostics = new SectionBox(tr("Load errors"), this); - m_diagnosticsView = new QPlainTextEdit(this); - m_diagnosticsView->setReadOnly(true); - m_diagnosticsView->setMaximumHeight(110); - m_diagnosticsView->setFont(monospaceFont(11)); - m_diagnostics->bodyLayout()->addWidget(m_diagnosticsView); - m_diagnostics->setVisible(false); - m_rawToggle = new QToolButton(this); m_rawToggle->setText(tr("▸ Show raw TOML")); m_rawToggle->setCursor(Qt::PointingHandCursor); @@ -267,10 +287,6 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) root->addWidget(identity); root->addWidget(connection); root->addWidget(match); - root->addWidget(templ); - root->addWidget(roleSection); - root->addWidget(contextSection); - root->addWidget(m_diagnostics); root->addWidget(m_rawToggle, 0, Qt::AlignLeft); root->addWidget(m_rawToml); root->addStretch(1); @@ -286,6 +302,11 @@ void AgentDetailPane::setInstanceFactory(Providers::ProviderInstanceFactory *fac populateProviderCombo(); } +void AgentDetailPane::setAgentFactory(AgentFactory *factory) +{ + m_agentFactory = factory; +} + void AgentDetailPane::populateProviderCombo() { if (m_providerComboPopulated) @@ -294,13 +315,105 @@ void AgentDetailPane::populateProviderCombo() m_providerComboHasSentinel = false; if (m_instanceFactory) { for (const auto &inst : m_instanceFactory->instances()) { - m_providerCombo->addItem( - QStringLiteral("%1 (%2)").arg(inst.name, inst.clientApi), inst.name); + m_providerCombo + ->addItem(QStringLiteral("%1 (%2)").arg(inst.name, inst.clientApi), inst.name); } } m_providerComboPopulated = true; } +void AgentDetailPane::onChangeModel() +{ + if (!m_agentFactory || !m_current) + return; + + const QString name = m_current->name; + AgentModelDialog dialog(m_agentFactory, name, m_current->model, this); + if (dialog.exec() != QDialog::Accepted) + return; + + const QString model = dialog.selectedModel(); + if (model == m_current->model) + return; + + QString err; + if (!m_agentFactory->setAgentModelOverride(name, model, &err)) { + QMessageBox::warning(this, tr("Set model"), err); + return; + } + if (const AgentConfig *cfg = m_agentFactory->configByName(name)) + setAgent(*cfg); +} + +void AgentDetailPane::onResetModel() +{ + if (!m_agentFactory || !m_current) + return; + if (m_agentFactory->agentModelOverride(m_current->name).isEmpty()) + return; + + const QString name = m_current->name; + QString err; + if (!m_agentFactory->setAgentModelOverride(name, QString(), &err)) { + QMessageBox::warning(this, tr("Reset model"), err); + return; + } + if (const AgentConfig *cfg = m_agentFactory->configByName(name)) + setAgent(*cfg); +} + +void AgentDetailPane::onChangeProvider(int index) +{ + if (!m_agentFactory || !m_current || index < 0) + return; + + const QString name = m_current->name; + const QString selected = m_providerCombo->itemData(index).toString(); + if (selected.isEmpty() || selected == m_current->providerInstance) + return; + + const auto answer = QMessageBox::warning( + this, + tr("Change provider"), + tr("Changing the agent's default provider may make it behave incorrectly — " + "the agent template is tuned for its original provider.\n\n" + "Switch '%1' to provider '%2'?") + .arg(name, selected), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No); + if (answer != QMessageBox::Yes) { + const int cur = m_providerCombo->findData(m_current->providerInstance); + if (cur >= 0) + m_providerCombo->setCurrentIndex(cur); + return; + } + + QString err; + if (!m_agentFactory->setAgentProviderOverride(name, selected, &err)) { + QMessageBox::warning(this, tr("Change provider"), err); + return; + } + if (const AgentConfig *cfg = m_agentFactory->configByName(name)) + setAgent(*cfg); +} + +void AgentDetailPane::onResetProvider() +{ + if (!m_agentFactory || !m_current) + return; + if (m_agentFactory->agentProviderOverride(m_current->name).isEmpty()) + return; + + const QString name = m_current->name; + QString err; + if (!m_agentFactory->setAgentProviderOverride(name, QString(), &err)) { + QMessageBox::warning(this, tr("Reset provider"), err); + return; + } + if (const AgentConfig *cfg = m_agentFactory->configByName(name)) + setAgent(*cfg); +} + void AgentDetailPane::setAgent(const AgentConfig &cfg) { m_currentStorage = cfg; @@ -309,9 +422,8 @@ void AgentDetailPane::setAgent(const AgentConfig &cfg) m_name->setText(cfg.name); m_path->setText(cfg.sourcePath); - m_description->setText(cfg.description.isEmpty() - ? tr("No description provided.") - : cfg.description); + m_description->setText( + cfg.description.isEmpty() ? tr("No description provided.") : cfg.description); m_nameValue->setText(cfg.name); if (cfg.extendsName.isEmpty()) { @@ -342,36 +454,41 @@ void AgentDetailPane::setAgent(const AgentConfig &cfg) m_providerCombo->setCurrentIndex(idx); } else if (!cfg.providerInstance.isEmpty()) { m_providerCombo->insertItem( - 0, tr("%1 (missing — not in provider library)") - .arg(cfg.providerInstance), + 0, + tr("%1 (missing — not in provider library)").arg(cfg.providerInstance), cfg.providerInstance); m_providerCombo->setCurrentIndex(0); m_providerComboHasSentinel = true; } + m_providerCombo->setEnabled(m_agentFactory != nullptr); + const bool hasProviderOverride = m_agentFactory + && !m_agentFactory->agentProviderOverride(cfg.name).isEmpty(); + m_providerResetBtn->setVisible(hasProviderOverride); + m_providerCombo->setToolTip( + hasProviderOverride + ? tr("Overridden in settings. Reset to use the agent's default provider.") + : QString()); + m_endpointValue->setText(cfg.endpoint); m_endpointValue->setPlaceholderText(tr("(provider default)")); m_modelValue->setText(cfg.model); + m_modelChangeBtn->setEnabled(m_agentFactory != nullptr); + const bool hasModelOverride = m_agentFactory + && !m_agentFactory->agentModelOverride(cfg.name).isEmpty(); + m_modelResetBtn->setVisible(hasModelOverride); + m_modelValue->setToolTip( + hasModelOverride ? tr("Overridden in settings. Reset to use the agent's default model.") + : QString()); const QString eff = resolvedUrl + cfg.endpoint; m_effectiveUrl->setText( - eff.isEmpty() - ? tr("# effective request line\n(unknown — provider instance not found)") - : QStringLiteral("# %1\nPOST %2") - .arg(tr("effective request line"), eff)); - - m_roleText->setPlainText( - cfg.role.isEmpty() ? tr("(no system role set)") : cfg.role); - m_contextText->setPlainText( - cfg.context.isEmpty() ? tr("(no context block)") : cfg.context); + eff.isEmpty() ? tr("# effective request line\n(unknown — provider instance not found)") + : QStringLiteral("# %1\nPOST %2").arg(tr("effective request line"), eff)); m_filePatternsValue->setText(cfg.match.filePatterns.join(QStringLiteral(", "))); m_filePatternsValue->setPlaceholderText(tr("(matches every file)")); - m_messageFormat->setPlainText( - cfg.messageFormat.isEmpty() ? tr("(inherited from parent / none)") - : cfg.messageFormat); - const FileReadResult raw = readFileTextCapped(cfg.sourcePath, kRawTomlMaxBytes); switch (raw.status) { case FileReadStatus::Ok: @@ -391,12 +508,12 @@ void AgentDetailPane::setAgent(const AgentConfig &cfg) } m_openBtn->setEnabled(user); - m_openBtn->setToolTip(user ? QString() - : tr("Bundled agents are read-only — " - "duplicate to edit.")); + m_openBtn->setToolTip( + user ? QString() + : tr("Bundled agents are read-only — " + "duplicate to edit.")); m_deleteBtn->setEnabled(user); - m_deleteBtn->setToolTip(user ? QString() - : tr("Bundled agents cannot be deleted.")); + m_deleteBtn->setToolTip(user ? QString() : tr("Bundled agents cannot be deleted.")); m_dupBtn->setEnabled(true); applyCodePalette(); } @@ -418,30 +535,20 @@ void AgentDetailPane::clear() m_providerComboHasSentinel = false; } m_providerCombo->setCurrentIndex(-1); + m_providerCombo->setEnabled(false); + m_providerResetBtn->setVisible(false); m_endpointValue->clear(); m_modelValue->clear(); + m_modelChangeBtn->setEnabled(false); + m_modelResetBtn->setVisible(false); m_effectiveUrl->clear(); - m_roleText->clear(); - m_contextText->clear(); m_filePatternsValue->clear(); - m_messageFormat->clear(); m_rawToml->clear(); m_openBtn->setEnabled(false); m_dupBtn->setEnabled(false); m_deleteBtn->setEnabled(false); } -void AgentDetailPane::setLoadDiagnostics(const QStringList &errors, const QStringList &warnings) -{ - QStringList lines; - for (const QString &e : errors) - lines << tr("error: %1").arg(e); - for (const QString &w : warnings) - lines << tr("warning: %1").arg(w); - m_diagnostics->setVisible(!lines.isEmpty()); - m_diagnosticsView->setPlainText(lines.join(QLatin1Char('\n'))); -} - void AgentDetailPane::changeEvent(QEvent *event) { QWidget::changeEvent(event); @@ -463,14 +570,14 @@ QLineEdit *AgentDetailPane::makeReadOnlyLine(bool mono) void AgentDetailPane::applyCodePalette() { QScopedValueRollback guard(m_inApplyPalette, true); - const Theme theme = themeFor(palette()); + const QColor codeBg = Utils::creatorColor(Utils::Theme::BackgroundColorNormal); QPalette p = m_effectiveUrl->palette(); - p.setColor(QPalette::Window, QColor(theme.codeBg)); - p.setColor(QPalette::WindowText, palette().color(QPalette::Text)); + p.setColor(QPalette::Window, codeBg); + p.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::TextColorNormal)); m_effectiveUrl->setPalette(p); - m_effectiveUrl->setStyleSheet(QStringLiteral( - "QLabel { background:%1; border:1px solid %2; }") - .arg(theme.codeBg, theme.rowSeparator)); + m_effectiveUrl->setStyleSheet( + QStringLiteral("QLabel { background:%1; border:1px solid %2; }") + .arg(cssColor(codeBg), cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)))); } } // namespace QodeAssist::Settings diff --git a/settings/AgentDetailPane.hpp b/settings/AgentDetailPane.hpp index cac0bfe..7168e46 100644 --- a/settings/AgentDetailPane.hpp +++ b/settings/AgentDetailPane.hpp @@ -17,6 +17,10 @@ class QPlainTextEdit; class QPushButton; class QToolButton; +namespace QodeAssist { +class AgentFactory; +} + namespace QodeAssist::Providers { class ProviderInstanceFactory; } @@ -32,9 +36,9 @@ public: explicit AgentDetailPane(QWidget *parent = nullptr); void setInstanceFactory(Providers::ProviderInstanceFactory *factory); + void setAgentFactory(AgentFactory *factory); void setAgent(const AgentConfig &cfg); void clear(); - void setLoadDiagnostics(const QStringList &errors, const QStringList &warnings); signals: void openInEditorRequested(const AgentConfig &cfg); @@ -48,6 +52,10 @@ private: QLineEdit *makeReadOnlyLine(bool mono = false); void applyCodePalette(); void populateProviderCombo(); + void onChangeModel(); + void onResetModel(); + void onChangeProvider(int index); + void onResetProvider(); bool m_inApplyPalette = false; bool m_providerComboPopulated = false; @@ -71,20 +79,17 @@ private: QLineEdit *m_tagsValue = nullptr; QComboBox *m_providerCombo = nullptr; + QPushButton *m_providerResetBtn = nullptr; QPointer m_instanceFactory; + QPointer m_agentFactory; QLineEdit *m_endpointValue = nullptr; QLineEdit *m_modelValue = nullptr; + QPushButton *m_modelChangeBtn = nullptr; + QPushButton *m_modelResetBtn = nullptr; QLabel *m_effectiveUrl = nullptr; QLineEdit *m_filePatternsValue = nullptr; - QPlainTextEdit *m_roleText = nullptr; - QPlainTextEdit *m_contextText = nullptr; - QPlainTextEdit *m_messageFormat = nullptr; - - SectionBox *m_diagnostics = nullptr; - QPlainTextEdit *m_diagnosticsView = nullptr; - QToolButton *m_rawToggle = nullptr; QPlainTextEdit *m_rawToml = nullptr; }; diff --git a/settings/AgentDuplicator.cpp b/settings/AgentDuplicator.cpp index 6ad4868..12b8f42 100644 --- a/settings/AgentDuplicator.cpp +++ b/settings/AgentDuplicator.cpp @@ -25,12 +25,23 @@ QString tomlEscape(const QString &s) out.reserve(s.size()); for (QChar c : s) { switch (c.unicode()) { - case '\\': out += QLatin1String("\\\\"); break; - case '"': out += QLatin1String("\\\""); break; - case '\n': out += QLatin1String("\\n"); break; - case '\r': out += QLatin1String("\\r"); break; - case '\t': out += QLatin1String("\\t"); break; - default: out += c; + case '\\': + out += QLatin1String("\\\\"); + break; + case '"': + out += QLatin1String("\\\""); + break; + case '\n': + out += QLatin1String("\\n"); + break; + case '\r': + out += QLatin1String("\\r"); + break; + case '\t': + out += QLatin1String("\\t"); + break; + default: + out += c; } } return out; @@ -41,9 +52,7 @@ constexpr int kMaxUniqueAttempts = 1000; QString uniqueFilename(const QString &userDir, const QString &parentBasename) { QString fileName = parentBasename + QStringLiteral("_custom.toml"); - for (int n = 2; n < kMaxUniqueAttempts - && QFile::exists(QDir(userDir).filePath(fileName)); - ++n) + for (int n = 2; n < kMaxUniqueAttempts && QFile::exists(QDir(userDir).filePath(fileName)); ++n) fileName = QStringLiteral("%1_custom_%2.toml").arg(parentBasename).arg(n); return QDir(userDir).filePath(fileName); } @@ -63,8 +72,7 @@ QString trUser(const char *src) } // namespace -AgentDuplicateResult duplicateAgentInUserDir( - const AgentConfig &parent, const AgentFactory &factory) +AgentDuplicateResult duplicateAgentInUserDir(const AgentConfig &parent, const AgentFactory &factory) { AgentDuplicateResult result; if (parent.name.trimmed().isEmpty()) { @@ -81,14 +89,14 @@ AgentDuplicateResult duplicateAgentInUserDir( const QString parentBasename = QFileInfo(parent.sourcePath).baseName(); result.filePath = uniqueFilename(userDir, parentBasename); if (QFile::exists(result.filePath)) { - result.error = trUser("Could not find a free filename after %1 attempts.") - .arg(kMaxUniqueAttempts); + result.error + = trUser("Could not find a free filename after %1 attempts.").arg(kMaxUniqueAttempts); return result; } result.newName = uniqueName(parent.name, factory); if (factory.configByName(result.newName)) { - result.error = trUser("Could not find a free agent name after %1 attempts.") - .arg(kMaxUniqueAttempts); + result.error + = trUser("Could not find a free agent name after %1 attempts.").arg(kMaxUniqueAttempts); return result; } @@ -97,18 +105,17 @@ AgentDuplicateResult duplicateAgentInUserDir( result.error = trUser("Cannot create %1: %2").arg(result.filePath, f.errorString()); return result; } - const QString description - = QStringLiteral("User customization of '%1'. Override fields below to taste; " - "values not overridden are inherited from the parent.") - .arg(parent.name); - const QString body = QStringLiteral( - "schema_version = 1\n" - "name = \"%1\"\n" - "extends = \"%2\"\n" - "description = \"%3\"\n") - .arg(tomlEscape(result.newName), - tomlEscape(parent.name), - tomlEscape(description)); + const QString description = QStringLiteral( + "User customization of '%1'. Override fields below to taste; " + "values not overridden are inherited from the parent.") + .arg(parent.name); + QString body + = QStringLiteral( + "schema_version = 1\n" + "name = \"%1\"\n" + "extends = \"%2\"\n" + "description = \"%3\"\n") + .arg(tomlEscape(result.newName), tomlEscape(parent.name), tomlEscape(description)); const QByteArray payload = body.toUtf8(); if (f.write(payload) != payload.size() || !f.commit()) { result.error = trUser("Failed to write %1: %2").arg(result.filePath, f.errorString()); diff --git a/settings/AgentDuplicator.hpp b/settings/AgentDuplicator.hpp index c4ef751..67f65b3 100644 --- a/settings/AgentDuplicator.hpp +++ b/settings/AgentDuplicator.hpp @@ -9,7 +9,7 @@ namespace QodeAssist { class AgentFactory; struct AgentConfig; -} +} // namespace QodeAssist namespace QodeAssist::Settings { @@ -21,7 +21,6 @@ struct AgentDuplicateResult QString error; }; -AgentDuplicateResult duplicateAgentInUserDir( - const AgentConfig &parent, const AgentFactory &factory); +AgentDuplicateResult duplicateAgentInUserDir(const AgentConfig &parent, const AgentFactory &factory); } // namespace QodeAssist::Settings diff --git a/settings/AgentListItem.cpp b/settings/AgentListItem.cpp index 20dec13..965579f 100644 --- a/settings/AgentListItem.cpp +++ b/settings/AgentListItem.cpp @@ -7,6 +7,8 @@ #include "SettingsTheme.hpp" #include "TagChip.hpp" +#include + #include #include #include @@ -32,7 +34,7 @@ AgentListItem::AgentListItem(const AgentConfig &cfg, QWidget *parent) df.setPixelSize(10); dot->setFont(df); QPalette dp = dot->palette(); - dp.setColor(QPalette::WindowText, dp.color(QPalette::Mid)); + dp.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); dot->setPalette(dp); auto *nameLbl = new QLabel(cfg.name, this); @@ -52,15 +54,14 @@ AgentListItem::AgentListItem(const AgentConfig &cfg, QWidget *parent) col->setSpacing(2); col->addLayout(headerRow); - if (!cfg.model.isEmpty()) { - auto *model = new QLabel(cfg.model, this); - model->setFont(monospaceFont(11)); - model->setContentsMargins(16, 0, 0, 0); - QPalette mp = model->palette(); - mp.setColor(QPalette::WindowText, mp.color(QPalette::Mid)); - model->setPalette(mp); - col->addWidget(model); - } + m_modelLabel = new QLabel(cfg.model, this); + m_modelLabel->setFont(monospaceFont(11)); + m_modelLabel->setContentsMargins(16, 0, 0, 0); + QPalette mp = m_modelLabel->palette(); + mp.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); + m_modelLabel->setPalette(mp); + m_modelLabel->setVisible(!cfg.model.isEmpty()); + col->addWidget(m_modelLabel); if (!cfg.tags.isEmpty()) { auto *tagsHolder = new QWidget(this); @@ -78,7 +79,7 @@ AgentListItem::AgentListItem(const AgentConfig &cfg, QWidget *parent) } auto *outer = new QVBoxLayout(this); - outer->setContentsMargins(8, 6, 8, 6); + outer->setContentsMargins(5, 6, 8, 6); outer->setSpacing(0); outer->addLayout(col); @@ -99,6 +100,14 @@ void AgentListItem::setActiveTags(const QSet &active) chip->setActive(active.contains(chip->tag())); } +void AgentListItem::setModel(const QString &model) +{ + if (!m_modelLabel) + return; + m_modelLabel->setText(model); + m_modelLabel->setVisible(!model.isEmpty()); +} + void AgentListItem::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) @@ -118,11 +127,13 @@ void AgentListItem::applyTheme() if (m_inApplyTheme) return; QScopedValueRollback guard(m_inApplyTheme, true); - const Theme theme = themeFor(palette()); + const QString accent = m_selected + ? cssColor(Utils::creatorColor(Utils::Theme::TextColorLink)) + : QStringLiteral("transparent"); setStyleSheet(QStringLiteral( - "#AgentListItem { background:%1; border-top:1px solid %2; }") - .arg(m_selected ? theme.rowSelectedBg : QStringLiteral("transparent"), - theme.rowSeparator)); + "#AgentListItem { background:transparent;" + " border-top:1px solid %1; border-left:3px solid %2; }") + .arg(cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)), accent)); } } // namespace QodeAssist::Settings diff --git a/settings/AgentListItem.hpp b/settings/AgentListItem.hpp index 219846d..a89e9b2 100644 --- a/settings/AgentListItem.hpp +++ b/settings/AgentListItem.hpp @@ -11,6 +11,10 @@ #include +QT_BEGIN_NAMESPACE +class QLabel; +QT_END_NAMESPACE + namespace QodeAssist::Settings { class TagChip; @@ -24,6 +28,7 @@ public: QString agentName() const { return m_name; } void setSelected(bool selected); void setActiveTags(const QSet &active); + void setModel(const QString &model); signals: void clicked(const QString &name); @@ -39,6 +44,7 @@ private: QString m_name; bool m_selected = false; bool m_inApplyTheme = false; + QLabel *m_modelLabel = nullptr; QList m_chips; }; diff --git a/settings/AgentListPane.cpp b/settings/AgentListPane.cpp index 14c9470..81448db 100644 --- a/settings/AgentListPane.cpp +++ b/settings/AgentListPane.cpp @@ -9,6 +9,8 @@ #include "SettingsUiBuilders.hpp" #include "TagFilterStrip.hpp" +#include + #include #include @@ -69,6 +71,18 @@ AgentListPane::AgentListPane(AgentFactory *factory, QWidget *parent) [this](const QSet &) { rebuildList(); }, Qt::QueuedConnection); + if (m_factory) { + connect(m_factory, &AgentFactory::agentModelChanged, this, + [this](const QString &name) { + const AgentConfig *cfg = m_factory->configByName(name); + if (!cfg) + return; + for (auto *item : m_rows) + if (item->agentName() == name) + item->setModel(cfg->model); + }); + } + applyFilterHolderTheme(); } @@ -109,11 +123,11 @@ void AgentListPane::applyFilterHolderTheme() { if (!m_filterHolder) return; - const Theme theme = themeFor(palette()); m_filterHolder->setStyleSheet( QStringLiteral("QWidget#FilterHolder { background:%1;" " border-bottom:1px solid %2; }") - .arg(theme.listHeaderBg, theme.rowSeparator)); + .arg(cssColor(Utils::creatorColor(Utils::Theme::BackgroundColorNormal)), + cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)))); } std::vector AgentListPane::visibleAgents() const @@ -196,7 +210,7 @@ void AgentListPane::rebuildList() empty->setAlignment(Qt::AlignCenter); empty->setContentsMargins(10, 16, 10, 16); QPalette ep = empty->palette(); - ep.setColor(QPalette::WindowText, ep.color(QPalette::Mid)); + ep.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); empty->setPalette(ep); contentLayout->addWidget(empty); } diff --git a/settings/AgentModelDialog.cpp b/settings/AgentModelDialog.cpp new file mode 100644 index 0000000..d7aea59 --- /dev/null +++ b/settings/AgentModelDialog.cpp @@ -0,0 +1,142 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentModelDialog.hpp" + +#include "SettingsTheme.hpp" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QodeAssist::Settings { + +AgentModelDialog::AgentModelDialog( + AgentFactory *factory, + const QString &agentName, + const QString ¤tModel, + QWidget *parent) + : QDialog(parent) + , m_factory(factory) + , m_agentName(agentName) +{ + setWindowTitle(tr("Select Model")); + resize(440, 380); + + m_modelEdit = new QLineEdit(currentModel, this); + m_modelEdit->setFont(monospaceFont(11)); + m_modelEdit->setPlaceholderText(tr("Type a model name or pick one below")); + m_modelEdit->setClearButtonEnabled(true); + + m_fetchBtn = new QPushButton(tr("Fetch available models"), this); + m_fetchBtn->setToolTip(tr("Query the agent's provider for its installed models")); + + m_status = new QLabel(this); + m_status->setFont(monospaceFont(10)); + QPalette sp = m_status->palette(); + sp.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); + m_status->setPalette(sp); + + m_list = new QListWidget(this); + m_list->setFont(monospaceFont(11)); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + + connect(m_list, &QListWidget::currentTextChanged, this, [this](const QString &text) { + if (!text.isEmpty()) + m_modelEdit->setText(text); + }); + connect(m_list, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *) { accept(); }); + connect(m_fetchBtn, &QPushButton::clicked, this, [this] { fetchModels(); }); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *fetchRow = new QHBoxLayout; + fetchRow->setContentsMargins(0, 0, 0, 0); + fetchRow->setSpacing(8); + fetchRow->addWidget(m_fetchBtn); + fetchRow->addWidget(m_status, 1); + + auto *root = new QVBoxLayout(this); + root->addWidget(new QLabel(tr("Model:"), this)); + root->addWidget(m_modelEdit); + root->addLayout(fetchRow); + root->addWidget(m_list, 1); + root->addWidget(buttons); + + fetchModels(); +} + +QString AgentModelDialog::selectedModel() const +{ + return m_modelEdit->text().trimmed(); +} + +void AgentModelDialog::fetchModels() +{ + if (!m_factory) + return; + if (m_watcher && m_watcher->isRunning()) + return; + + QString err; + Agent *probe = m_factory->create(m_agentName, this, &err); + if (!probe) { + m_status->setText( + err.isEmpty() ? tr("Provider is not available.") + : tr("Provider is not available: %1").arg(err)); + return; + } + m_probe = probe; + + if (!m_watcher) { + m_watcher = new QFutureWatcher>(this); + connect(m_watcher, &QFutureWatcher>::finished, this, + [this] { onModelsFetched(); }); + } + + m_fetchBtn->setEnabled(false); + m_status->setText(tr("Loading models…")); + m_watcher->setFuture(probe->installedModels()); +} + +void AgentModelDialog::onModelsFetched() +{ + QList models; + if (m_watcher->future().resultCount() > 0) + models = m_watcher->result(); + + if (m_probe) { + m_probe->deleteLater(); + m_probe.clear(); + } + + m_fetchBtn->setEnabled(true); + + const QString keep = m_modelEdit->text(); + { + QSignalBlocker block(m_list); + m_list->clear(); + m_list->addItems(models); + } + m_modelEdit->setText(keep); + + m_status->setText( + models.isEmpty() + ? tr("No models returned — type the model name manually.") + : tr("%n model(s) available.", nullptr, static_cast(models.size()))); +} + +} // namespace QodeAssist::Settings diff --git a/settings/AgentModelDialog.hpp b/settings/AgentModelDialog.hpp new file mode 100644 index 0000000..a843632 --- /dev/null +++ b/settings/AgentModelDialog.hpp @@ -0,0 +1,55 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include +#include +#include + +QT_BEGIN_NAMESPACE +class QLabel; +class QLineEdit; +class QListWidget; +class QPushButton; +QT_END_NAMESPACE + +namespace QodeAssist { +class AgentFactory; +class Agent; +} + +namespace QodeAssist::Settings { + +class AgentModelDialog : public QDialog +{ + Q_OBJECT +public: + AgentModelDialog( + AgentFactory *factory, + const QString &agentName, + const QString ¤tModel, + QWidget *parent = nullptr); + + [[nodiscard]] QString selectedModel() const; + +private: + void fetchModels(); + void onModelsFetched(); + + AgentFactory *m_factory = nullptr; + QString m_agentName; + + QLineEdit *m_modelEdit = nullptr; + QListWidget *m_list = nullptr; + QLabel *m_status = nullptr; + QPushButton *m_fetchBtn = nullptr; + + QPointer m_probe; + QFutureWatcher> *m_watcher = nullptr; +}; + +} // namespace QodeAssist::Settings diff --git a/settings/AgentRole.cpp b/settings/AgentRole.cpp deleted file mode 100644 index 07f23c3..0000000 --- a/settings/AgentRole.cpp +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "AgentRole.hpp" - -#include - -#include -#include -#include -#include - -namespace QodeAssist::Settings { - -QString AgentRolesManager::getConfigurationDirectory() -{ - QString path = QString("%1/qodeassist/agent_roles") - .arg(Core::ICore::userResourcePath().toFSPathString()); - QDir().mkpath(path); - return path; -} - -QList AgentRolesManager::loadAllRoles() -{ - QList roles; - QString configDir = getConfigurationDirectory(); - QDir dir(configDir); - - ensureDefaultRoles(); - - const QStringList jsonFiles = dir.entryList({"*.json"}, QDir::Files); - for (const QString &fileName : jsonFiles) { - AgentRole role = loadRoleFromFile(dir.absoluteFilePath(fileName)); - if (!role.id.isEmpty()) { - roles.append(role); - } - } - - return roles; -} - -AgentRole AgentRolesManager::loadRole(const QString &roleId) -{ - if (roleId.isEmpty()) - return {}; - - QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(roleId + ".json"); - if (!QFile::exists(filePath)) - return {}; - - return loadRoleFromFile(filePath); -} - -AgentRole AgentRolesManager::loadRoleFromFile(const QString &filePath) -{ - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly)) - return {}; - - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (doc.isNull() || !doc.isObject()) - return {}; - - return AgentRole::fromJson(doc.object()); -} - -bool AgentRolesManager::saveRole(const AgentRole &role) -{ - if (role.id.isEmpty()) - return false; - - QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(role.id + ".json"); - QFile file(filePath); - - if (!file.open(QIODevice::WriteOnly)) - return false; - - QJsonDocument doc(role.toJson()); - file.write(doc.toJson(QJsonDocument::Indented)); - - return true; -} - -bool AgentRolesManager::deleteRole(const QString &roleId) -{ - if (roleId.isEmpty()) - return false; - - QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(roleId + ".json"); - return QFile::remove(filePath); -} - -bool AgentRolesManager::roleExists(const QString &roleId) -{ - if (roleId.isEmpty()) - return false; - - QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(roleId + ".json"); - return QFile::exists(filePath); -} - -void AgentRolesManager::ensureDefaultRoles() -{ - QDir dir(getConfigurationDirectory()); - - if (!dir.exists("developer.json")) - saveRole(getDefaultDeveloperRole()); - - if (!dir.exists("reviewer.json")) - saveRole(getDefaultReviewerRole()); - - if (!dir.exists("researcher.json")) - saveRole(getDefaultResearcherRole()); -} - -AgentRole AgentRolesManager::getDefaultDeveloperRole() -{ - return AgentRole{ - "developer", - "Developer", - "Experienced Qt/C++ developer for implementation tasks", - "You are an experienced Qt/C++ developer working on a Qt Creator plugin.\n\n" - "Your workflow:\n" - "1. **Analyze** - understand the problem and what needs to be done\n" - "2. **Propose solution** - explain your approach in 2-3 sentences\n" - "3. **Wait for approval** - don't write code until the solution is confirmed\n" - "4. **Implement** - write clean, minimal code that solves the task\n\n" - "When analyzing:\n" - "- Ask clarifying questions if requirements are unclear\n" - "- Check existing code for similar patterns\n" - "- Consider edge cases and potential issues\n\n" - "When proposing:\n" - "- Explain what you'll change and why\n" - "- Mention files you'll modify\n" - "- Note any architectural implications\n\n" - "When implementing:\n" - "- Use C++20, Qt6, follow existing codebase style\n" - "- Write only what's needed (MVP approach)\n" - "- Include file paths and necessary changes\n" - "- Handle errors properly\n" - "- Make sure it compiles\n\n" - "Keep it practical:\n" - "- Short explanations, let code speak\n" - "- No over-engineering or unnecessary refactoring\n" - "- No TODOs, debug code, or unfinished work\n" - "- Point out non-obvious things\n\n" - "You're a pragmatic team member who thinks before coding.", - true}; -} - -AgentRole AgentRolesManager::getDefaultReviewerRole() -{ - return AgentRole{ - "reviewer", - "Code Reviewer", - "Expert C++/QML code reviewer for quality assurance", - "You are an expert C++/QML code reviewer specializing in C++20 and Qt6.\n\n" - "What you check:\n" - "- Bugs, memory leaks, undefined behavior\n" - "- C++20 compliance and Qt6 patterns\n" - "- RAII, move semantics, smart pointers\n" - "- Qt parent-child ownership and signal/slot correctness\n" - "- Thread safety and Qt concurrent usage\n" - "- const-correctness and Qt container usage\n" - "- Performance bottlenecks\n" - "- Production readiness: error handling, no debug leftovers\n\n" - "What you do:\n" - "- Point out problems with clear explanations\n" - "- Suggest specific fixes with code examples\n" - "- Remove unnecessary comments, keep essential docs only\n" - "- Flag anything that's not production-ready\n" - "- Recommend optimizations when you spot them\n\n" - "Focus on: correctness, performance, maintainability, Qt idioms.\n\n" - "Be direct and specific. Show, don't just tell.", - true}; -} - -AgentRole AgentRolesManager::getDefaultResearcherRole() -{ - return AgentRole{ - "researcher", - "Researcher", - "Research-oriented developer for exploring solutions", - "You are a research-oriented Qt/C++ developer who investigates problems and explores " - "solutions.\n\n" - "Your job is to think, not to code:\n" - "- Deep dive into the problem before suggesting anything\n" - "- Research Qt docs, patterns, and best practices\n" - "- Find multiple ways to solve it\n" - "- Compare trade-offs: performance, complexity, maintainability\n" - "- Look for relevant Qt APIs and modules\n" - "- Think about architectural consequences\n\n" - "How you work:\n" - "1. **Problem Analysis** - what exactly needs solving\n" - "2. **Research Findings** - what you learned about this problem space\n" - "3. **Solution Options** - present 2-3 approaches with honest pros/cons\n" - "4. **Recommendation** - which one fits best and why\n" - "5. **Next Steps** - what to consider before implementing\n\n" - "What you provide:\n" - "- Clear comparison of different approaches\n" - "- Code snippets as examples (not ready-to-use patches)\n" - "- Links to docs, examples, similar implementations\n" - "- Questions to clarify requirements\n" - "- Warning about potential problems\n\n" - "You DO NOT write implementation code. You explore options and let the developer choose.\n\n" - "Think like a consultant: research thoroughly, present clearly, stay objective.", - true}; -} - -} // namespace QodeAssist::Settings diff --git a/settings/AgentRole.hpp b/settings/AgentRole.hpp deleted file mode 100644 index 5652b0b..0000000 --- a/settings/AgentRole.hpp +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include - -namespace QodeAssist::Settings { - -struct AgentRole -{ - QString id; - QString name; - QString description; - QString systemPrompt; - bool isBuiltin = false; - - QJsonObject toJson() const - { - return QJsonObject{ - {"id", id}, - {"name", name}, - {"description", description}, - {"systemPrompt", systemPrompt}, - {"isBuiltin", isBuiltin}}; - } - - static AgentRole fromJson(const QJsonObject &json) - { - return AgentRole{ - json["id"].toString(), - json["name"].toString(), - json["description"].toString(), - json["systemPrompt"].toString(), - json["isBuiltin"].toBool(false)}; - } - - bool operator==(const AgentRole &other) const { return id == other.id; } -}; - -class AgentRolesManager -{ -public: - static QString getConfigurationDirectory(); - static QList loadAllRoles(); - static AgentRole loadRole(const QString &roleId); - static AgentRole loadRoleFromFile(const QString &filePath); - static bool saveRole(const AgentRole &role); - static bool deleteRole(const QString &roleId); - static bool roleExists(const QString &roleId); - static void ensureDefaultRoles(); - - static AgentRole getNoRole() - { - return AgentRole{"", "No Role", "Use base system prompt without role specialization", "", false}; - } - -private: - static AgentRole getDefaultDeveloperRole(); - static AgentRole getDefaultReviewerRole(); - static AgentRole getDefaultResearcherRole(); -}; - -} // namespace QodeAssist::Settings diff --git a/settings/AgentRoleDialog.cpp b/settings/AgentRoleDialog.cpp deleted file mode 100644 index 76b4ed5..0000000 --- a/settings/AgentRoleDialog.cpp +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "AgentRoleDialog.hpp" - -#include -#include -#include -#include -#include -#include -#include - -namespace QodeAssist::Settings { - -AgentRoleDialog::AgentRoleDialog(Action action, QWidget *parent) - : QDialog{parent} - , m_action{action} -{ - auto getTitle = [](Action action) { - switch(action) - { - case Action::Add: - return tr("Add Agent Role"); - case Action::Duplicate: - return tr("Duplicate Agent Role"); - case Action::Edit: - return tr("Edit Agent Role"); - } - }; - - setWindowTitle(getTitle(action)); - setupUI(); -} - -void AgentRoleDialog::setupUI() -{ - auto *mainLayout = new QVBoxLayout(this); - auto *formLayout = new QFormLayout(); - - m_nameEdit = new QLineEdit(this); - m_nameEdit->setPlaceholderText(tr("e.g., Developer, Code Reviewer")); - formLayout->addRow(tr("Name:"), m_nameEdit); - - m_idEdit = new QLineEdit(this); - m_idEdit->setPlaceholderText(tr("e.g., developer, code_reviewer")); - formLayout->addRow(tr("ID:"), m_idEdit); - - m_descriptionEdit = new QTextEdit(this); - m_descriptionEdit->setPlaceholderText(tr("Brief description of this role...")); - m_descriptionEdit->setMaximumHeight(80); - formLayout->addRow(tr("Description:"), m_descriptionEdit); - - mainLayout->addLayout(formLayout); - - auto *promptLabel = new QLabel(tr("System Prompt:"), this); - mainLayout->addWidget(promptLabel); - - m_systemPromptEdit = new QTextEdit(this); - m_systemPromptEdit->setPlaceholderText( - tr("You are an expert in...\n\nYour role is to:\n- Task 1\n- Task 2\n- Task 3")); - mainLayout->addWidget(m_systemPromptEdit); - - m_buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); - mainLayout->addWidget(m_buttonBox); - - connect(m_buttonBox, &QDialogButtonBox::accepted, this, &AgentRoleDialog::accept); - connect(m_buttonBox, &QDialogButtonBox::rejected, this, &AgentRoleDialog::reject); - connect(m_nameEdit, &QLineEdit::textChanged, this, &AgentRoleDialog::validateInput); - connect(m_idEdit, &QLineEdit::textChanged, this, &AgentRoleDialog::validateInput); - connect(m_systemPromptEdit, &QTextEdit::textChanged, this, &AgentRoleDialog::validateInput); - - if (m_action == Action::Edit) { - m_idEdit->setEnabled(false); - m_idEdit->setToolTip(tr("ID cannot be changed for existing roles")); - } - - setMinimumSize(600, 500); - validateInput(); -} - -void AgentRoleDialog::validateInput() -{ - bool valid = !m_nameEdit->text().trimmed().isEmpty() - && !m_idEdit->text().trimmed().isEmpty() - && !m_systemPromptEdit->toPlainText().trimmed().isEmpty(); - - m_buttonBox->button(QDialogButtonBox::Ok)->setEnabled(valid); -} - -AgentRole AgentRoleDialog::getRole() const -{ - return AgentRole{ - m_idEdit->text().trimmed(), - m_nameEdit->text().trimmed(), - m_descriptionEdit->toPlainText().trimmed(), - m_systemPromptEdit->toPlainText().trimmed(), - false}; -} - -void AgentRoleDialog::setRole(const AgentRole &role) -{ - m_idEdit->setText(role.id); - m_nameEdit->setText(role.name); - m_descriptionEdit->setPlainText(role.description); - m_systemPromptEdit->setPlainText(role.systemPrompt); -} - -} // namespace QodeAssist::Settings diff --git a/settings/AgentRoleDialog.hpp b/settings/AgentRoleDialog.hpp deleted file mode 100644 index 48d5ec3..0000000 --- a/settings/AgentRoleDialog.hpp +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include "AgentRole.hpp" - -class QLineEdit; -class QTextEdit; -class QDialogButtonBox; - -namespace QodeAssist::Settings { - -class AgentRoleDialog : public QDialog -{ - Q_OBJECT - -public: - enum class Action { - Add, - Duplicate, - Edit, - }; - - explicit AgentRoleDialog(Action action, QWidget *parent = nullptr); - explicit AgentRoleDialog(const AgentRole &role, Action action, QWidget *parent = nullptr) - : AgentRoleDialog{action, parent} - { - setRole(role); - } - - AgentRole getRole() const; - void setRole(const AgentRole &role); - -private: - void setupUI(); - void validateInput(); - - QLineEdit *m_nameEdit = nullptr; - QLineEdit *m_idEdit = nullptr; - QTextEdit *m_descriptionEdit = nullptr; - QTextEdit *m_systemPromptEdit = nullptr; - QDialogButtonBox *m_buttonBox = nullptr; - Action m_action; -}; - -} // namespace QodeAssist::Settings diff --git a/settings/AgentRolesWidget.cpp b/settings/AgentRolesWidget.cpp deleted file mode 100644 index a8ac0d8..0000000 --- a/settings/AgentRolesWidget.cpp +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "AgentRolesWidget.hpp" - -#include "AgentRole.hpp" -#include "AgentRoleDialog.hpp" -#include "SettingsTr.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace QodeAssist::Settings { - -void AgentRolesWidget::setupUI() -{ - auto *mainLayout = new QVBoxLayout(this); - - auto *headerLayout = new QHBoxLayout(); - - auto *infoLabel = new QLabel( - Tr::tr("Agent roles define different system prompts for specific tasks."), this); - infoLabel->setWordWrap(true); - headerLayout->addWidget(infoLabel, 1); - - auto *openFolderButton = new QPushButton(Tr::tr("Open Roles Folder..."), this); - connect(openFolderButton, &QPushButton::clicked, this, &AgentRolesWidget::onOpenRolesFolder); - headerLayout->addWidget(openFolderButton); - - mainLayout->addLayout(headerLayout); - - auto *contentLayout = new QHBoxLayout(); - - m_rolesList = new QListWidget(this); - m_rolesList->setSelectionMode(QAbstractItemView::SingleSelection); - connect(m_rolesList, &QListWidget::itemSelectionChanged, this, &AgentRolesWidget::updateButtons); - connect(m_rolesList, &QListWidget::itemDoubleClicked, this, &AgentRolesWidget::onEditRole); - contentLayout->addWidget(m_rolesList, 1); - - auto *buttonsLayout = new QVBoxLayout(); - - m_addButton = new QPushButton(Tr::tr("Add..."), this); - connect(m_addButton, &QPushButton::clicked, this, &AgentRolesWidget::onAddRole); - buttonsLayout->addWidget(m_addButton); - - m_editButton = new QPushButton(Tr::tr("Edit..."), this); - connect(m_editButton, &QPushButton::clicked, this, &AgentRolesWidget::onEditRole); - buttonsLayout->addWidget(m_editButton); - - m_duplicateButton = new QPushButton(Tr::tr("Duplicate..."), this); - connect(m_duplicateButton, &QPushButton::clicked, this, &AgentRolesWidget::onDuplicateRole); - buttonsLayout->addWidget(m_duplicateButton); - - m_deleteButton = new QPushButton(Tr::tr("Delete"), this); - connect(m_deleteButton, &QPushButton::clicked, this, &AgentRolesWidget::onDeleteRole); - buttonsLayout->addWidget(m_deleteButton); - - buttonsLayout->addStretch(); - - contentLayout->addLayout(buttonsLayout); - mainLayout->addLayout(contentLayout); - - updateButtons(); -} - -void AgentRolesWidget::loadRoles() -{ - m_rolesList->clear(); - - const QList roles = AgentRolesManager::loadAllRoles(); - for (const AgentRole &role : roles) { - auto *item = new QListWidgetItem(role.name, m_rolesList); - item->setData(Qt::UserRole, role.id); - - QString tooltip = role.description; - if (role.isBuiltin) { - tooltip += "\n\n" + Tr::tr("(Built-in role)"); - item->setForeground(Qt::darkGray); - } - item->setToolTip(tooltip); - } -} - -void AgentRolesWidget::updateButtons() -{ - QListWidgetItem *selectedItem = m_rolesList->currentItem(); - bool hasSelection = selectedItem != nullptr; - bool isBuiltin = false; - - if (hasSelection) { - QString roleId = selectedItem->data(Qt::UserRole).toString(); - AgentRole role = AgentRolesManager::loadRole(roleId); - isBuiltin = role.isBuiltin; - } - - m_editButton->setEnabled(hasSelection); - m_duplicateButton->setEnabled(hasSelection); - m_deleteButton->setEnabled(hasSelection && !isBuiltin); -} - -void AgentRolesWidget::onAddRole() -{ - AgentRoleDialog dialog{AgentRoleDialog::Action::Add, this}; - if (dialog.exec() != QDialog::Accepted) - return; - - AgentRole newRole = dialog.getRole(); - - if (AgentRolesManager::roleExists(newRole.id)) { - QMessageBox::warning( - this, - Tr::tr("Role Already Exists"), - Tr::tr("A role with ID '%1' already exists. Please use a different ID.") - .arg(newRole.id)); - return; - } - - if (AgentRolesManager::saveRole(newRole)) { - loadRoles(); - } else { - QMessageBox::critical( - this, Tr::tr("Error"), Tr::tr("Failed to save role '%1'.").arg(newRole.name)); - } -} - -void AgentRolesWidget::onEditRole() -{ - QListWidgetItem *selectedItem = m_rolesList->currentItem(); - if (!selectedItem) - return; - - QString roleId = selectedItem->data(Qt::UserRole).toString(); - AgentRole role = AgentRolesManager::loadRole(roleId); - - if (role.isBuiltin) { - QMessageBox::information( - this, - Tr::tr("Cannot Edit Built-in Role"), - Tr::tr( - "Built-in roles cannot be edited. You can duplicate this role and modify the copy.")); - return; - } - - AgentRoleDialog dialog{role, AgentRoleDialog::Action::Edit, this}; - if (dialog.exec() != QDialog::Accepted) - return; - - AgentRole updatedRole = dialog.getRole(); - - if (AgentRolesManager::saveRole(updatedRole)) { - loadRoles(); - } else { - QMessageBox::critical( - this, Tr::tr("Error"), Tr::tr("Failed to update role '%1'.").arg(updatedRole.name)); - } -} - -void AgentRolesWidget::onDuplicateRole() -{ - QListWidgetItem *selectedItem = m_rolesList->currentItem(); - if (!selectedItem) - return; - - QString roleId = selectedItem->data(Qt::UserRole).toString(); - AgentRole role = AgentRolesManager::loadRole(roleId); - - role.name += " (Copy)"; - role.id += "_copy"; - role.isBuiltin = false; - - int counter = 1; - QString baseId = role.id; - while (AgentRolesManager::roleExists(role.id)) { - role.id = baseId + QString::number(counter++); - } - - AgentRoleDialog dialog{role, AgentRoleDialog::Action::Duplicate, this}; - if (dialog.exec() != QDialog::Accepted) - return; - - AgentRole newRole = dialog.getRole(); - - if (AgentRolesManager::roleExists(newRole.id)) { - QMessageBox::warning( - this, - Tr::tr("Role Already Exists"), - Tr::tr("A role with ID '%1' already exists. Please use a different ID.") - .arg(newRole.id)); - return; - } - - if (AgentRolesManager::saveRole(newRole)) { - loadRoles(); - } else { - QMessageBox::critical(this, Tr::tr("Error"), Tr::tr("Failed to duplicate role.")); - } -} - -void AgentRolesWidget::onDeleteRole() -{ - QListWidgetItem *selectedItem = m_rolesList->currentItem(); - if (!selectedItem) - return; - - QString roleId = selectedItem->data(Qt::UserRole).toString(); - AgentRole role = AgentRolesManager::loadRole(roleId); - - if (role.isBuiltin) { - QMessageBox::information( - this, Tr::tr("Cannot Delete Built-in Role"), Tr::tr("Built-in roles cannot be deleted.")); - return; - } - - QMessageBox::StandardButton reply = QMessageBox::question( - this, - Tr::tr("Delete Role"), - Tr::tr("Are you sure you want to delete the role '%1'?").arg(role.name), - QMessageBox::Yes | QMessageBox::No); - - if (reply == QMessageBox::Yes) { - if (AgentRolesManager::deleteRole(roleId)) { - loadRoles(); - } else { - QMessageBox::critical( - this, Tr::tr("Error"), Tr::tr("Failed to delete role '%1'.").arg(role.name)); - } - } -} - -void AgentRolesWidget::onOpenRolesFolder() -{ - QDesktopServices::openUrl(QUrl::fromLocalFile(AgentRolesManager::getConfigurationDirectory())); -} - -} // namespace QodeAssist::Settings diff --git a/settings/AgentRolesWidget.hpp b/settings/AgentRolesWidget.hpp deleted file mode 100644 index 6378c2d..0000000 --- a/settings/AgentRolesWidget.hpp +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -class QListWidget; -class QPushButton; - -namespace QodeAssist::Settings { - -class AgentRolesWidget : public Core::IOptionsPageWidget -{ - Q_OBJECT - -public: - explicit AgentRolesWidget() - { - setupUI(); - loadRoles(); - } - -private: - void setupUI(); - void loadRoles(); - void updateButtons(); - - void onAddRole(); - void onEditRole(); - void onDuplicateRole(); - void onDeleteRole(); - void onOpenRolesFolder(); - - QListWidget *m_rolesList = nullptr; - QPushButton *m_addButton = nullptr; - QPushButton *m_editButton = nullptr; - QPushButton *m_duplicateButton = nullptr; - QPushButton *m_deleteButton = nullptr; -}; - -} // namespace QodeAssist::Settings diff --git a/settings/AgentsSettingsPage.cpp b/settings/AgentsSettingsPage.cpp index c66adb5..326c7b1 100644 --- a/settings/AgentsSettingsPage.cpp +++ b/settings/AgentsSettingsPage.cpp @@ -7,17 +7,20 @@ #include "AgentDetailPane.hpp" #include "AgentDuplicator.hpp" #include "AgentListPane.hpp" -#include "SettingsTheme.hpp" #include "SettingsConstants.hpp" +#include "SettingsTheme.hpp" #include #include #include +#include +#include #include #include #include +#include #include #include #include @@ -79,7 +82,7 @@ public: m_userPathLabel = new QLabel(this); m_userPathLabel->setFont(monospaceFont(11)); QPalette mutedPal = m_userPathLabel->palette(); - mutedPal.setColor(QPalette::WindowText, mutedPal.color(QPalette::Mid)); + mutedPal.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); m_userPathLabel->setPalette(mutedPal); m_userPathLabel->setMaximumWidth(260); @@ -100,6 +103,7 @@ public: m_detail = new AgentDetailPane(this); m_detail->setInstanceFactory(m_agentFactory->instanceFactory()); + m_detail->setAgentFactory(m_agentFactory); m_detailScroll = new QScrollArea(this); m_detailScroll->setWidgetResizable(true); m_detailScroll->setFrameShape(QFrame::StyledPanel); @@ -126,26 +130,49 @@ public: QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); }); - connect(m_listPane, &AgentListPane::currentAgentChanged, this, - [this](const QString &name) { - if (const AgentConfig *cfg = m_agentFactory->configByName(name)) - m_detail->setAgent(*cfg); - else - m_detail->clear(); - }); + connect(m_listPane, &AgentListPane::currentAgentChanged, this, [this](const QString &name) { + if (const AgentConfig *cfg = m_agentFactory->configByName(name)) + m_detail->setAgent(*cfg); + else + m_detail->clear(); + }); - connect(m_detail, &AgentDetailPane::openInEditorRequested, - this, &AgentsWidget::openAgentInEditor); - connect(m_detail, &AgentDetailPane::customizeRequested, - this, &AgentsWidget::customizeAgent); - connect(m_detail, &AgentDetailPane::deleteRequested, - this, &AgentsWidget::deleteAgent); + connect( + m_detail, + &AgentDetailPane::openInEditorRequested, + this, + &AgentsWidget::openAgentInEditor); + connect(m_detail, &AgentDetailPane::customizeRequested, this, &AgentsWidget::customizeAgent); + connect(m_detail, &AgentDetailPane::deleteRequested, this, &AgentsWidget::deleteAgent); if (m_navigator) { - connect(m_navigator, &AgentsPageNavigator::selectAgentRequested, - m_listPane, &AgentListPane::selectByName); + connect( + m_navigator, + &AgentsPageNavigator::selectAgentRequested, + m_listPane, + &AgentListPane::selectByName); } + m_reloadDebounce = new QTimer(this); + m_reloadDebounce->setSingleShot(true); + m_reloadDebounce->setInterval(300); + connect(m_reloadDebounce, &QTimer::timeout, this, [this] { + constexpr qint64 kSelfWriteIgnoreMs = 1500; + if (QDateTime::currentMSecsSinceEpoch() - m_lastSelfWriteMs < kSelfWriteIgnoreMs) { + armWatcher(); + return; + } + reloadFromDisk(); + }); + + m_watcher = new QFileSystemWatcher(this); + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, [this](const QString &) { + m_reloadDebounce->start(); + }); + connect(m_watcher, &QFileSystemWatcher::fileChanged, this, [this](const QString &) { + m_reloadDebounce->start(); + }); + reloadFromDisk(); if (m_navigator) { @@ -165,10 +192,23 @@ private: void reloadFromDisk() { m_agentFactory->reload(); - m_detail->setLoadDiagnostics( - m_agentFactory->lastLoadErrors(), m_agentFactory->lastLoadWarnings()); updateUserPathLabel(); m_listPane->refresh(); + armWatcher(); + } + + void armWatcher() + { + if (!m_watcher) + return; + const QStringList watched = m_watcher->files() + m_watcher->directories(); + if (!watched.isEmpty()) + m_watcher->removePaths(watched); + const QString dir = QodeAssist::AgentFactory::userAgentsDir(); + m_watcher->addPath(dir); + const QDir userDir(dir); + for (const QString &f : userDir.entryList({QStringLiteral("*.toml")}, QDir::Files)) + m_watcher->addPath(userDir.filePath(f)); } void updateUserPathLabel() @@ -187,7 +227,8 @@ private: if (!isUser) { QMessageBox::information( - this, tr("Open agent"), + this, + tr("Open agent"), tr("'%1' is bundled with the plugin and read-only.\n" "Use Duplicate to create an editable user copy.") .arg(name)); @@ -195,19 +236,17 @@ private: } if (sourcePath.isEmpty() || sourcePath.startsWith(QLatin1String(":/"))) { QMessageBox::warning( - this, tr("Open agent"), - tr("Agent '%1' has no editable source file.").arg(name)); + this, tr("Open agent"), tr("Agent '%1' has no editable source file.").arg(name)); return; } if (!Core::EditorManager::openEditor(Utils::FilePath::fromString(sourcePath))) { - QMessageBox::warning( - this, tr("Open agent"), - tr("Could not open %1.").arg(sourcePath)); + QMessageBox::warning(this, tr("Open agent"), tr("Could not open %1.").arg(sourcePath)); } } void customizeAgent(const AgentConfig &parent) { + m_lastSelfWriteMs = QDateTime::currentMSecsSinceEpoch(); const AgentDuplicateResult res = duplicateAgentInUserDir(parent, *m_agentFactory); if (!res.ok) { QMessageBox::warning(this, tr("Duplicate"), res.error); @@ -226,18 +265,23 @@ private: const QString sourcePath = agent.sourcePath; if (QMessageBox::question( - this, tr("Delete Agent"), - tr("Delete agent '%1'?\n\nThis will remove the file:\n%2") - .arg(name, sourcePath), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + this, + tr("Delete Agent"), + tr("Delete agent '%1'?\n\nThis will remove the file:\n%2").arg(name, sourcePath), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) != QMessageBox::Yes) return; + m_lastSelfWriteMs = QDateTime::currentMSecsSinceEpoch(); if (!QFile::remove(sourcePath)) { QMessageBox::warning( - this, tr("Delete Agent"), + this, + tr("Delete Agent"), tr("Could not delete the agent file:\n%1").arg(sourcePath)); return; } + m_agentFactory->clearAgentModelOverride(name); + m_agentFactory->clearAgentProviderOverride(name); reloadFromDisk(); } @@ -252,6 +296,9 @@ private: AgentListPane *m_listPane = nullptr; QScrollArea *m_detailScroll = nullptr; AgentDetailPane *m_detail = nullptr; + QFileSystemWatcher *m_watcher = nullptr; + QTimer *m_reloadDebounce = nullptr; + qint64 m_lastSelfWriteMs = 0; }; class AgentsSettingsPage : public Core::IOptionsPage @@ -262,9 +309,8 @@ public: setId(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID); setDisplayName(QObject::tr("Agents")); setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY); - setWidgetCreator([agentFactory, navigator]() { - return new AgentsWidget(agentFactory, navigator); - }); + setWidgetCreator( + [agentFactory, navigator]() { return new AgentsWidget(agentFactory, navigator); }); } }; diff --git a/settings/ButtonAspect.hpp b/settings/ButtonAspect.hpp index d1d1222..929336e 100644 --- a/settings/ButtonAspect.hpp +++ b/settings/ButtonAspect.hpp @@ -47,14 +47,6 @@ public: parent.addItem(button); } - void updateVisibility(bool visible) - { - if (m_visible == visible) - return; - m_visible = visible; - emit visibleChanged(visible); - } - QString m_buttonText; QIcon m_icon; QString m_tooltip; diff --git a/settings/CMakeLists.txt b/settings/CMakeLists.txt index e311bc4..7e85f4e 100644 --- a/settings/CMakeLists.txt +++ b/settings/CMakeLists.txt @@ -1,6 +1,5 @@ add_library(QodeAssistSettings STATIC GeneralSettings.hpp GeneralSettings.cpp - ConfigurationManager.hpp ConfigurationManager.cpp SettingsUtils.hpp SettingsConstants.hpp ButtonAspect.hpp @@ -11,7 +10,6 @@ add_library(QodeAssistSettings STATIC ToolsSettings.hpp ToolsSettings.cpp SkillsSettings.hpp SkillsSettings.cpp McpSettings.hpp McpSettings.cpp - SettingsDialog.hpp SettingsDialog.cpp ProjectSettings.hpp ProjectSettings.cpp ProjectSettingsPanel.hpp ProjectSettingsPanel.cpp ProviderSettings.hpp ProviderSettings.cpp @@ -25,11 +23,9 @@ add_library(QodeAssistSettings STATIC ProviderDetailPane.hpp ProviderDetailPane.cpp PluginUpdater.hpp PluginUpdater.cpp UpdateDialog.hpp UpdateDialog.cpp - AgentRole.hpp AgentRole.cpp - AgentRoleDialog.hpp AgentRoleDialog.cpp - AgentRolesWidget.hpp AgentRolesWidget.cpp AgentsSettingsPage.hpp AgentsSettingsPage.cpp AgentDetailPane.hpp AgentDetailPane.cpp + AgentModelDialog.hpp AgentModelDialog.cpp AgentListItem.hpp AgentListItem.cpp AgentListPane.hpp AgentListPane.cpp AgentDuplicator.hpp AgentDuplicator.cpp @@ -47,5 +43,9 @@ target_link_libraries(QodeAssistSettings ProvidersConfig Agents Skills + QodeAssistAgentPipelines +) +target_include_directories(QodeAssistSettings + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} + PRIVATE ${CMAKE_SOURCE_DIR}/sources/settings ) -target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/settings/ChatAssistantSettings.cpp b/settings/ChatAssistantSettings.cpp index ccbc58c..8d113b2 100644 --- a/settings/ChatAssistantSettings.cpp +++ b/settings/ChatAssistantSettings.cpp @@ -14,7 +14,6 @@ #include "SettingsConstants.hpp" #include "SettingsTr.hpp" #include "SettingsUtils.hpp" -#include "AgentRolesWidget.hpp" namespace QodeAssist::Settings { @@ -46,11 +45,6 @@ ChatAssistantSettings::ChatAssistantSettings() enableChatInNavigationPanel.setLabelText(Tr::tr("Enable chat in navigation panel")); enableChatInNavigationPanel.setDefaultValue(false); - enableChatTools.setSettingsKey(Constants::CA_ENABLE_CHAT_TOOLS); - enableChatTools.setLabelText(Tr::tr("Enable tools/function calling")); - enableChatTools.setToolTip(Tr::tr("When enabled, AI can use tools to read files, search project, and build code")); - enableChatTools.setDefaultValue(false); - autoCompress.setSettingsKey(Constants::CA_AUTO_COMPRESS); autoCompress.setLabelText(Tr::tr("Auto-compress chat when session tokens exceed:")); autoCompress.setToolTip(Tr::tr( @@ -63,125 +57,6 @@ ChatAssistantSettings::ChatAssistantSettings() autoCompressThreshold.setRange(1000, 99999999); autoCompressThreshold.setDefaultValue(40000); - // General Parameters Settings - temperature.setSettingsKey(Constants::CA_TEMPERATURE); - temperature.setLabelText(Tr::tr("Temperature:")); - temperature.setDefaultValue(0.5); - temperature.setRange(0.0, 2.0); - temperature.setSingleStep(0.1); - - maxTokens.setSettingsKey(Constants::CA_MAX_TOKENS); - maxTokens.setLabelText(Tr::tr("Max Tokens:")); - maxTokens.setRange(-1, 200000); // -1 for unlimited, 200k max for extended output - maxTokens.setDefaultValue(2000); - - // Advanced Parameters - useTopP.setSettingsKey(Constants::CA_USE_TOP_P); - useTopP.setDefaultValue(false); - useTopP.setLabelText(Tr::tr("Top P:")); - - topP.setSettingsKey(Constants::CA_TOP_P); - topP.setDefaultValue(0.9); - topP.setRange(0.0, 1.0); - topP.setSingleStep(0.1); - - useTopK.setSettingsKey(Constants::CA_USE_TOP_K); - useTopK.setDefaultValue(false); - useTopK.setLabelText(Tr::tr("Top K:")); - - topK.setSettingsKey(Constants::CA_TOP_K); - topK.setDefaultValue(50); - topK.setRange(1, 1000); - - usePresencePenalty.setSettingsKey(Constants::CA_USE_PRESENCE_PENALTY); - usePresencePenalty.setDefaultValue(false); - usePresencePenalty.setLabelText(Tr::tr("Presence Penalty:")); - - presencePenalty.setSettingsKey(Constants::CA_PRESENCE_PENALTY); - presencePenalty.setDefaultValue(0.0); - presencePenalty.setRange(-2.0, 2.0); - presencePenalty.setSingleStep(0.1); - - useFrequencyPenalty.setSettingsKey(Constants::CA_USE_FREQUENCY_PENALTY); - useFrequencyPenalty.setDefaultValue(false); - useFrequencyPenalty.setLabelText(Tr::tr("Frequency Penalty:")); - - frequencyPenalty.setSettingsKey(Constants::CA_FREQUENCY_PENALTY); - frequencyPenalty.setDefaultValue(0.0); - frequencyPenalty.setRange(-2.0, 2.0); - frequencyPenalty.setSingleStep(0.1); - - // Context Settings - useSystemPrompt.setSettingsKey(Constants::CA_USE_SYSTEM_PROMPT); - useSystemPrompt.setDefaultValue(true); - useSystemPrompt.setLabelText(Tr::tr("Use System Prompt")); - - systemPrompt.setSettingsKey(Constants::CA_SYSTEM_PROMPT); - systemPrompt.setDisplayStyle(Utils::StringAspect::TextEditDisplay); - systemPrompt.setDefaultValue( - "You are an advanced AI assistant specializing in C++, Qt, and QML development. Your role " - "is to provide helpful, accurate, and detailed responses to questions about coding, " - "debugging, " - "and best practices in these technologies."); - - // Ollama Settings - ollamaLivetime.setSettingsKey(Constants::CA_OLLAMA_LIVETIME); - ollamaLivetime.setToolTip( - Tr::tr("Time to suspend Ollama after completion request (in minutes), " - "Only Ollama, -1 to disable")); - ollamaLivetime.setLabelText("Livetime:"); - ollamaLivetime.setDefaultValue("5m"); - ollamaLivetime.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - - contextWindow.setSettingsKey(Constants::CA_OLLAMA_CONTEXT_WINDOW); - contextWindow.setLabelText(Tr::tr("Context Window:")); - contextWindow.setRange(-1, 10000); - contextWindow.setDefaultValue(2048); - - // Extended Thinking Settings - enableThinkingMode.setSettingsKey(Constants::CA_ENABLE_THINKING_MODE); - enableThinkingMode.setLabelText(Tr::tr("Enable extended thinking mode.")); - enableThinkingMode.setToolTip( - Tr::tr("Enable extended thinking mode for complex reasoning tasks." - "This provides step-by-step reasoning before the final answer." - "Temperature is 1.0 accordingly API requirement")); - enableThinkingMode.setDefaultValue(false); - - thinkingBudgetTokens.setSettingsKey(Constants::CA_THINKING_BUDGET_TOKENS); - thinkingBudgetTokens.setLabelText(Tr::tr("Thinking budget tokens:")); - thinkingBudgetTokens.setToolTip( - Tr::tr("Maximum number of tokens Claude can use for internal reasoning. " - "Larger budgets improve quality but increase latency. Minimum: 1024, Recommended: 10000-16000.")); - thinkingBudgetTokens.setRange(1024, 100000); - thinkingBudgetTokens.setDefaultValue(10000); - - thinkingMaxTokens.setSettingsKey(Constants::CA_THINKING_MAX_TOKENS); - thinkingMaxTokens.setLabelText(Tr::tr("Thinking mode max output tokens:")); - thinkingMaxTokens.setToolTip( - Tr::tr("Maximum number of tokens for the final response when thinking mode is enabled. " - "Set to -1 to use the default max tokens setting. Recommended: 4096-16000.")); - thinkingMaxTokens.setRange(-1, 200000); - thinkingMaxTokens.setDefaultValue(16000); - - // OpenAI Responses API Settings - openAIResponsesReasoningEffort.setSettingsKey(Constants::CA_OPENAI_RESPONSES_REASONING_EFFORT); - openAIResponsesReasoningEffort.setLabelText(Tr::tr("Reasoning effort:")); - openAIResponsesReasoningEffort.setDisplayStyle(Utils::SelectionAspect::DisplayStyle::ComboBox); - openAIResponsesReasoningEffort.addOption("None"); - openAIResponsesReasoningEffort.addOption("Minimal"); - openAIResponsesReasoningEffort.addOption("Low"); - openAIResponsesReasoningEffort.addOption("Medium"); - openAIResponsesReasoningEffort.addOption("High"); - openAIResponsesReasoningEffort.setDefaultValue("Medium"); - openAIResponsesReasoningEffort.setToolTip( - Tr::tr("Constrains effort on reasoning for OpenAI gpt-5 and o-series models:\n\n" - "None: No reasoning (gpt-5.1 only)\n" - "Minimal: Minimal reasoning effort (o-series only)\n" - "Low: Low reasoning effort\n" - "Medium: Balanced reasoning (default for most models)\n" - "High: Maximum reasoning effort (gpt-5-pro only supports this)\n\n" - "Note: Reducing effort = faster responses + fewer tokens")); - autosave.setDefaultValue(true); autosave.setLabelText(Tr::tr("Enable autosave when message received")); @@ -252,9 +127,6 @@ ChatAssistantSettings::ChatAssistantSettings() chatRenderer.setDefaultValue("rhi"); #endif - lastUsedRoleId.setSettingsKey(Constants::CA_LAST_USED_ROLE); - lastUsedRoleId.setDefaultValue(""); - resetToDefaults.m_buttonText = TrConstants::RESET_TO_DEFAULTS; readSettings(); @@ -264,27 +136,6 @@ ChatAssistantSettings::ChatAssistantSettings() setLayouter([this]() { using namespace Layouting; - auto genGrid = Grid{}; - genGrid.addRow({Row{temperature}}); - genGrid.addRow({Row{maxTokens}}); - - auto advancedGrid = Grid{}; - advancedGrid.addRow({useTopP, topP}); - advancedGrid.addRow({useTopK, topK}); - advancedGrid.addRow({usePresencePenalty, presencePenalty}); - advancedGrid.addRow({useFrequencyPenalty, frequencyPenalty}); - - auto ollamaGrid = Grid{}; - ollamaGrid.addRow({ollamaLivetime}); - ollamaGrid.addRow({contextWindow}); - - auto thinkingGrid = Grid{}; - thinkingGrid.addRow({thinkingBudgetTokens}); - thinkingGrid.addRow({thinkingMaxTokens}); - - auto openAIResponsesGrid = Grid{}; - openAIResponsesGrid.addRow({openAIResponsesReasoningEffort}); - auto chatViewSettingsGrid = Grid{}; chatViewSettingsGrid.addRow({textFontFamily, textFontSize}); chatViewSettingsGrid.addRow({codeFontFamily, codeFontSize}); @@ -301,32 +152,6 @@ ChatAssistantSettings::ChatAssistantSettings() autosave, Row{autoCompress, autoCompressThreshold, Stretch{1}}}}, Space{8}, - Group{ - title(Tr::tr("Tools")), - Column{enableChatTools}}, - Space{8}, - Group{ - title(Tr::tr("Extended Thinking (Claude)")), - Column{enableThinkingMode, Row{thinkingGrid, Stretch{1}}}}, - Space{8}, - Group{ - title(Tr::tr("OpenAI Responses API")), - Column{Row{openAIResponsesGrid, Stretch{1}}}}, - 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}}; }); @@ -353,26 +178,7 @@ void ChatAssistantSettings::resetSettingsToDefaults() if (reply == QMessageBox::Yes) { resetAspect(autoCompress); resetAspect(autoCompressThreshold); - resetAspect(temperature); - resetAspect(maxTokens); - resetAspect(useTopP); - resetAspect(topP); - resetAspect(useTopK); - resetAspect(topK); - resetAspect(usePresencePenalty); - resetAspect(presencePenalty); - resetAspect(useFrequencyPenalty); - resetAspect(frequencyPenalty); - resetAspect(useSystemPrompt); - resetAspect(systemPrompt); - resetAspect(ollamaLivetime); - resetAspect(contextWindow); - resetAspect(enableThinkingMode); - resetAspect(thinkingBudgetTokens); - resetAspect(thinkingMaxTokens); - resetAspect(openAIResponsesReasoningEffort); resetAspect(linkOpenFiles); - resetAspect(enableChatTools); resetAspect(textFontFamily); resetAspect(codeFontFamily); resetAspect(textFontSize); @@ -397,18 +203,4 @@ public: const ChatAssistantSettingsPage chatAssistantSettingsPage; -class AgentRolesSettingsPage : public Core::IOptionsPage -{ -public: - AgentRolesSettingsPage() - { - setId("QodeAssist.AgentRoles"); - setDisplayName(Tr::tr("Agent Roles")); - setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY); - setWidgetCreator([]() { return new AgentRolesWidget(); }); - } -}; - -const AgentRolesSettingsPage agentRolesSettingsPage; - } // namespace QodeAssist::Settings diff --git a/settings/ChatAssistantSettings.hpp b/settings/ChatAssistantSettings.hpp index 4e95899..66b8a59 100644 --- a/settings/ChatAssistantSettings.hpp +++ b/settings/ChatAssistantSettings.hpp @@ -6,7 +6,6 @@ #include -#include "AgentRole.hpp" #include "ButtonAspect.hpp" namespace QodeAssist::Settings { @@ -23,43 +22,9 @@ public: Utils::BoolAspect autosave{this}; Utils::BoolAspect enableChatInBottomToolBar{this}; Utils::BoolAspect enableChatInNavigationPanel{this}; - Utils::BoolAspect enableChatTools{this}; Utils::BoolAspect autoCompress{this}; Utils::IntegerAspect autoCompressThreshold{this}; - // General Parameters Settings - Utils::DoubleAspect temperature{this}; - Utils::IntegerAspect maxTokens{this}; - - // Advanced Parameters - Utils::BoolAspect useTopP{this}; - Utils::DoubleAspect topP{this}; - - Utils::BoolAspect useTopK{this}; - Utils::IntegerAspect topK{this}; - - Utils::BoolAspect usePresencePenalty{this}; - Utils::DoubleAspect presencePenalty{this}; - - Utils::BoolAspect useFrequencyPenalty{this}; - Utils::DoubleAspect frequencyPenalty{this}; - - // Context Settings - Utils::BoolAspect useSystemPrompt{this}; - Utils::StringAspect systemPrompt{this}; - - // Ollama Settings - Utils::StringAspect ollamaLivetime{this}; - Utils::IntegerAspect contextWindow{this}; - - // Extended Thinking Settings (Claude only) - Utils::BoolAspect enableThinkingMode{this}; - Utils::IntegerAspect thinkingBudgetTokens{this}; - Utils::IntegerAspect thinkingMaxTokens{this}; - - // OpenAI Responses API Settings - Utils::SelectionAspect openAIResponsesReasoningEffort{this}; - // Visuals settings Utils::SelectionAspect textFontFamily{this}; Utils::IntegerAspect textFontSize{this}; @@ -69,8 +34,6 @@ public: Utils::SelectionAspect chatRenderer{this}; - Utils::StringAspect lastUsedRoleId{this}; - private: void setupConnections(); void resetSettingsToDefaults(); diff --git a/settings/CodeCompletionSettings.cpp b/settings/CodeCompletionSettings.cpp index 4344fa8..8ea0fd6 100644 --- a/settings/CodeCompletionSettings.cpp +++ b/settings/CodeCompletionSettings.cpp @@ -165,54 +165,6 @@ CodeCompletionSettings::CodeCompletionSettings() "for triggering completions. This helps trigger completions based on actual code " "characters only.")); - // General Parameters Settings - temperature.setSettingsKey(Constants::CC_TEMPERATURE); - temperature.setLabelText(Tr::tr("Temperature:")); - temperature.setDefaultValue(0.2); - temperature.setRange(0.0, 2.0); - temperature.setSingleStep(0.1); - - maxTokens.setSettingsKey(Constants::CC_MAX_TOKENS); - maxTokens.setLabelText(Tr::tr("Max Tokens:")); - maxTokens.setRange(-1, 900000); - maxTokens.setDefaultValue(500); - - // Advanced Parameters - useTopP.setSettingsKey(Constants::CC_USE_TOP_P); - useTopP.setDefaultValue(false); - useTopP.setLabelText(Tr::tr("Top P:")); - - topP.setSettingsKey(Constants::CC_TOP_P); - topP.setDefaultValue(0.9); - topP.setRange(0.0, 1.0); - topP.setSingleStep(0.1); - - useTopK.setSettingsKey(Constants::CC_USE_TOP_K); - useTopK.setDefaultValue(false); - useTopK.setLabelText(Tr::tr("Top K:")); - - topK.setSettingsKey(Constants::CC_TOP_K); - topK.setDefaultValue(50); - topK.setRange(1, 1000); - - usePresencePenalty.setSettingsKey(Constants::CC_USE_PRESENCE_PENALTY); - usePresencePenalty.setDefaultValue(false); - usePresencePenalty.setLabelText(Tr::tr("Presence Penalty:")); - - presencePenalty.setSettingsKey(Constants::CC_PRESENCE_PENALTY); - presencePenalty.setDefaultValue(0.0); - presencePenalty.setRange(-2.0, 2.0); - presencePenalty.setSingleStep(0.1); - - useFrequencyPenalty.setSettingsKey(Constants::CC_USE_FREQUENCY_PENALTY); - useFrequencyPenalty.setDefaultValue(false); - useFrequencyPenalty.setLabelText(Tr::tr("Frequency Penalty:")); - - frequencyPenalty.setSettingsKey(Constants::CC_FREQUENCY_PENALTY); - frequencyPenalty.setDefaultValue(0.0); - frequencyPenalty.setRange(-2.0, 2.0); - frequencyPenalty.setSingleStep(0.1); - // Context Settings readFullFile.setSettingsKey(Constants::CC_READ_FULL_FILE); readFullFile.setLabelText(Tr::tr("Read Full File")); @@ -230,53 +182,6 @@ CodeCompletionSettings::CodeCompletionSettings() readStringsAfterCursor.setRange(0, 10000); readStringsAfterCursor.setDefaultValue(30); - useSystemPrompt.setSettingsKey(Constants::CC_USE_SYSTEM_PROMPT); - useSystemPrompt.setDefaultValue(true); - useSystemPrompt.setLabelText(Tr::tr("Use System Prompt")); - - systemPrompt.setSettingsKey(Constants::CC_SYSTEM_PROMPT); - systemPrompt.setDisplayStyle(Utils::StringAspect::TextEditDisplay); - systemPrompt.setDefaultValue( - "You are an expert C++, Qt, and QML code completion assistant. Your task is to provide " - "precise and contextually appropriate code completions.\n\n"); - - useUserMessageTemplateForCC.setSettingsKey(Constants::CC_USE_USER_TEMPLATE); - useUserMessageTemplateForCC.setDefaultValue(true); - useUserMessageTemplateForCC.setLabelText( - Tr::tr("Use special system prompt and user message for non FIM models")); - - systemPromptForNonFimModels.setSettingsKey(Constants::CC_SYSTEM_PROMPT_FOR_NON_FIM); - systemPromptForNonFimModels.setDisplayStyle(Utils::StringAspect::TextEditDisplay); - systemPromptForNonFimModels.setLabelText(Tr::tr("System prompt for non FIM models:")); - systemPromptForNonFimModels.setDefaultValue( - "You are an expert C++, Qt, and QML code completion assistant. Your task is to provide " - "precise and contextually appropriate code completions.\n\n" - "Core Requirements:\n" - "1. Continue code exactly from the cursor position, ensuring it properly connects with any " - "existing code after the cursor\n" - "2. Never repeat existing code before or after the cursor\n" - "Specific Guidelines:\n" - "- For function calls: Complete parameters with appropriate types and names\n" - "- For class members: Respect access modifiers and class conventions\n" - "- Respect existing indentation and formatting\n" - "- Consider scope and visibility of referenced symbols\n" - "- Ensure seamless integration with code both before and after the cursor\n\n" - "Context Format:\n" - "\n" - "{{code before cursor}}{{code after cursor}}\n" - "\n\n" - "Response Format:\n" - "- No explanations or comments\n" - "- Only include new characters needed to create valid code\n" - "- Should be codeblock with language\n"); - - userMessageTemplateForCC.setSettingsKey(Constants::CC_USER_TEMPLATE); - userMessageTemplateForCC.setDisplayStyle(Utils::StringAspect::TextEditDisplay); - userMessageTemplateForCC.setLabelText(Tr::tr("User message for non FIM models:")); - userMessageTemplateForCC.setDefaultValue( - "Here is the code context with insertion points:\n" - "\n${prefix}${suffix}\n\n\n"); - customLanguages.setSettingsKey(Constants::CC_CUSTOM_LANGUAGES); customLanguages.setLabelText( Tr::tr("Additional Programming Languages for handling: Example: rust,//,rust rs,rs")); @@ -311,39 +216,6 @@ CodeCompletionSettings::CodeCompletionSettings() maxChangesCacheSize.setRange(2, 1000); maxChangesCacheSize.setDefaultValue(10); - // Ollama Settings - ollamaLivetime.setSettingsKey(Constants::CC_OLLAMA_LIVETIME); - ollamaLivetime.setToolTip( - Tr::tr("Time to suspend Ollama after completion request (in minutes), " - "Only Ollama, -1 to disable")); - ollamaLivetime.setLabelText("Livetime:"); - ollamaLivetime.setDefaultValue("5m"); - ollamaLivetime.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - - contextWindow.setSettingsKey(Constants::CC_OLLAMA_CONTEXT_WINDOW); - contextWindow.setLabelText(Tr::tr("Context Window:")); - contextWindow.setRange(-1, 10000); - contextWindow.setDefaultValue(2048); - - // OpenAI Responses API Settings - openAIResponsesReasoningEffort.setSettingsKey(Constants::CC_OPENAI_RESPONSES_REASONING_EFFORT); - openAIResponsesReasoningEffort.setLabelText(Tr::tr("Reasoning effort:")); - openAIResponsesReasoningEffort.setDisplayStyle(Utils::SelectionAspect::DisplayStyle::ComboBox); - openAIResponsesReasoningEffort.addOption("None"); - openAIResponsesReasoningEffort.addOption("Minimal"); - openAIResponsesReasoningEffort.addOption("Low"); - openAIResponsesReasoningEffort.addOption("Medium"); - openAIResponsesReasoningEffort.addOption("High"); - openAIResponsesReasoningEffort.setDefaultValue("Medium"); - openAIResponsesReasoningEffort.setToolTip( - Tr::tr("Constrains effort on reasoning for OpenAI gpt-5 and o-series models:\n\n" - "None: No reasoning (gpt-5.1 only)\n" - "Minimal: Minimal reasoning effort (o-series only)\n" - "Low: Low reasoning effort\n" - "Medium: Balanced reasoning (default for most models)\n" - "High: Maximum reasoning effort (gpt-5-pro only supports this)\n\n" - "Note: Reducing effort = faster responses + fewer tokens")); - resetToDefaults.m_buttonText = Tr::tr("Reset Page to Defaults"); readSettings(); @@ -357,39 +229,13 @@ CodeCompletionSettings::CodeCompletionSettings() setLayouter([this]() { using namespace Layouting; - auto genGrid = Grid{}; - genGrid.addRow({Row{temperature}}); - genGrid.addRow({Row{maxTokens}}); - - auto advancedGrid = Grid{}; - advancedGrid.addRow({useTopP, topP}); - advancedGrid.addRow({useTopK, topK}); - advancedGrid.addRow({usePresencePenalty, presencePenalty}); - advancedGrid.addRow({useFrequencyPenalty, frequencyPenalty}); - - auto ollamaGrid = Grid{}; - ollamaGrid.addRow({ollamaLivetime}); - ollamaGrid.addRow({contextWindow}); - - auto openAIResponsesGrid = Grid{}; - openAIResponsesGrid.addRow({openAIResponsesReasoningEffort}); - auto contextGrid = Grid{}; contextGrid.addRow({Row{readFullFile}}); contextGrid.addRow({Row{readFileParts, readStringsBeforeCursor, readStringsAfterCursor}}); auto contextItem = Column{ Row{contextGrid, Stretch{1}}, - Row{useSystemPrompt, Stretch{1}}, - Group{title(Tr::tr("Prompts for FIM models")), Column{systemPrompt}}, - Group{ - title(Tr::tr("Prompts for Non FIM models")), - Column{ - Row{useUserMessageTemplateForCC, Stretch{1}}, - systemPromptForNonFimModels, - userMessageTemplateForCC, - customLanguages, - }}, + customLanguages, Row{useProjectChangesCache, maxChangesCacheSize, Stretch{1}}}; auto generalSettings = Column{ @@ -418,19 +264,7 @@ CodeCompletionSettings::CodeCompletionSettings() Space{8}, Group{title(Tr::tr("Automatic Trigger Mode")), autoTriggerSettings}}}, Space{8}, - Group{title(Tr::tr("General Parameters")), - Column{ - 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")), contextItem}, - Space{8}, - Group{title(Tr::tr("OpenAI Responses API")), Column{Row{openAIResponsesGrid, Stretch{1}}}}, - Space{8}, - Group{title(Tr::tr("Ollama Settings")), Column{Row{ollamaGrid, Stretch{1}}}}, Stretch{1}}; }); } @@ -470,30 +304,12 @@ void CodeCompletionSettings::resetSettingsToDefaults() if (reply == QMessageBox::Yes) { resetAspect(autoCompletion); resetAspect(multiLineCompletion); - resetAspect(temperature); - resetAspect(maxTokens); - resetAspect(useTopP); - resetAspect(topP); - resetAspect(useTopK); - resetAspect(topK); - resetAspect(usePresencePenalty); - resetAspect(presencePenalty); - resetAspect(useFrequencyPenalty); - resetAspect(frequencyPenalty); resetAspect(readFullFile); resetAspect(readFileParts); resetAspect(readStringsBeforeCursor); resetAspect(readStringsAfterCursor); - resetAspect(useSystemPrompt); - resetAspect(systemPrompt); resetAspect(useProjectChangesCache); resetAspect(maxChangesCacheSize); - resetAspect(ollamaLivetime); - resetAspect(contextWindow); - resetAspect(openAIResponsesReasoningEffort); - resetAspect(useUserMessageTemplateForCC); - resetAspect(userMessageTemplateForCC); - resetAspect(systemPromptForNonFimModels); resetAspect(customLanguages); resetAspect(showProgressWidget); resetAspect(useOpenFilesContext); @@ -527,14 +343,6 @@ void CodeCompletionSettings::migrateCompletionMode() writeSettings(); } -QString CodeCompletionSettings::processMessageToFIM(const QString &prefix, const QString &suffix) const -{ - QString result = userMessageTemplateForCC(); - result.replace("${prefix}", prefix); - result.replace("${suffix}", suffix); - return result; -} - class CodeCompletionSettingsPage : public Core::IOptionsPage { public: diff --git a/settings/CodeCompletionSettings.hpp b/settings/CodeCompletionSettings.hpp index 131aca9..7980c14 100644 --- a/settings/CodeCompletionSettings.hpp +++ b/settings/CodeCompletionSettings.hpp @@ -41,45 +41,14 @@ public: Utils::BoolAspect abortAssistOnRequest{this}; Utils::BoolAspect useOpenFilesContext{this}; - // General Parameters Settings - Utils::DoubleAspect temperature{this}; - Utils::IntegerAspect maxTokens{this}; - - // Advanced Parameters - Utils::BoolAspect useTopP{this}; - Utils::DoubleAspect topP{this}; - - Utils::BoolAspect useTopK{this}; - Utils::IntegerAspect topK{this}; - - Utils::BoolAspect usePresencePenalty{this}; - Utils::DoubleAspect presencePenalty{this}; - - Utils::BoolAspect useFrequencyPenalty{this}; - Utils::DoubleAspect frequencyPenalty{this}; - // Context Settings Utils::BoolAspect readFullFile{this}; Utils::BoolAspect readFileParts{this}; Utils::IntegerAspect readStringsBeforeCursor{this}; Utils::IntegerAspect readStringsAfterCursor{this}; - Utils::BoolAspect useSystemPrompt{this}; - Utils::StringAspect systemPrompt{this}; - Utils::BoolAspect useUserMessageTemplateForCC{this}; - Utils::StringAspect systemPromptForNonFimModels{this}; - Utils::StringAspect userMessageTemplateForCC{this}; Utils::BoolAspect useProjectChangesCache{this}; Utils::IntegerAspect maxChangesCacheSize{this}; - // Ollama Settings - Utils::StringAspect ollamaLivetime{this}; - Utils::IntegerAspect contextWindow{this}; - - // OpenAI Responses API Settings - Utils::SelectionAspect openAIResponsesReasoningEffort{this}; - - QString processMessageToFIM(const QString &prefix, const QString &suffix) const; - private: void setupConnections(); void resetSettingsToDefaults(); diff --git a/settings/ConfigurationManager.cpp b/settings/ConfigurationManager.cpp deleted file mode 100644 index 19cb092..0000000 --- a/settings/ConfigurationManager.cpp +++ /dev/null @@ -1,373 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "ConfigurationManager.hpp" - -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "Logger.hpp" -#include "ProviderNameMigration.hpp" - -namespace QodeAssist::Settings { - -ConfigurationManager::ConfigurationManager(QObject *parent) - : QObject(parent) -{} - -ConfigurationManager &ConfigurationManager::instance() -{ - static ConfigurationManager instance; - return instance; -} - -QVector ConfigurationManager::getPredefinedConfigurations( - ConfigurationType type) -{ - QVector presets; - - AIConfiguration claudeOpus; - claudeOpus.id = "preset_claude_opus"; - claudeOpus.name = "Claude Opus 4.7"; - claudeOpus.provider = "Claude"; - claudeOpus.model = "claude-opus-4-7"; - claudeOpus.url = "https://api.anthropic.com"; - claudeOpus.customEndpoint = ""; - claudeOpus.templateName = "Claude"; - claudeOpus.type = type; - claudeOpus.isPredefined = true; - - AIConfiguration claudeSonnet; - claudeSonnet.id = "preset_claude_sonnet"; - claudeSonnet.name = "Claude Sonnet 4.6"; - claudeSonnet.provider = "Claude"; - claudeSonnet.model = "claude-sonnet-4-6"; - claudeSonnet.url = "https://api.anthropic.com"; - claudeSonnet.customEndpoint = ""; - claudeSonnet.templateName = "Claude"; - claudeSonnet.type = type; - claudeSonnet.isPredefined = true; - - AIConfiguration claudeHaiku; - claudeHaiku.id = "preset_claude_haiku"; - claudeHaiku.name = "Claude Haiku 4.5"; - claudeHaiku.provider = "Claude"; - claudeHaiku.model = "claude-haiku-4-5-20251001"; - claudeHaiku.url = "https://api.anthropic.com"; - claudeHaiku.customEndpoint = ""; - claudeHaiku.templateName = "Claude"; - claudeHaiku.type = type; - claudeHaiku.isPredefined = true; - - AIConfiguration codestral; - codestral.id = "preset_codestral"; - codestral.name = "Codestral"; - codestral.provider = "Codestral"; - codestral.model = "codestral-latest"; - codestral.url = "https://codestral.mistral.ai"; - codestral.customEndpoint = ""; - codestral.templateName = type == ConfigurationType::CodeCompletion ? "Mistral AI FIM" : "Mistral AI Chat"; - codestral.type = type; - codestral.isPredefined = true; - - AIConfiguration mistral; - mistral.id = "preset_mistral"; - mistral.name = "Mistral"; - mistral.provider = "Mistral AI"; - mistral.model = type == ConfigurationType::CodeCompletion ? "codestral-latest" : "mistral-large-latest"; - mistral.url = "https://api.mistral.ai"; - mistral.customEndpoint = ""; - mistral.templateName = type == ConfigurationType::CodeCompletion ? "Mistral AI FIM" : "Mistral AI Chat"; - mistral.type = type; - mistral.isPredefined = true; - - AIConfiguration geminiFlash; - geminiFlash.id = "preset_gemini_flash"; - geminiFlash.name = "Gemini 2.5 Flash"; - geminiFlash.provider = "Google AI"; - geminiFlash.model = "gemini-2.5-flash"; - geminiFlash.url = "https://generativelanguage.googleapis.com/v1beta"; - geminiFlash.customEndpoint = ""; - geminiFlash.templateName = "Google AI"; - geminiFlash.type = type; - geminiFlash.isPredefined = true; - - AIConfiguration qwenPlus; - qwenPlus.id = "preset_qwen_plus"; - qwenPlus.name = "Qwen3.6 Plus"; - qwenPlus.provider = "Qwen (OpenAI Response)"; - qwenPlus.model = "qwen3.6-plus"; - qwenPlus.url = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; - qwenPlus.customEndpoint = ""; - qwenPlus.templateName = "OpenAI Responses"; - qwenPlus.type = type; - qwenPlus.isPredefined = true; - - AIConfiguration qwenMax; - qwenMax.id = "preset_qwen_max"; - qwenMax.name = "Qwen3.7 Max"; - qwenMax.provider = "Qwen (OpenAI Response)"; - qwenMax.model = "qwen3.7-max"; - qwenMax.url = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; - qwenMax.customEndpoint = ""; - qwenMax.templateName = "OpenAI Responses"; - qwenMax.type = type; - qwenMax.isPredefined = true; - - AIConfiguration deepSeekFlash; - deepSeekFlash.id = "preset_deepseek_flash"; - deepSeekFlash.name = "DeepSeek V4 Flash"; - deepSeekFlash.provider = "DeepSeek"; - deepSeekFlash.model = "deepseek-v4-flash"; - deepSeekFlash.url = "https://api.deepseek.com"; - deepSeekFlash.customEndpoint = ""; - deepSeekFlash.templateName = "OpenAI Compatible"; - deepSeekFlash.type = type; - deepSeekFlash.isPredefined = true; - - AIConfiguration deepSeekPro; - deepSeekPro.id = "preset_deepseek_pro"; - deepSeekPro.name = "DeepSeek V4 Pro"; - deepSeekPro.provider = "DeepSeek"; - deepSeekPro.model = "deepseek-v4-pro"; - deepSeekPro.url = "https://api.deepseek.com"; - deepSeekPro.customEndpoint = ""; - deepSeekPro.templateName = "OpenAI Compatible"; - deepSeekPro.type = type; - deepSeekPro.isPredefined = true; - - AIConfiguration gpt; - gpt.id = "preset_gpt"; - gpt.name = "gpt-5.5"; - gpt.provider = "OpenAI (Responses API)"; - gpt.model = "gpt-5.5"; - gpt.url = "https://api.openai.com/v1"; - gpt.customEndpoint = ""; - gpt.templateName = "OpenAI Responses"; - gpt.type = type; - gpt.isPredefined = true; - - presets.append(claudeSonnet); - presets.append(claudeHaiku); - presets.append(claudeOpus); - presets.append(gpt); - presets.append(codestral); - presets.append(mistral); - presets.append(geminiFlash); - presets.append(qwenPlus); - presets.append(qwenMax); - presets.append(deepSeekFlash); - presets.append(deepSeekPro); - - return presets; -} - -QString ConfigurationManager::configurationTypeToString(ConfigurationType type) const -{ - switch (type) { - case ConfigurationType::CodeCompletion: - return "code_completion"; - case ConfigurationType::Chat: - return "chat"; - case ConfigurationType::QuickRefactor: - return "quick_refactor"; - } - return "unknown"; -} - -QString ConfigurationManager::getConfigurationDirectory(ConfigurationType type) const -{ - QString path = QString("%1/qodeassist/configurations/%2") - .arg(Core::ICore::userResourcePath().toFSPathString(), - configurationTypeToString(type)); - return path; -} - -bool ConfigurationManager::ensureDirectoryExists(ConfigurationType type) const -{ - QDir dir(getConfigurationDirectory(type)); - if (!dir.exists()) { - return dir.mkpath("."); - } - return true; -} - -bool ConfigurationManager::loadConfigurations(ConfigurationType type) -{ - QVector *configs = nullptr; - switch (type) { - case ConfigurationType::CodeCompletion: - configs = &m_ccConfigurations; - break; - case ConfigurationType::Chat: - configs = &m_caConfigurations; - break; - case ConfigurationType::QuickRefactor: - configs = &m_qrConfigurations; - break; - } - - if (!configs) { - return false; - } - - configs->clear(); - - QVector predefinedConfigs = getPredefinedConfigurations(type); - configs->append(predefinedConfigs); - - if (!ensureDirectoryExists(type)) { - LOG_MESSAGE("Failed to create configuration directory"); - return false; - } - - QDir dir(getConfigurationDirectory(type)); - QStringList filters; - filters << "*.json"; - QFileInfoList files = dir.entryInfoList(filters, QDir::Files); - - for (const QFileInfo &fileInfo : files) { - QFile file(fileInfo.absoluteFilePath()); - if (!file.open(QIODevice::ReadOnly)) { - LOG_MESSAGE(QString("Failed to open configuration file: %1").arg(fileInfo.fileName())); - continue; - } - - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - file.close(); - - if (!doc.isObject()) { - LOG_MESSAGE(QString("Invalid configuration file: %1").arg(fileInfo.fileName())); - continue; - } - - QJsonObject obj = doc.object(); - AIConfiguration config; - config.id = obj["id"].toString(); - config.name = obj["name"].toString(); - config.provider = migrateProviderName(obj["provider"].toString()); - config.model = obj["model"].toString(); - config.templateName = obj["template"].toString(); - config.url = obj["url"].toString(); - config.customEndpoint = obj["customEndpoint"].toString(); - config.type = type; - config.formatVersion = obj.value("formatVersion").toInt(1); - - config.isPredefined = false; - - if (config.id.isEmpty() || config.name.isEmpty()) { - LOG_MESSAGE(QString("Invalid configuration data in file: %1").arg(fileInfo.fileName())); - continue; - } - - configs->append(config); - } - - emit configurationsChanged(type); - return true; -} - -bool ConfigurationManager::saveConfiguration(const AIConfiguration &config) -{ - if (!ensureDirectoryExists(config.type)) { - LOG_MESSAGE("Failed to create configuration directory"); - return false; - } - - QJsonObject obj; - obj["formatVersion"] = config.formatVersion; - obj["id"] = config.id; - obj["name"] = config.name; - obj["provider"] = config.provider; - obj["model"] = config.model; - obj["template"] = config.templateName; - obj["url"] = config.url; - obj["customEndpoint"] = config.customEndpoint; - - QString sanitizedName = config.name; - sanitizedName.replace(" ", "_"); - sanitizedName.replace(QRegularExpression("[^a-zA-Z0-9_-]"), ""); - - QString fileName = QString("%1/%2_%3.json") - .arg(getConfigurationDirectory(config.type), sanitizedName, config.id); - - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - LOG_MESSAGE(QString("Failed to create configuration file: %1").arg(fileName)); - return false; - } - - QJsonDocument doc(obj); - file.write(doc.toJson(QJsonDocument::Indented)); - file.close(); - - loadConfigurations(config.type); - return true; -} - -bool ConfigurationManager::deleteConfiguration(const QString &id, ConfigurationType type) -{ - AIConfiguration config = getConfigurationById(id, type); - if (config.isPredefined) { - LOG_MESSAGE(QString("Cannot delete predefined configuration: %1").arg(id)); - return false; - } - - QDir dir(getConfigurationDirectory(type)); - QStringList filters; - filters << QString("*_%1.json").arg(id); - QFileInfoList files = dir.entryInfoList(filters, QDir::Files); - - if (files.isEmpty()) { - LOG_MESSAGE(QString("Configuration file not found for id: %1").arg(id)); - return false; - } - - for (const QFileInfo &fileInfo : files) { - QFile file(fileInfo.absoluteFilePath()); - if (!file.remove()) { - LOG_MESSAGE(QString("Failed to delete configuration file: %1") - .arg(fileInfo.absoluteFilePath())); - return false; - } - } - - loadConfigurations(type); - return true; -} - -QVector ConfigurationManager::configurations(ConfigurationType type) const -{ - switch (type) { - case ConfigurationType::CodeCompletion: - return m_ccConfigurations; - case ConfigurationType::Chat: - return m_caConfigurations; - case ConfigurationType::QuickRefactor: - return m_qrConfigurations; - } - return {}; -} - -AIConfiguration ConfigurationManager::getConfigurationById(const QString &id, - ConfigurationType type) const -{ - const QVector &configs = configurations(type); - for (const AIConfiguration &config : configs) { - if (config.id == id) { - return config; - } - } - return AIConfiguration(); -} - -} // namespace QodeAssist::Settings - diff --git a/settings/ConfigurationManager.hpp b/settings/ConfigurationManager.hpp deleted file mode 100644 index 306ad1f..0000000 --- a/settings/ConfigurationManager.hpp +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include - -namespace QodeAssist::Settings { - -enum class ConfigurationType { CodeCompletion, Chat, QuickRefactor }; - -inline constexpr int CONFIGURATION_FORMAT_VERSION = 1; - -struct AIConfiguration -{ - QString id; - QString name; - QString provider; - QString model; - QString templateName; - QString url; - // Empty = use the template's endpoint; non-empty = override path. - QString customEndpoint; - ConfigurationType type; - int formatVersion = CONFIGURATION_FORMAT_VERSION; - bool isPredefined = false; -}; - -class ConfigurationManager : public QObject -{ - Q_OBJECT - -public: - static ConfigurationManager &instance(); - - bool loadConfigurations(ConfigurationType type); - bool saveConfiguration(const AIConfiguration &config); - bool deleteConfiguration(const QString &id, ConfigurationType type); - - QVector configurations(ConfigurationType type) const; - AIConfiguration getConfigurationById(const QString &id, ConfigurationType type) const; - - QString getConfigurationDirectory(ConfigurationType type) const; - - static QVector getPredefinedConfigurations(ConfigurationType type); - -signals: - void configurationsChanged(ConfigurationType type); - -private: - explicit ConfigurationManager(QObject *parent = nullptr); - ~ConfigurationManager() override = default; - - bool ensureDirectoryExists(ConfigurationType type) const; - QString configurationTypeToString(ConfigurationType type) const; - - QVector m_ccConfigurations; - QVector m_caConfigurations; - QVector m_qrConfigurations; -}; - -} // namespace QodeAssist::Settings - diff --git a/settings/GeneralSettings.cpp b/settings/GeneralSettings.cpp index 8f7df30..c311e80 100644 --- a/settings/GeneralSettings.cpp +++ b/settings/GeneralSettings.cpp @@ -6,47 +6,29 @@ #include #include +#include #include -#include -#include -#include -#include -#include +#include +#include +#include #include #include -#include -#include #include -#include -#include -#include -#include -#include -#include -#include +#include #include "../Version.hpp" -#include "ConfigurationManager.hpp" +#include "AgentRosterWidget.hpp" +#include "AgentsSettingsPage.hpp" #include "Logger.hpp" -#include "ProviderNameMigration.hpp" +#include "PipelinesConfig.hpp" #include "SettingsConstants.hpp" -#include "SettingsDialog.hpp" #include "SettingsTr.hpp" #include "SettingsUtils.hpp" #include "UpdateDialog.hpp" -namespace QodeAssist::Settings { +#include -void addDialogButtons(QBoxLayout *layout, QAbstractButton *okButton, QAbstractButton *cancelButton) -{ -#if defined(Q_OS_MACOS) - layout->addWidget(cancelButton); - layout->addWidget(okButton); -#else - layout->addWidget(okButton); - layout->addWidget(cancelButton); -#endif -} +namespace QodeAssist::Settings { GeneralSettings &generalSettings() { @@ -54,6 +36,190 @@ GeneralSettings &generalSettings() return settings; } +namespace { + +constexpr int kSaveDebounceMs = 300; + +class AgentPipelinesWidget : public QWidget +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(AgentPipelinesWidget) +public: + AgentPipelinesWidget( + const QPointer &agentFactory, + const QPointer &agentsNavigator, + QWidget *parent = nullptr) + : QWidget(parent) + , m_agentFactory(agentFactory) + , m_agentsNavigator(agentsNavigator) + { + m_titleLabel = new QLabel(Tr::tr(TrConstants::AGENT_PIPELINES), this); + QFont tf = m_titleLabel->font(); + tf.setBold(true); + tf.setPixelSize(13); + m_titleLabel->setFont(tf); + + auto *headerRow = new QHBoxLayout; + headerRow->setContentsMargins(0, 0, 0, 0); + headerRow->setSpacing(8); + headerRow->addWidget(m_titleLabel); + headerRow->addStretch(1); + + auto *headerSep = new QFrame(this); + headerSep->setFrameShape(QFrame::HLine); + headerSep->setFrameShadow(QFrame::Sunken); + + m_loadWarning = new Utils::InfoLabel({}, Utils::InfoLabel::Warning, this); + m_loadWarning->setElideMode(Qt::ElideNone); + m_loadWarning->setWordWrap(true); + m_loadWarning->setVisible(false); + + m_completionRoster = new AgentRosterWidget(this); + m_completionRoster->setSlot( + Tr::tr(TrConstants::CODE_COMPLETION), + Tr::tr(TrConstants::SLOT_HINT_CODE_COMPLETION), + {QStringLiteral("completion")}); + + m_chatRoster = new AgentRosterWidget(this); + m_chatRoster->setSlot( + Tr::tr(TrConstants::CHAT_ASSISTANT), + Tr::tr(TrConstants::SLOT_HINT_CHAT_ASSISTANT), + {QStringLiteral("chat")}); + m_chatRoster->setOrderable(false); + + m_compressionRoster = new AgentRosterWidget(this); + m_compressionRoster->setSlot( + Tr::tr(TrConstants::CHAT_COMPRESSION), + Tr::tr(TrConstants::SLOT_HINT_CHAT_COMPRESSION), + {QStringLiteral("compression")}); + m_compressionRoster->setSingle(true); + + m_refactorRoster = new AgentRosterWidget(this); + m_refactorRoster->setSlot( + Tr::tr(TrConstants::QUICK_REFACTOR), + Tr::tr(TrConstants::SLOT_HINT_QUICK_REFACTOR), + {QStringLiteral("refactor")}); + m_refactorRoster->setSingle(true); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(12); + root->addLayout(headerRow); + root->addWidget(headerSep); + root->addWidget(m_loadWarning); + root->addWidget(m_completionRoster); + root->addWidget(m_chatRoster); + root->addWidget(m_compressionRoster); + root->addWidget(m_refactorRoster); + + m_saveDebounce = new QTimer(this); + m_saveDebounce->setSingleShot(true); + m_saveDebounce->setInterval(kSaveDebounceMs); + connect(m_saveDebounce, &QTimer::timeout, this, [this]() { persist(); }); + + loadFromSettings(); + + for (AgentRosterWidget *roster : + {m_completionRoster, m_chatRoster, m_compressionRoster, m_refactorRoster}) { + connect(roster, &AgentRosterWidget::editAgentRequested, this, + &AgentPipelinesWidget::onEditAgent); + connect(roster, &AgentRosterWidget::rosterChanged, this, + [this](const QStringList &) { m_saveDebounce->start(); }); + } + } + + ~AgentPipelinesWidget() override + { + if (m_saveDebounce && m_saveDebounce->isActive()) { + m_saveDebounce->stop(); + persist(/*interactive*/ false); + } + } + + void resetToDefaults() + { + QString err; + if (!PipelinesConfig::save(PipelineRosters::defaults(), &err)) + LOG_MESSAGE(QStringLiteral("[Pipelines] failed to reset rosters: %1").arg(err)); + + m_saveErrorShown = false; + loadFromSettings(); + } + +private: + void persist(bool interactive = true) + { + PipelineRosters rosters; + rosters.codeCompletion = m_completionRoster->roster(); + rosters.chatAssistant = m_chatRoster->roster(); + rosters.chatCompression = m_compressionRoster->roster().value(0); + rosters.quickRefactor = m_refactorRoster->roster().value(0); + QString err; + if (!PipelinesConfig::save(rosters, &err)) { + LOG_MESSAGE(QStringLiteral("[Pipelines] save failed (%1): %2") + .arg(PipelinesConfig::filePath(), err)); + if (interactive && !m_saveErrorShown) { + m_saveErrorShown = true; + QMessageBox::warning( + Core::ICore::dialogParent(), + Tr::tr(TrConstants::AGENT_PIPELINES), + tr("Failed to save pipelines.toml:\n%1\n\n" + "Further save failures will only be logged.") + .arg(err)); + } + } else { + m_saveErrorShown = false; + } + } + + void onEditAgent(const QString &name) + { + if (m_agentsNavigator) + m_agentsNavigator->requestSelectAgent(name); + + showSettings(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID); + } + + void loadFromSettings() + { + const PipelinesLoadResult lr = PipelinesConfig::load(); + const bool broken = lr.status == PipelinesLoadStatus::ParseError + || lr.status == PipelinesLoadStatus::SchemaError; + if (broken) { + m_loadWarning->setText( + tr("pipelines.toml has issues — using defaults for affected entries:\n%1\n" + "Changes you make here will overwrite the file.") + .arg(lr.message)); + } + m_loadWarning->setVisible(broken); + + AgentFactory *factory = m_agentFactory.data(); + const auto asList = [](const QString &name) { + return name.isEmpty() ? QStringList{} : QStringList{name}; + }; + m_completionRoster->setRoster(lr.rosters.codeCompletion, factory); + m_chatRoster->setRoster(lr.rosters.chatAssistant, factory); + m_compressionRoster->setRoster(asList(lr.rosters.chatCompression), factory); + m_refactorRoster->setRoster(asList(lr.rosters.quickRefactor), factory); + } + + QPointer m_agentFactory; + QPointer m_agentsNavigator; + + QLabel *m_titleLabel = nullptr; + Utils::InfoLabel *m_loadWarning = nullptr; + + AgentRosterWidget *m_completionRoster = nullptr; + AgentRosterWidget *m_chatRoster = nullptr; + AgentRosterWidget *m_compressionRoster = nullptr; + AgentRosterWidget *m_refactorRoster = nullptr; + + QTimer *m_saveDebounce = nullptr; + bool m_saveErrorShown = false; +}; + +} // namespace + GeneralSettings::GeneralSettings() { setAutoApply(false); @@ -86,264 +252,16 @@ GeneralSettings::GeneralSettings() resetToDefaults.m_buttonText = TrConstants::RESET_TO_DEFAULTS; checkUpdate.m_buttonText = TrConstants::CHECK_UPDATE; - - ccPresetConfig.setDisplayStyle(Utils::SelectionAspect::DisplayStyle::ComboBox); - ccPresetConfig.setLabelText(Tr::tr("Quick Setup")); - loadPresetConfigurations(ccPresetConfig, ConfigurationType::CodeCompletion); - - ccConfigureApiKey.m_buttonText = Tr::tr("Configure API Key"); - ccConfigureApiKey.m_tooltip = Tr::tr("Open Provider Settings to configure API keys"); - - caPresetConfig.setDisplayStyle(Utils::SelectionAspect::DisplayStyle::ComboBox); - caPresetConfig.setLabelText(Tr::tr("Quick Setup")); - loadPresetConfigurations(caPresetConfig, ConfigurationType::Chat); - - caConfigureApiKey.m_buttonText = Tr::tr("Configure API Key"); - caConfigureApiKey.m_tooltip = Tr::tr("Open Provider Settings to configure API keys"); - - qrPresetConfig.setDisplayStyle(Utils::SelectionAspect::DisplayStyle::ComboBox); - qrPresetConfig.setLabelText(Tr::tr("Quick Setup")); - loadPresetConfigurations(qrPresetConfig, ConfigurationType::QuickRefactor); - - qrConfigureApiKey.m_buttonText = Tr::tr("Configure API Key"); - qrConfigureApiKey.m_tooltip = Tr::tr("Open Provider Settings to configure API keys"); - - initStringAspect(ccProvider, Constants::CC_PROVIDER, TrConstants::PROVIDER, "Ollama (Native)"); - ccProvider.setReadOnly(true); - ccSelectProvider.m_buttonText = TrConstants::SELECT; - - initStringAspect(ccModel, Constants::CC_MODEL, TrConstants::MODEL, "qwen2.5-coder:7b"); - ccModel.setHistoryCompleter(Constants::CC_MODEL_HISTORY); - ccSelectModel.m_buttonText = TrConstants::SELECT; - - initStringAspect(ccTemplate, Constants::CC_TEMPLATE, TrConstants::TEMPLATE, "Ollama FIM"); - ccTemplate.setReadOnly(true); - ccSelectTemplate.m_buttonText = TrConstants::SELECT; - - initStringAspect(ccUrl, Constants::CC_URL, TrConstants::URL, "http://localhost:11434"); - ccUrl.setHistoryCompleter(Constants::CC_URL_HISTORY); - ccSetUrl.m_buttonText = TrConstants::SELECT; - - initStringAspect(ccCustomEndpoint, Constants::CC_CUSTOM_ENDPOINT, TrConstants::CUSTOM_ENDPOINT, ""); - ccCustomEndpoint.setHistoryCompleter(Constants::CC_CUSTOM_ENDPOINT_HISTORY); - - ccStatus.setDisplayStyle(Utils::StringAspect::LabelDisplay); - ccStatus.setLabelText(TrConstants::STATUS); - ccStatus.setDefaultValue(""); - ccTest.m_buttonText = TrConstants::TEST; - - ccTemplateDescription.setDisplayStyle(Utils::StringAspect::TextEditDisplay); - ccTemplateDescription.setReadOnly(true); - ccTemplateDescription.setDefaultValue(""); - - ccSaveConfig.m_buttonText = TrConstants::SAVE_CONFIG; - ccLoadConfig.m_buttonText = TrConstants::LOAD_CONFIG; - ccLoadConfig.m_tooltip = Tr::tr("Load configuration (includes predefined cloud models)"); - ccOpenConfigFolder.m_buttonText = TrConstants::OPEN_CONFIG_FOLDER; - ccOpenConfigFolder.m_icon = Utils::Icons::OPENFILE.icon(); - ccOpenConfigFolder.m_isCompact = true; - - // preset1 - specifyPreset1.setSettingsKey(Constants::CC_SPECIFY_PRESET1); - specifyPreset1.setLabelText(TrConstants::ADD_NEW_PRESET); - specifyPreset1.setDefaultValue(false); - - preset1Language.setSettingsKey(Constants::CC_PRESET1_LANGUAGE); - preset1Language.setDisplayStyle(Utils::SelectionAspect::DisplayStyle::ComboBox); - // see ProgrammingLanguageUtils - preset1Language.addOption("qml"); - preset1Language.addOption("c/c++"); - preset1Language.addOption("python"); - - initStringAspect( - ccPreset1Provider, - Constants::CC_PRESET1_PROVIDER, - TrConstants::PROVIDER, - "Ollama (Native)"); - ccPreset1Provider.setReadOnly(true); - ccPreset1SelectProvider.m_buttonText = TrConstants::SELECT; - - initStringAspect( - ccPreset1Url, Constants::CC_PRESET1_URL, TrConstants::URL, "http://localhost:11434"); - ccPreset1Url.setHistoryCompleter(Constants::CC_PRESET1_URL_HISTORY); - ccPreset1SetUrl.m_buttonText = TrConstants::SELECT; - - initStringAspect( - ccPreset1CustomEndpoint, - Constants::CC_PRESET1_CUSTOM_ENDPOINT, - TrConstants::CUSTOM_ENDPOINT, - ""); - ccPreset1CustomEndpoint.setHistoryCompleter(Constants::CC_PRESET1_CUSTOM_ENDPOINT_HISTORY); - - initStringAspect( - ccPreset1Model, Constants::CC_PRESET1_MODEL, TrConstants::MODEL, "qwen2.5-coder:7b"); - ccPreset1Model.setHistoryCompleter(Constants::CC_PRESET1_MODEL_HISTORY); - ccPreset1SelectModel.m_buttonText = TrConstants::SELECT; - - initStringAspect( - ccPreset1Template, Constants::CC_PRESET1_TEMPLATE, TrConstants::TEMPLATE, "Ollama FIM"); - ccPreset1Template.setReadOnly(true); - ccPreset1SelectTemplate.m_buttonText = TrConstants::SELECT; - - // chat assistance - initStringAspect(caProvider, Constants::CA_PROVIDER, TrConstants::PROVIDER, "Ollama (Native)"); - caProvider.setReadOnly(true); - caSelectProvider.m_buttonText = TrConstants::SELECT; - - initStringAspect(caModel, Constants::CA_MODEL, TrConstants::MODEL, "qwen3.5:9b"); - caModel.setHistoryCompleter(Constants::CA_MODEL_HISTORY); - caSelectModel.m_buttonText = TrConstants::SELECT; - - initStringAspect(caTemplate, Constants::CA_TEMPLATE, TrConstants::TEMPLATE, "Ollama Chat"); - caTemplate.setReadOnly(true); - - caSelectTemplate.m_buttonText = TrConstants::SELECT; - - initStringAspect(caUrl, Constants::CA_URL, TrConstants::URL, "http://localhost:11434"); - caUrl.setHistoryCompleter(Constants::CA_URL_HISTORY); - caSetUrl.m_buttonText = TrConstants::SELECT; - - initStringAspect(caCustomEndpoint, Constants::CA_CUSTOM_ENDPOINT, TrConstants::CUSTOM_ENDPOINT, ""); - caCustomEndpoint.setHistoryCompleter(Constants::CA_CUSTOM_ENDPOINT_HISTORY); - - caStatus.setDisplayStyle(Utils::StringAspect::LabelDisplay); - caStatus.setLabelText(TrConstants::STATUS); - caStatus.setDefaultValue(""); - caTest.m_buttonText = TrConstants::TEST; - - caTemplateDescription.setDisplayStyle(Utils::StringAspect::TextEditDisplay); - caTemplateDescription.setReadOnly(true); - caTemplateDescription.setDefaultValue(""); - - caSaveConfig.m_buttonText = TrConstants::SAVE_CONFIG; - caLoadConfig.m_buttonText = TrConstants::LOAD_CONFIG; - caLoadConfig.m_tooltip = Tr::tr("Load configuration (includes predefined cloud models)"); - caOpenConfigFolder.m_buttonText = TrConstants::OPEN_CONFIG_FOLDER; - caOpenConfigFolder.m_icon = Utils::Icons::OPENFILE.icon(); - caOpenConfigFolder.m_isCompact = true; - - // quick refactor settings - initStringAspect(qrProvider, Constants::QR_PROVIDER, TrConstants::PROVIDER, "Ollama (Native)"); - qrProvider.setReadOnly(true); - qrSelectProvider.m_buttonText = TrConstants::SELECT; - - initStringAspect(qrModel, Constants::QR_MODEL, TrConstants::MODEL, "qwen3.5:9b"); - qrModel.setHistoryCompleter(Constants::QR_MODEL_HISTORY); - qrSelectModel.m_buttonText = TrConstants::SELECT; - - initStringAspect(qrTemplate, Constants::QR_TEMPLATE, TrConstants::TEMPLATE, "Ollama Chat"); - qrTemplate.setReadOnly(true); - - qrSelectTemplate.m_buttonText = TrConstants::SELECT; - - initStringAspect(qrUrl, Constants::QR_URL, TrConstants::URL, "http://localhost:11434"); - qrUrl.setHistoryCompleter(Constants::QR_URL_HISTORY); - qrSetUrl.m_buttonText = TrConstants::SELECT; - - initStringAspect(qrCustomEndpoint, Constants::QR_CUSTOM_ENDPOINT, TrConstants::CUSTOM_ENDPOINT, ""); - qrCustomEndpoint.setHistoryCompleter(Constants::QR_CUSTOM_ENDPOINT_HISTORY); - - qrStatus.setDisplayStyle(Utils::StringAspect::LabelDisplay); - qrStatus.setLabelText(TrConstants::STATUS); - qrStatus.setDefaultValue(""); - qrTest.m_buttonText = TrConstants::TEST; - - qrTemplateDescription.setDisplayStyle(Utils::StringAspect::TextEditDisplay); - qrTemplateDescription.setReadOnly(true); - qrTemplateDescription.setDefaultValue(""); - - qrSaveConfig.m_buttonText = TrConstants::SAVE_CONFIG; - qrLoadConfig.m_buttonText = TrConstants::LOAD_CONFIG; - qrLoadConfig.m_tooltip = Tr::tr("Load configuration (includes predefined cloud models)"); - qrOpenConfigFolder.m_buttonText = TrConstants::OPEN_CONFIG_FOLDER; - qrOpenConfigFolder.m_icon = Utils::Icons::OPENFILE.icon(); - qrOpenConfigFolder.m_isCompact = true; - - ccShowTemplateInfo.m_icon = Utils::Icons::INFO.icon(); - ccShowTemplateInfo.m_tooltip = Tr::tr("Show template information"); - ccShowTemplateInfo.m_isCompact = true; - - caShowTemplateInfo.m_icon = Utils::Icons::INFO.icon(); - caShowTemplateInfo.m_tooltip = Tr::tr("Show template information"); - caShowTemplateInfo.m_isCompact = true; - - qrShowTemplateInfo.m_icon = Utils::Icons::INFO.icon(); - qrShowTemplateInfo.m_tooltip = Tr::tr("Show template information"); - qrShowTemplateInfo.m_isCompact = true; readSettings(); - auto migrateProviderAspect = [](Utils::StringAspect &aspect) { - const QString migrated = migrateProviderName(aspect.value()); - if (migrated != aspect.value()) - aspect.setValue(migrated); - }; - migrateProviderAspect(ccProvider); - migrateProviderAspect(ccPreset1Provider); - migrateProviderAspect(caProvider); - migrateProviderAspect(qrProvider); - writeSettings(); - Logger::instance().setLoggingEnabled(enableLogging()); setupConnections(); - updatePreset1Visiblity(specifyPreset1.value()); - setLayouter([this]() { using namespace Layouting; - auto ccGrid = Grid{}; - ccGrid.addRow({ccProvider, ccSelectProvider}); - ccGrid.addRow({ccUrl, ccSetUrl}); - ccGrid.addRow({ccCustomEndpoint}); - ccGrid.addRow({ccModel, ccSelectModel}); - ccGrid.addRow({ccTemplate, ccSelectTemplate, ccShowTemplateInfo}); - - auto ccPreset1Grid = Grid{}; - ccPreset1Grid.addRow({ccPreset1Provider, ccPreset1SelectProvider}); - ccPreset1Grid.addRow({ccPreset1Url, ccPreset1SetUrl}); - ccPreset1Grid.addRow({ccPreset1CustomEndpoint}); - ccPreset1Grid.addRow({ccPreset1Model, ccPreset1SelectModel}); - ccPreset1Grid.addRow({ccPreset1Template, ccPreset1SelectTemplate}); - - auto caGrid = Grid{}; - caGrid.addRow({caProvider, caSelectProvider}); - caGrid.addRow({caUrl, caSetUrl}); - caGrid.addRow({caCustomEndpoint}); - caGrid.addRow({caModel, caSelectModel}); - caGrid.addRow({caTemplate, caSelectTemplate, caShowTemplateInfo}); - - auto qrGrid = Grid{}; - qrGrid.addRow({qrProvider, qrSelectProvider}); - qrGrid.addRow({qrUrl, qrSetUrl}); - qrGrid.addRow({qrCustomEndpoint}); - qrGrid.addRow({qrModel, qrSelectModel}); - qrGrid.addRow({qrTemplate, qrSelectTemplate, qrShowTemplateInfo}); - - auto ccGroup = Group{ - title(TrConstants::CODE_COMPLETION), - Column{ - Row{ccSaveConfig, ccLoadConfig, ccOpenConfigFolder, Stretch{1}}, - Row{ccPresetConfig, ccConfigureApiKey, Stretch{1}}, - ccGrid, - Row{specifyPreset1, preset1Language, Stretch{1}}, - ccPreset1Grid}}; - - auto caGroup = Group{ - title(TrConstants::CHAT_ASSISTANT), - Column{ - Row{caSaveConfig, caLoadConfig, caOpenConfigFolder, Stretch{1}}, - Row{caPresetConfig, caConfigureApiKey, Stretch{1}}, - caGrid}}; - - auto qrGroup = Group{ - title(TrConstants::QUICK_REFACTOR), - Column{ - Row{qrSaveConfig, qrLoadConfig, qrOpenConfigFolder, Stretch{1}}, - Row{qrPresetConfig, qrConfigureApiKey, Stretch{1}}, - qrGrid}}; - auto networkGroup = Group{ title(Tr::tr("Network")), Column{Row{requestTimeout, Stretch{1}}}}; @@ -362,7 +280,13 @@ GeneralSettings::GeneralSettings() supportLinks->setOpenExternalLinks(true); supportLinks->setTextFormat(Qt::RichText); - auto rootLayout = Column{ + auto *pipelines = new AgentPipelinesWidget(m_agentFactory, m_agentsNavigator); + m_resetPipelines = [p = QPointer(pipelines)] { + if (p) + p->resetToDefaults(); + }; + + return Column{ Row{supportLabel, supportLinks, Stretch{1}, checkUpdate, resetToDefaults}, Space{8}, Row{enableQodeAssist, Stretch{1}}, @@ -370,223 +294,17 @@ GeneralSettings::GeneralSettings() Row{enableCheckUpdate, Stretch{1}}, Space{8}, networkGroup, - Space{8}, - ccGroup, - Space{8}, - caGroup, - Space{8}, - qrGroup, + Space{12}, + pipelines, Stretch{1}}; - - return rootLayout; }); } -void GeneralSettings::showSelectionDialog( - const QStringList &data, Utils::StringAspect &aspect, const QString &title, const QString &text) +void GeneralSettings::setAgentPipelinesContext( + AgentFactory *agentFactory, AgentsPageNavigator *agentsNavigator) { - if (data.isEmpty()) - return; - - bool ok; - QInputDialog dialog(Core::ICore::dialogParent()); - dialog.setWindowTitle(title); - dialog.setLabelText(text); - dialog.setComboBoxItems(data); - dialog.setComboBoxEditable(false); - dialog.setFixedSize(400, 150); - - if (dialog.exec() == QDialog::Accepted) { - QString result = dialog.textValue(); - if (!result.isEmpty()) { - aspect.setValue(result); - writeSettings(); - } - } -} - -void GeneralSettings::showModelsNotFoundDialog(Utils::StringAspect &aspect) -{ - SettingsDialog dialog(TrConstants::CONNECTION_ERROR); - dialog.addLabel(TrConstants::NO_MODELS_FOUND); - dialog.addLabel(TrConstants::CHECK_CONNECTION); - dialog.addSpacing(); - - ButtonAspect *providerButton = nullptr; - ButtonAspect *urlButton = nullptr; - - if (&aspect == &ccModel) { - providerButton = &ccSelectProvider; - urlButton = &ccSetUrl; - } else if (&aspect == &caModel) { - providerButton = &caSelectProvider; - urlButton = &caSetUrl; - } else if (&aspect == &qrModel) { - providerButton = &qrSelectProvider; - urlButton = &qrSetUrl; - } - - if (providerButton && urlButton) { - auto selectProviderBtn = new QPushButton(TrConstants::SELECT_PROVIDER); - auto selectUrlBtn = new QPushButton(TrConstants::SELECT_URL); - auto enterManuallyBtn = new QPushButton(TrConstants::ENTER_MODEL_MANUALLY); - auto configureApiKeyBtn = new QPushButton(TrConstants::CONFIGURE_API_KEY); - - connect(selectProviderBtn, &QPushButton::clicked, &dialog, [this, providerButton, &dialog]() { - dialog.close(); - emit providerButton->clicked(); - }); - - connect(selectUrlBtn, &QPushButton::clicked, &dialog, [this, urlButton, &dialog]() { - dialog.close(); - emit urlButton->clicked(); - }); - - connect(enterManuallyBtn, &QPushButton::clicked, &dialog, [this, &aspect, &dialog]() { - dialog.close(); - showModelsNotSupportedDialog(aspect); - }); - - connect(configureApiKeyBtn, &QPushButton::clicked, &dialog, [&dialog]() { - dialog.close(); - Settings::showSettings(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID); - }); - - dialog.buttonLayout()->addWidget(selectProviderBtn); - dialog.buttonLayout()->addWidget(selectUrlBtn); - dialog.buttonLayout()->addWidget(enterManuallyBtn); - dialog.buttonLayout()->addWidget(configureApiKeyBtn); - } - - auto closeBtn = new QPushButton(TrConstants::CLOSE); - connect(closeBtn, &QPushButton::clicked, &dialog, &QDialog::close); - dialog.buttonLayout()->addWidget(closeBtn); - - dialog.exec(); -} - -void GeneralSettings::showModelsNotSupportedDialog(Utils::StringAspect &aspect) -{ - SettingsDialog dialog(TrConstants::MODEL_SELECTION); - dialog.addLabel(TrConstants::MODEL_LISTING_NOT_SUPPORTED_INFO); - dialog.addSpacing(); - - QString key = QString("CompleterHistory/") - .append( - (&aspect == &ccModel) ? Constants::CC_MODEL_HISTORY - : (&aspect == &caModel) ? Constants::CA_MODEL_HISTORY - : Constants::QR_MODEL_HISTORY); -#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 0) - QStringList historyList - = Utils::QtcSettings().value(Utils::Key(key.toLocal8Bit())).toStringList(); -#else - QStringList historyList = qtcSettings()->value(Utils::Key(key.toLocal8Bit())).toStringList(); -#endif - - auto modelList = dialog.addComboBox(historyList, aspect.value()); - dialog.addSpacing(); - - auto okButton = new QPushButton(TrConstants::OK); - connect(okButton, &QPushButton::clicked, &dialog, [this, &aspect, modelList, &dialog]() { - QString value = modelList->currentText().trimmed(); - if (!value.isEmpty()) { - aspect.setValue(value); - writeSettings(); - dialog.accept(); - } - }); - - auto cancelButton = new QPushButton(TrConstants::CANCEL); - connect(cancelButton, &QPushButton::clicked, &dialog, &QDialog::reject); - - addDialogButtons(dialog.buttonLayout(), okButton, cancelButton); - - modelList->setFocus(); - dialog.exec(); -} - -void GeneralSettings::showUrlSelectionDialog( - Utils::StringAspect &aspect, const QStringList &predefinedUrls) -{ - SettingsDialog dialog(TrConstants::URL_SELECTION); - dialog.addLabel(TrConstants::URL_SELECTION_INFO); - dialog.addSpacing(); - - QStringList allUrls = predefinedUrls; - QString key = QString("CompleterHistory/") - .append( - (&aspect == &ccUrl) ? Constants::CC_URL_HISTORY - : (&aspect == &ccPreset1Url) ? Constants::CC_PRESET1_URL_HISTORY - : (&aspect == &caUrl) ? Constants::CA_URL_HISTORY - : Constants::QR_URL_HISTORY); -#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 0) - QStringList historyList - = Utils::QtcSettings().value(Utils::Key(key.toLocal8Bit())).toStringList(); -#else - QStringList historyList = qtcSettings()->value(Utils::Key(key.toLocal8Bit())).toStringList(); -#endif - allUrls.append(historyList); - allUrls.removeDuplicates(); - - auto urlList = dialog.addComboBox(allUrls, aspect.value()); - dialog.addSpacing(); - - auto okButton = new QPushButton(TrConstants::OK); - connect(okButton, &QPushButton::clicked, &dialog, [this, &aspect, urlList, &dialog]() { - QString value = urlList->currentText().trimmed(); - if (!value.isEmpty()) { - aspect.setValue(value); - writeSettings(); - dialog.accept(); - } - }); - - auto cancelButton = new QPushButton(TrConstants::CANCEL); - connect(cancelButton, &QPushButton::clicked, &dialog, &QDialog::reject); - - addDialogButtons(dialog.buttonLayout(), okButton, cancelButton); - - urlList->setFocus(); - dialog.exec(); -} - -void GeneralSettings::showTemplateInfoDialog( - const Utils::StringAspect &descriptionAspect, const QString &templateName) -{ - SettingsDialog dialog(Tr::tr("Template Information")); - dialog.addLabel(QString("%1: %2").arg(Tr::tr("Template"), templateName)); - dialog.addSpacing(); - - auto *descriptionLabel = new QLabel(Tr::tr("Description:")); - dialog.layout()->addWidget(descriptionLabel); - - auto *textEdit = new QTextEdit(); - textEdit->setReadOnly(true); - textEdit->setMinimumHeight(200); - textEdit->setMinimumWidth(500); - textEdit->setText(descriptionAspect.value()); - dialog.layout()->addWidget(textEdit); - - dialog.addSpacing(); - - auto *closeButton = new QPushButton(TrConstants::CLOSE); - connect(closeButton, &QPushButton::clicked, &dialog, &QDialog::accept); - dialog.buttonLayout()->addWidget(closeButton); - - dialog.exec(); -} - -void GeneralSettings::updatePreset1Visiblity(bool state) -{ - ccPreset1Provider.setVisible(specifyPreset1.volatileValue()); - ccPreset1SelectProvider.updateVisibility(specifyPreset1.volatileValue()); - ccPreset1Url.setVisible(specifyPreset1.volatileValue()); - ccPreset1SetUrl.updateVisibility(specifyPreset1.volatileValue()); - ccPreset1Model.setVisible(specifyPreset1.volatileValue()); - ccPreset1SelectModel.updateVisibility(specifyPreset1.volatileValue()); - ccPreset1Template.setVisible(specifyPreset1.volatileValue()); - ccPreset1SelectTemplate.updateVisibility(specifyPreset1.volatileValue()); - ccPreset1CustomEndpoint.setVisible(specifyPreset1.volatileValue()); + m_agentFactory = agentFactory; + m_agentsNavigator = agentsNavigator; } void GeneralSettings::setupConnections() @@ -598,401 +316,27 @@ void GeneralSettings::setupConnections() connect(&checkUpdate, &ButtonAspect::clicked, this, [this]() { QodeAssist::UpdateDialog::checkForUpdatesAndShow(Core::ICore::dialogParent()); }); - - connect(&ccPresetConfig, &Utils::SelectionAspect::volatileValueChanged, this, [this]() { - applyPresetConfiguration(ccPresetConfig.volatileValue(), ConfigurationType::CodeCompletion); - ccPresetConfig.setValue(0); - }); - - connect(&ccConfigureApiKey, &ButtonAspect::clicked, this, []() { - Settings::showSettings(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID); - }); - - connect(&caPresetConfig, &Utils::SelectionAspect::volatileValueChanged, this, [this]() { - applyPresetConfiguration(caPresetConfig.volatileValue(), ConfigurationType::Chat); - caPresetConfig.setValue(0); - }); - - connect(&caConfigureApiKey, &ButtonAspect::clicked, this, []() { - Settings::showSettings(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID); - }); - - connect(&qrPresetConfig, &Utils::SelectionAspect::volatileValueChanged, this, [this]() { - applyPresetConfiguration(qrPresetConfig.volatileValue(), ConfigurationType::QuickRefactor); - qrPresetConfig.setValue(0); - }); - - connect(&qrConfigureApiKey, &ButtonAspect::clicked, this, []() { - Settings::showSettings(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID); - }); - - connect(&specifyPreset1, &Utils::BoolAspect::volatileValueChanged, this, [this]() { - updatePreset1Visiblity(specifyPreset1.volatileValue()); - }); - connect(&ccShowTemplateInfo, &ButtonAspect::clicked, this, [this]() { - showTemplateInfoDialog(ccTemplateDescription, ccTemplate.value()); - }); - - connect(&caShowTemplateInfo, &ButtonAspect::clicked, this, [this]() { - showTemplateInfoDialog(caTemplateDescription, caTemplate.value()); - }); - - connect(&qrShowTemplateInfo, &ButtonAspect::clicked, this, [this]() { - showTemplateInfoDialog(qrTemplateDescription, qrTemplate.value()); - }); - - connect(&ccSaveConfig, &ButtonAspect::clicked, this, [this]() { onSaveConfiguration("cc"); }); - connect(&ccLoadConfig, &ButtonAspect::clicked, this, [this]() { onLoadConfiguration("cc"); }); - - connect(&caSaveConfig, &ButtonAspect::clicked, this, [this]() { onSaveConfiguration("ca"); }); - connect(&caLoadConfig, &ButtonAspect::clicked, this, [this]() { onLoadConfiguration("ca"); }); - - connect(&qrSaveConfig, &ButtonAspect::clicked, this, [this]() { onSaveConfiguration("qr"); }); - connect(&qrLoadConfig, &ButtonAspect::clicked, this, [this]() { onLoadConfiguration("qr"); }); - - connect(&ccOpenConfigFolder, &ButtonAspect::clicked, this, [this]() { - auto &manager = ConfigurationManager::instance(); - QString path = manager.getConfigurationDirectory(ConfigurationType::CodeCompletion); - QDir dir(path); - if (!dir.exists()) { - dir.mkpath("."); - } - QUrl url = QUrl::fromLocalFile(dir.absolutePath()); - QDesktopServices::openUrl(url); - }); - - connect(&caOpenConfigFolder, &ButtonAspect::clicked, this, [this]() { - auto &manager = ConfigurationManager::instance(); - QString path = manager.getConfigurationDirectory(ConfigurationType::Chat); - QDir dir(path); - if (!dir.exists()) { - dir.mkpath("."); - } - QUrl url = QUrl::fromLocalFile(dir.absolutePath()); - QDesktopServices::openUrl(url); - }); - - connect(&qrOpenConfigFolder, &ButtonAspect::clicked, this, [this]() { - auto &manager = ConfigurationManager::instance(); - QString path = manager.getConfigurationDirectory(ConfigurationType::QuickRefactor); - QDir dir(path); - if (!dir.exists()) { - dir.mkpath("."); - } - QUrl url = QUrl::fromLocalFile(dir.absolutePath()); - QDesktopServices::openUrl(url); - }); } void GeneralSettings::resetPageToDefaults() { - QMessageBox::StandardButton reply; - reply = QMessageBox::question( + const QMessageBox::StandardButton reply = QMessageBox::question( Core::ICore::dialogParent(), TrConstants::RESET_SETTINGS, TrConstants::CONFIRMATION, QMessageBox::Yes | QMessageBox::No); - if (reply == QMessageBox::Yes) { - resetAspect(enableQodeAssist); - resetAspect(enableLogging); - resetAspect(requestTimeout); - resetAspect(ccProvider); - resetAspect(ccModel); - resetAspect(ccTemplate); - resetAspect(ccUrl); - resetAspect(caProvider); - resetAspect(caModel); - resetAspect(caTemplate); - resetAspect(caUrl); - resetAspect(enableCheckUpdate); - resetAspect(specifyPreset1); - resetAspect(preset1Language); - resetAspect(ccPreset1Provider); - resetAspect(ccPreset1Model); - resetAspect(ccPreset1Template); - resetAspect(ccPreset1Url); - resetAspect(ccCustomEndpoint); - resetAspect(ccPreset1CustomEndpoint); - resetAspect(caCustomEndpoint); - resetAspect(qrProvider); - resetAspect(qrModel); - resetAspect(qrTemplate); - resetAspect(qrUrl); - resetAspect(qrCustomEndpoint); - writeSettings(); - } -} - -void GeneralSettings::onSaveConfiguration(const QString &prefix) -{ - bool ok; - QString configName = QInputDialog::getText( - Core::ICore::dialogParent(), - TrConstants::SAVE_CONFIGURATION, - TrConstants::CONFIGURATION_NAME, - QLineEdit::Normal, - QString(), - &ok); - - if (!ok || configName.trimmed().isEmpty()) { + if (reply != QMessageBox::Yes) return; - } - AIConfiguration config; - config.id = QUuid::createUuid().toString(QUuid::WithoutBraces); - config.name = configName.trimmed(); - - if (prefix == "cc") { - config.provider = ccProvider.value(); - config.model = ccModel.value(); - config.templateName = ccTemplate.value(); - config.url = ccUrl.value(); - config.customEndpoint = ccCustomEndpoint.value(); - config.type = ConfigurationType::CodeCompletion; - } else if (prefix == "ca") { - config.provider = caProvider.value(); - config.model = caModel.value(); - config.templateName = caTemplate.value(); - config.url = caUrl.value(); - config.customEndpoint = caCustomEndpoint.value(); - config.type = ConfigurationType::Chat; - } else if (prefix == "qr") { - config.provider = qrProvider.value(); - config.model = qrModel.value(); - config.templateName = qrTemplate.value(); - config.url = qrUrl.value(); - config.customEndpoint = qrCustomEndpoint.value(); - config.type = ConfigurationType::QuickRefactor; - } - - auto &manager = ConfigurationManager::instance(); - if (manager.saveConfiguration(config)) { - QMessageBox::information( - Core::ICore::dialogParent(), - TrConstants::SAVE_CONFIGURATION, - TrConstants::CONFIGURATION_SAVED); - } else { - QMessageBox::warning( - Core::ICore::dialogParent(), - TrConstants::SAVE_CONFIGURATION, - Tr::tr("Failed to save configuration. Check logs for details.")); - } -} - -void GeneralSettings::onLoadConfiguration(const QString &prefix) -{ - ConfigurationType type; - if (prefix == "cc") { - type = ConfigurationType::CodeCompletion; - } else if (prefix == "ca") { - type = ConfigurationType::Chat; - } else if (prefix == "qr") { - type = ConfigurationType::QuickRefactor; - } else { - return; - } - - auto &manager = ConfigurationManager::instance(); - manager.loadConfigurations(type); - - QVector configs = manager.configurations(type); - if (configs.isEmpty()) { - QMessageBox::information( - Core::ICore::dialogParent(), - TrConstants::LOAD_CONFIGURATION, - TrConstants::NO_CONFIGURATIONS_FOUND); - return; - } - - SettingsDialog dialog(TrConstants::LOAD_CONFIGURATION); - dialog.addLabel(TrConstants::SELECT_CONFIGURATION); - - int predefinedCount = 0; - for (const AIConfiguration &config : configs) { - if (config.isPredefined) { - predefinedCount++; - } - } - - if (predefinedCount > 0) { - auto *hintLabel = dialog.addLabel( - Tr::tr("[Preset] configurations are predefined cloud models ready to use.")); - QFont hintFont = hintLabel->font(); - hintFont.setItalic(true); - hintFont.setPointSize(hintFont.pointSize() - 1); - hintLabel->setFont(hintFont); - hintLabel->setStyleSheet("color: gray;"); - } - - dialog.addSpacing(); - - QStringList configNames; - for (const AIConfiguration &config : configs) { - QString displayName = config.name; - if (config.isPredefined) { - displayName = QString("[Preset] %1").arg(config.name); - } - configNames.append(displayName); - } - - auto configList = dialog.addComboBox(configNames, QString()); - dialog.addSpacing(); - - auto *deleteButton = new QPushButton(TrConstants::DELETE_CONFIGURATION); - auto *okButton = new QPushButton(TrConstants::OK); - auto *cancelButton = new QPushButton(TrConstants::CANCEL); - - auto updateDeleteButtonState = [&]() { - int currentIndex = configList->currentIndex(); - if (currentIndex >= 0 && currentIndex < configs.size()) { - deleteButton->setEnabled(!configs[currentIndex].isPredefined); - } - }; - - connect(configList, - QOverload::of(&QComboBox::currentIndexChanged), - updateDeleteButtonState); - - updateDeleteButtonState(); - - connect(deleteButton, &QPushButton::clicked, &dialog, [&]() { - int currentIndex = configList->currentIndex(); - if (currentIndex >= 0 && currentIndex < configs.size()) { - const AIConfiguration &configToDelete = configs[currentIndex]; - if (configToDelete.isPredefined) { - QMessageBox::information( - &dialog, - TrConstants::DELETE_CONFIGURATION, - Tr::tr("Predefined configurations cannot be deleted.")); - return; - } - - QMessageBox::StandardButton reply = QMessageBox::question( - &dialog, - TrConstants::DELETE_CONFIGURATION, - TrConstants::CONFIRM_DELETE_CONFIG, - QMessageBox::Yes | QMessageBox::No); - - if (reply == QMessageBox::Yes) { - if (manager.deleteConfiguration(configToDelete.id, type)) { - dialog.accept(); - onLoadConfiguration(prefix); - } else { - QMessageBox::warning( - &dialog, - TrConstants::DELETE_CONFIGURATION, - Tr::tr("Failed to delete configuration.")); - } - } - } - }); - - connect(okButton, &QPushButton::clicked, &dialog, [&]() { - int currentIndex = configList->currentIndex(); - if (currentIndex >= 0 && currentIndex < configs.size()) { - const AIConfiguration &config = configs[currentIndex]; - - if (prefix == "cc") { - ccProvider.setValue(config.provider); - ccModel.setValue(config.model); - ccTemplate.setValue(config.templateName); - ccUrl.setValue(config.url); - ccCustomEndpoint.setValue(config.customEndpoint); - } else if (prefix == "ca") { - caProvider.setValue(config.provider); - caModel.setValue(config.model); - caTemplate.setValue(config.templateName); - caUrl.setValue(config.url); - caCustomEndpoint.setValue(config.customEndpoint); - } else if (prefix == "qr") { - qrProvider.setValue(config.provider); - qrModel.setValue(config.model); - qrTemplate.setValue(config.templateName); - qrUrl.setValue(config.url); - qrCustomEndpoint.setValue(config.customEndpoint); - } - - writeSettings(); - QMessageBox::information( - Core::ICore::dialogParent(), - TrConstants::LOAD_CONFIGURATION, - TrConstants::CONFIGURATION_LOADED); - dialog.accept(); - } - }); - - connect(cancelButton, &QPushButton::clicked, &dialog, &QDialog::reject); - - dialog.buttonLayout()->addWidget(deleteButton); - addDialogButtons(dialog.buttonLayout(), okButton, cancelButton); - - configList->setFocus(); - dialog.exec(); -} - -void GeneralSettings::loadPresetConfigurations(Utils::SelectionAspect &aspect, - ConfigurationType type) -{ - QVector presets = ConfigurationManager::getPredefinedConfigurations(type); - - if (type == ConfigurationType::CodeCompletion) { - m_ccPresets = presets; - } else if (type == ConfigurationType::Chat) { - m_caPresets = presets; - } else if (type == ConfigurationType::QuickRefactor) { - m_qrPresets = presets; - } - - aspect.addOption(Tr::tr("-- Select Preset --")); - for (const AIConfiguration &config : presets) { - aspect.addOption(config.name); - } - aspect.setDefaultValue(0); -} - -void GeneralSettings::applyPresetConfiguration(int index, ConfigurationType type) -{ - if (index <= 0) { - return; - } - - QVector *presets = nullptr; - if (type == ConfigurationType::CodeCompletion) { - presets = &m_ccPresets; - } else if (type == ConfigurationType::Chat) { - presets = &m_caPresets; - } else if (type == ConfigurationType::QuickRefactor) { - presets = &m_qrPresets; - } - - if (!presets || index - 1 >= presets->size()) { - return; - } - - const AIConfiguration &config = presets->at(index - 1); - - if (type == ConfigurationType::CodeCompletion) { - ccProvider.setValue(config.provider); - ccModel.setValue(config.model); - ccTemplate.setValue(config.templateName); - ccUrl.setValue(config.url); - ccCustomEndpoint.setValue(config.customEndpoint); - } else if (type == ConfigurationType::Chat) { - caProvider.setValue(config.provider); - caModel.setValue(config.model); - caTemplate.setValue(config.templateName); - caUrl.setValue(config.url); - caCustomEndpoint.setValue(config.customEndpoint); - } else if (type == ConfigurationType::QuickRefactor) { - qrProvider.setValue(config.provider); - qrModel.setValue(config.model); - qrTemplate.setValue(config.templateName); - qrUrl.setValue(config.url); - qrCustomEndpoint.setValue(config.customEndpoint); - } - + resetAspect(enableQodeAssist); + resetAspect(enableLogging); + resetAspect(requestTimeout); + resetAspect(enableCheckUpdate); writeSettings(); + + if (m_resetPipelines) + m_resetPipelines(); } class GeneralSettingsPage : public Core::IOptionsPage @@ -1012,6 +356,7 @@ public: }; const GeneralSettingsPage generalSettingsPage; + /*! \sa {Core::ICore::showOptionsDialog()}, {Core::ICore::showSettings()} \note This function was added to fix Qt Creator API broken changes in v19.0.0-beta2 version @@ -1024,6 +369,7 @@ void showSettings(const Utils::Id page) Core::ICore::showOptionsDialog(page); #endif } + /*! \sa {Core::ICore::showOptionsDialog()}, {Core::ICore::showSettings()} \note This function was added to fix Qt Creator API broken changes in v19.0.0-beta2 version @@ -1038,3 +384,5 @@ void showSettings(const Utils::Id page, Utils::Id item) } } // namespace QodeAssist::Settings + +#include "GeneralSettings.moc" diff --git a/settings/GeneralSettings.hpp b/settings/GeneralSettings.hpp index abc1cd0..4a4316c 100644 --- a/settings/GeneralSettings.hpp +++ b/settings/GeneralSettings.hpp @@ -4,26 +4,30 @@ #pragma once -#include +#include + #include +#include + #include "ButtonAspect.hpp" -#include "ConfigurationManager.hpp" -namespace Utils { -class DetailsWidget; +namespace QodeAssist { +class AgentFactory; } -namespace QodeAssist::PluginLLMCore { -class Provider; -} namespace QodeAssist::Settings { +class AgentsPageNavigator; + class GeneralSettings : public Utils::AspectContainer { public: GeneralSettings(); + void setAgentPipelinesContext( + AgentFactory *agentFactory, AgentsPageNavigator *agentsNavigator); + Utils::BoolAspect enableQodeAssist{this}; Utils::BoolAspect enableLogging{this}; Utils::BoolAspect enableCheckUpdate{this}; @@ -33,139 +37,13 @@ public: ButtonAspect checkUpdate{this}; ButtonAspect resetToDefaults{this}; - // code completion setttings - Utils::SelectionAspect ccPresetConfig{this}; - ButtonAspect ccConfigureApiKey{this}; - - Utils::StringAspect ccProvider{this}; - ButtonAspect ccSelectProvider{this}; - - Utils::StringAspect ccModel{this}; - ButtonAspect ccSelectModel{this}; - - Utils::StringAspect ccTemplate{this}; - ButtonAspect ccSelectTemplate{this}; - - Utils::StringAspect ccUrl{this}; - ButtonAspect ccSetUrl{this}; - - Utils::StringAspect ccCustomEndpoint{this}; - - Utils::StringAspect ccStatus{this}; - ButtonAspect ccTest{this}; - - Utils::StringAspect ccTemplateDescription{this}; - - ButtonAspect ccSaveConfig{this}; - ButtonAspect ccLoadConfig{this}; - ButtonAspect ccOpenConfigFolder{this}; - - // TODO create dynamic presets system - // preset1 for code completion settings - Utils::BoolAspect specifyPreset1{this}; - Utils::SelectionAspect preset1Language{this}; - - Utils::StringAspect ccPreset1Provider{this}; - ButtonAspect ccPreset1SelectProvider{this}; - - Utils::StringAspect ccPreset1Url{this}; - ButtonAspect ccPreset1SetUrl{this}; - - Utils::StringAspect ccPreset1CustomEndpoint{this}; - - Utils::StringAspect ccPreset1Model{this}; - ButtonAspect ccPreset1SelectModel{this}; - - Utils::StringAspect ccPreset1Template{this}; - ButtonAspect ccPreset1SelectTemplate{this}; - - // chat assistant settings - Utils::SelectionAspect caPresetConfig{this}; - ButtonAspect caConfigureApiKey{this}; - - Utils::StringAspect caProvider{this}; - ButtonAspect caSelectProvider{this}; - - Utils::StringAspect caModel{this}; - ButtonAspect caSelectModel{this}; - - Utils::StringAspect caTemplate{this}; - ButtonAspect caSelectTemplate{this}; - - Utils::StringAspect caUrl{this}; - ButtonAspect caSetUrl{this}; - - Utils::StringAspect caCustomEndpoint{this}; - - Utils::StringAspect caStatus{this}; - ButtonAspect caTest{this}; - - Utils::StringAspect caTemplateDescription{this}; - - ButtonAspect caSaveConfig{this}; - ButtonAspect caLoadConfig{this}; - ButtonAspect caOpenConfigFolder{this}; - - // quick refactor settings - Utils::SelectionAspect qrPresetConfig{this}; - ButtonAspect qrConfigureApiKey{this}; - - Utils::StringAspect qrProvider{this}; - ButtonAspect qrSelectProvider{this}; - - Utils::StringAspect qrModel{this}; - ButtonAspect qrSelectModel{this}; - - Utils::StringAspect qrTemplate{this}; - ButtonAspect qrSelectTemplate{this}; - - Utils::StringAspect qrUrl{this}; - ButtonAspect qrSetUrl{this}; - - Utils::StringAspect qrCustomEndpoint{this}; - - Utils::StringAspect qrStatus{this}; - ButtonAspect qrTest{this}; - - Utils::StringAspect qrTemplateDescription{this}; - - ButtonAspect qrSaveConfig{this}; - ButtonAspect qrLoadConfig{this}; - ButtonAspect qrOpenConfigFolder{this}; - - ButtonAspect ccShowTemplateInfo{this}; - ButtonAspect caShowTemplateInfo{this}; - ButtonAspect qrShowTemplateInfo{this}; - - void showSelectionDialog( - const QStringList &data, - Utils::StringAspect &aspect, - const QString &title = {}, - const QString &text = {}); - - void showModelsNotFoundDialog(Utils::StringAspect &aspect); - - void showModelsNotSupportedDialog(Utils::StringAspect &aspect); - - void showUrlSelectionDialog(Utils::StringAspect &aspect, const QStringList &predefinedUrls); - - void showTemplateInfoDialog(const Utils::StringAspect &descriptionAspect, const QString &templateName); - - void updatePreset1Visiblity(bool state); - - void onSaveConfiguration(const QString &prefix); - void onLoadConfiguration(const QString &prefix); - - void loadPresetConfigurations(Utils::SelectionAspect &aspect, ConfigurationType type); - void applyPresetConfiguration(int index, ConfigurationType type); - private: void setupConnections(); void resetPageToDefaults(); - - QVector m_ccPresets; - QVector m_caPresets; - QVector m_qrPresets; + + QPointer m_agentFactory; + QPointer m_agentsNavigator; + std::function m_resetPipelines; }; GeneralSettings &generalSettings(); diff --git a/settings/PluginUpdater.cpp b/settings/PluginUpdater.cpp index b8a76c7..a96b292 100644 --- a/settings/PluginUpdater.cpp +++ b/settings/PluginUpdater.cpp @@ -71,11 +71,6 @@ QString PluginUpdater::currentVersion() const return QString(); } -bool PluginUpdater::isUpdateAvailable() const -{ - return m_lastUpdateInfo.isUpdateAvailable; -} - QString PluginUpdater::getUpdateUrl() const { return "https://api.github.com/repos/Palm1r/qodeassist/releases/latest"; diff --git a/settings/PluginUpdater.hpp b/settings/PluginUpdater.hpp index 9a6c004..00d6635 100644 --- a/settings/PluginUpdater.hpp +++ b/settings/PluginUpdater.hpp @@ -28,7 +28,6 @@ public: void checkForUpdates(); QString currentVersion() const; - bool isUpdateAvailable() const; signals: void updateCheckFinished(const UpdateInfo &info); diff --git a/settings/ProviderDetailPane.cpp b/settings/ProviderDetailPane.cpp index 4d49756..dec69bd 100644 --- a/settings/ProviderDetailPane.cpp +++ b/settings/ProviderDetailPane.cpp @@ -17,8 +17,10 @@ #include #include +#include #include "ProviderInstanceWriter.hpp" +#include "ProviderSettings.hpp" #include "SectionBox.hpp" #include "SettingsTheme.hpp" #include "SettingsUiBuilders.hpp" @@ -37,7 +39,7 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) m_sourcePathLabel = new QLabel(this); m_sourcePathLabel->setFont(monospaceFont(11)); QPalette spp = m_sourcePathLabel->palette(); - spp.setColor(QPalette::WindowText, spp.color(QPalette::Mid)); + spp.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); m_sourcePathLabel->setPalette(spp); m_editBtn = new QPushButton(tr("Edit…"), this); @@ -157,6 +159,14 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) m_apiKeySaveBtn->setEnabled(false); m_apiKeyClearBtn = new QPushButton(tr("Clear"), this); m_apiKeyClearBtn->setToolTip(tr("Erase the stored API key for this provider")); + m_legacyKeyBtn = new QPushButton(tr("Insert legacy key"), this); + m_legacyKeyBtn->setVisible(false); + connect(m_legacyKeyBtn, &QPushButton::clicked, this, [this] { + if (m_legacyKeyValue.isEmpty()) + return; + m_apiKeyEdit->setText(m_legacyKeyValue); + m_revealKeyBtn->setChecked(true); + }); connect(m_apiKeyEdit, &QLineEdit::textChanged, this, [this](const QString &t) { m_apiKeySaveBtn->setEnabled(!t.isEmpty()); }); @@ -189,14 +199,15 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) credGrid->setVerticalSpacing(4); FormBuilder credForm(credGrid); credForm.row(tr("API key:"), keyRow); - credGrid->addWidget(m_keyHint, credForm.currentRow(), 1); + credGrid->addWidget(m_legacyKeyBtn, credForm.currentRow(), 1, Qt::AlignLeft); + credGrid->addWidget(m_keyHint, credForm.currentRow() + 1, 1); credSection->bodyLayout()->addLayout(credGrid); m_launchSection = new SectionBox(tr("Launch"), this); m_launchEmptyHint = new QLabel(this); m_launchEmptyHint->setWordWrap(true); QPalette lehp = m_launchEmptyHint->palette(); - lehp.setColor(QPalette::WindowText, lehp.color(QPalette::Mid)); + lehp.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); m_launchEmptyHint->setPalette(lehp); m_launchCmdLabel = new QLabel(this); m_launchCmdLabel->setFont(monospaceFont(11)); @@ -318,6 +329,18 @@ void ProviderDetailPane::populate(const Providers::ProviderInstance &inst, bool m_keyHint->setText(tr("No key stored yet. Type a key and press Save key.")); } + const LegacyApiKeyEntry legacy + = needsKey ? legacyApiKeyForClientApi(inst.clientApi) : LegacyApiKeyEntry{}; + m_legacyKeyValue = legacy.value; + if (!legacy.value.isEmpty()) { + m_legacyKeyBtn->setToolTip( + tr("Insert the API key saved in the old %1 settings into the field.") + .arg(legacy.label)); + m_legacyKeyBtn->setVisible(true); + } else { + m_legacyKeyBtn->setVisible(false); + } + m_samplePreview->setText( QStringLiteral("# sample request line\nPOST %1/").arg(inst.url)); applyPreviewPalette(); @@ -348,6 +371,8 @@ void ProviderDetailPane::clear() m_apiKeySaveBtn->setEnabled(false); m_apiKeyClearBtn->setEnabled(false); m_revealKeyBtn->setEnabled(false); + m_legacyKeyValue.clear(); + m_legacyKeyBtn->setVisible(false); m_samplePreview->clear(); m_rawToml->clear(); m_editBtn->setVisible(false); @@ -398,7 +423,9 @@ void ProviderDetailPane::setLaunchState( } const QString detachedNote = m_current.launch.detach - ? tr(" (detached — survives Qt Creator restart)") + ? QStringLiteral(" %2") + .arg(Utils::creatorColor(Utils::Theme::PanelTextColorMid).name(), + tr("(detached — survives Qt Creator restart)")) : QString(); m_launchCmdLabel->setText( QStringLiteral("%1 %2%3") @@ -473,10 +500,10 @@ Providers::ProviderInstance ProviderDetailPane::collectEdits() const void ProviderDetailPane::applyPreviewPalette() { - const Theme theme = themeFor(palette()); - m_samplePreview->setStyleSheet(QStringLiteral( - "QLabel { background:%1; border:1px solid %2; }") - .arg(theme.codeBg, theme.rowSeparator)); + m_samplePreview->setStyleSheet( + QStringLiteral("QLabel { background:%1; border:1px solid %2; }") + .arg(cssColor(Utils::creatorColor(Utils::Theme::BackgroundColorNormal)), + cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)))); } void ProviderDetailPane::applyTerminalPalette() diff --git a/settings/ProviderDetailPane.hpp b/settings/ProviderDetailPane.hpp index 97d30c1..2526150 100644 --- a/settings/ProviderDetailPane.hpp +++ b/settings/ProviderDetailPane.hpp @@ -84,6 +84,8 @@ private: QLabel *m_keyHint = nullptr; QPushButton *m_apiKeySaveBtn = nullptr; QPushButton *m_apiKeyClearBtn = nullptr; + QPushButton *m_legacyKeyBtn = nullptr; + QString m_legacyKeyValue; SectionBox *m_launchSection = nullptr; QLabel *m_launchEmptyHint = nullptr; diff --git a/settings/ProviderListItem.cpp b/settings/ProviderListItem.cpp index 26fcb89..4feb7d8 100644 --- a/settings/ProviderListItem.cpp +++ b/settings/ProviderListItem.cpp @@ -4,6 +4,8 @@ #include "ProviderListItem.hpp" +#include + #include #include #include @@ -44,12 +46,12 @@ ProviderListItem::ProviderListItem( m_urlLabel = new QLabel(inst.url, this); m_urlLabel->setFont(monospaceFont(10)); QPalette up = m_urlLabel->palette(); - up.setColor(QPalette::WindowText, up.color(QPalette::Mid)); + up.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); m_urlLabel->setPalette(up); m_urlLabel->setContentsMargins(17, 0, 0, 0); auto *outer = new QVBoxLayout(this); - outer->setContentsMargins(8, 6, 8, 6); + outer->setContentsMargins(5, 6, 8, 6); outer->setSpacing(2); outer->addLayout(headerRow); outer->addWidget(m_urlLabel); @@ -88,11 +90,14 @@ void ProviderListItem::changeEvent(QEvent *event) QString ProviderListItem::statusColor(Status s) { switch (s) { - case Status::Ok: return QStringLiteral("#3a8a4f"); - case Status::Fail: return QStringLiteral("#c94a4a"); - case Status::Unknown: return QStringLiteral("#888888"); + case Status::Ok: + return cssColor(Utils::creatorColor(Utils::Theme::IconsRunColor)); + case Status::Fail: + return cssColor(Utils::creatorColor(Utils::Theme::TextColorError)); + case Status::Unknown: + return cssColor(Utils::creatorColor(Utils::Theme::PanelTextColorMid)); } - return QStringLiteral("#888888"); + return cssColor(Utils::creatorColor(Utils::Theme::PanelTextColorMid)); } void ProviderListItem::applyTheme() @@ -100,11 +105,13 @@ void ProviderListItem::applyTheme() if (m_inApplyTheme) return; QScopedValueRollback guard(m_inApplyTheme, true); - const Theme theme = themeFor(palette()); + const QString accent = m_selected + ? cssColor(Utils::creatorColor(Utils::Theme::TextColorLink)) + : QStringLiteral("transparent"); setStyleSheet(QStringLiteral( - "#ProvListItem { background:%1; border-top: 1px solid %2; }") - .arg(m_selected ? theme.rowSelectedBg : QStringLiteral("transparent"), - theme.rowSeparator)); + "#ProvListItem { background:transparent;" + " border-top:1px solid %1; border-left:3px solid %2; }") + .arg(cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)), accent)); } } // namespace QodeAssist::Settings diff --git a/settings/ProviderSettings.cpp b/settings/ProviderSettings.cpp index 069ce3f..651b744 100644 --- a/settings/ProviderSettings.cpp +++ b/settings/ProviderSettings.cpp @@ -21,6 +21,46 @@ ProviderSettings &providerSettings() return settings; } +LegacyApiKeyEntry legacyApiKeyForClientApi(const QString &clientApi) +{ + ProviderSettings &s = providerSettings(); + QString label; + QString value; + + if (clientApi == "Claude") { + label = QStringLiteral("Claude"); + value = s.claudeApiKey(); + } else if (clientApi == "OpenRouter") { + label = QStringLiteral("OpenRouter"); + value = s.openRouterApiKey(); + } else if (clientApi == "OpenAI Compatible") { + label = QStringLiteral("OpenAI Compatible"); + value = s.openAiCompatApiKey(); + } else if (clientApi == "OpenAI (Chat Completions)" || clientApi == "OpenAI (Responses API)") { + label = QStringLiteral("OpenAI"); + value = s.openAiApiKey(); + } else if (clientApi == "Mistral AI") { + label = QStringLiteral("Mistral AI"); + value = s.mistralAiApiKey(); + } else if (clientApi == "Codestral") { + label = QStringLiteral("Codestral"); + value = s.codestralApiKey(); + } else if (clientApi == "Google AI") { + label = QStringLiteral("Google AI"); + value = s.googleAiApiKey(); + } else if (clientApi == "Ollama (Native)" || clientApi == "Ollama (OpenAI-compatible)") { + label = QStringLiteral("Ollama (Bearer)"); + value = s.ollamaBasicAuthApiKey(); + } else if (clientApi == "llama.cpp") { + label = QStringLiteral("llama.cpp"); + value = s.llamaCppApiKey(); + } + + if (value.isEmpty()) + return {}; + return {label, value}; +} + ProviderSettings::ProviderSettings() { setAutoApply(false); @@ -256,8 +296,4 @@ public: } }; -#ifndef QODEASSIST_EXPERIMENTAL -const ProviderSettingsPage providerSettingsPage; -#endif - } // namespace QodeAssist::Settings diff --git a/settings/ProviderSettings.hpp b/settings/ProviderSettings.hpp index c50e4be..8bf68da 100644 --- a/settings/ProviderSettings.hpp +++ b/settings/ProviderSettings.hpp @@ -39,4 +39,12 @@ private: ProviderSettings &providerSettings(); +struct LegacyApiKeyEntry +{ + QString label; + QString value; +}; + +LegacyApiKeyEntry legacyApiKeyForClientApi(const QString &clientApi); + } // namespace QodeAssist::Settings diff --git a/settings/ProvidersSettingsPage.cpp b/settings/ProvidersSettingsPage.cpp index e973244..c5950e9 100644 --- a/settings/ProvidersSettingsPage.cpp +++ b/settings/ProvidersSettingsPage.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -37,7 +38,7 @@ #include "ProviderListItem.hpp" #include "ProviderSecretsStore.hpp" #include "SettingsConstants.hpp" -#include "SettingsTheme.hpp" +#include "SettingsUiBuilders.hpp" namespace QodeAssist::Settings { @@ -234,19 +235,7 @@ private slots: auto addSection = [&](const QString &title, bool userSection) { - auto *header = new QLabel(title.toUpper(), m_listContent); - QFont hf = header->font(); - hf.setPixelSize(10); - hf.setLetterSpacing(QFont::AbsoluteSpacing, 0.5); - header->setFont(hf); - QPalette hp = header->palette(); - hp.setColor(QPalette::WindowText, hp.color(QPalette::Mid)); - header->setPalette(hp); - header->setContentsMargins(8, 4, 8, 4); - header->setAutoFillBackground(true); - header->setStyleSheet( - QStringLiteral("QLabel { background:%1; }") - .arg(themeFor(palette()).listHeaderBg)); + auto *header = makeSectionHeader(title, m_listContent); m_listLayout->insertWidget(m_listLayout->count() - 1, header); std::vector sorted; @@ -281,7 +270,7 @@ private slots: m_listContent); empty->setContentsMargins(10, 6, 10, 6); QPalette ep = empty->palette(); - ep.setColor(QPalette::WindowText, ep.color(QPalette::Mid)); + ep.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); empty->setPalette(ep); m_listLayout->insertWidget(m_listLayout->count() - 1, empty); } diff --git a/settings/QuickRefactorSettings.cpp b/settings/QuickRefactorSettings.cpp index aa75f08..53f6c91 100644 --- a/settings/QuickRefactorSettings.cpp +++ b/settings/QuickRefactorSettings.cpp @@ -27,122 +27,6 @@ QuickRefactorSettings::QuickRefactorSettings() setDisplayName(Tr::tr("Quick Refactor")); - // General Parameters Settings - temperature.setSettingsKey(Constants::QR_TEMPERATURE); - temperature.setLabelText(Tr::tr("Temperature:")); - temperature.setDefaultValue(0.5); - temperature.setRange(0.0, 2.0); - temperature.setSingleStep(0.1); - - maxTokens.setSettingsKey(Constants::QR_MAX_TOKENS); - maxTokens.setLabelText(Tr::tr("Max Tokens:")); - maxTokens.setRange(-1, 200000); - maxTokens.setDefaultValue(2000); - - // Advanced Parameters - useTopP.setSettingsKey(Constants::QR_USE_TOP_P); - useTopP.setDefaultValue(false); - useTopP.setLabelText(Tr::tr("Top P:")); - - topP.setSettingsKey(Constants::QR_TOP_P); - topP.setDefaultValue(0.9); - topP.setRange(0.0, 1.0); - topP.setSingleStep(0.1); - - useTopK.setSettingsKey(Constants::QR_USE_TOP_K); - useTopK.setDefaultValue(false); - useTopK.setLabelText(Tr::tr("Top K:")); - - topK.setSettingsKey(Constants::QR_TOP_K); - topK.setDefaultValue(50); - topK.setRange(1, 1000); - - usePresencePenalty.setSettingsKey(Constants::QR_USE_PRESENCE_PENALTY); - usePresencePenalty.setDefaultValue(false); - usePresencePenalty.setLabelText(Tr::tr("Presence Penalty:")); - - presencePenalty.setSettingsKey(Constants::QR_PRESENCE_PENALTY); - presencePenalty.setDefaultValue(0.0); - presencePenalty.setRange(-2.0, 2.0); - presencePenalty.setSingleStep(0.1); - - useFrequencyPenalty.setSettingsKey(Constants::QR_USE_FREQUENCY_PENALTY); - useFrequencyPenalty.setDefaultValue(false); - useFrequencyPenalty.setLabelText(Tr::tr("Frequency Penalty:")); - - frequencyPenalty.setSettingsKey(Constants::QR_FREQUENCY_PENALTY); - frequencyPenalty.setDefaultValue(0.0); - frequencyPenalty.setRange(-2.0, 2.0); - frequencyPenalty.setSingleStep(0.1); - - // Ollama Settings - ollamaLivetime.setSettingsKey(Constants::QR_OLLAMA_LIVETIME); - ollamaLivetime.setToolTip( - Tr::tr( - "Time to suspend Ollama after completion request (in minutes), " - "Only Ollama, -1 to disable")); - ollamaLivetime.setLabelText("Livetime:"); - ollamaLivetime.setDefaultValue("5m"); - ollamaLivetime.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - - contextWindow.setSettingsKey(Constants::QR_OLLAMA_CONTEXT_WINDOW); - contextWindow.setLabelText(Tr::tr("Context Window:")); - contextWindow.setRange(-1, 10000); - contextWindow.setDefaultValue(2048); - - useTools.setSettingsKey(Constants::QR_USE_TOOLS); - useTools.setLabelText(Tr::tr("Enable Tools")); - useTools.setToolTip( - Tr::tr( - "Enable AI tools/functions for quick refactoring (allows reading project files, " - "searching code, etc.)")); - useTools.setDefaultValue(false); - - useThinking.setSettingsKey(Constants::QR_USE_THINKING); - useThinking.setLabelText(Tr::tr("Enable Thinking Mode")); - useThinking.setToolTip( - Tr::tr( - "Enable extended thinking mode for complex refactoring tasks (supported by " - "compatible models like Claude and Google AI)")); - useThinking.setDefaultValue(false); - - thinkingBudgetTokens.setSettingsKey(Constants::QR_THINKING_BUDGET_TOKENS); - thinkingBudgetTokens.setLabelText(Tr::tr("Thinking Budget Tokens:")); - thinkingBudgetTokens.setToolTip( - Tr::tr( - "Number of tokens allocated for thinking process. Use -1 for dynamic thinking " - "(model decides), 0 to disable, or positive value for custom budget")); - thinkingBudgetTokens.setRange(-1, 100000); - thinkingBudgetTokens.setDefaultValue(10000); - - thinkingMaxTokens.setSettingsKey(Constants::QR_THINKING_MAX_TOKENS); - thinkingMaxTokens.setLabelText(Tr::tr("Thinking Max Output Tokens:")); - thinkingMaxTokens.setToolTip( - Tr::tr( - "Maximum output tokens when thinking mode is enabled (includes thinking + response)")); - thinkingMaxTokens.setRange(1000, 200000); - thinkingMaxTokens.setDefaultValue(16000); - - // OpenAI Responses API Settings - openAIResponsesReasoningEffort.setSettingsKey(Constants::QR_OPENAI_RESPONSES_REASONING_EFFORT); - openAIResponsesReasoningEffort.setLabelText(Tr::tr("Reasoning effort:")); - openAIResponsesReasoningEffort.setDisplayStyle(Utils::SelectionAspect::DisplayStyle::ComboBox); - openAIResponsesReasoningEffort.addOption("None"); - openAIResponsesReasoningEffort.addOption("Minimal"); - openAIResponsesReasoningEffort.addOption("Low"); - openAIResponsesReasoningEffort.addOption("Medium"); - openAIResponsesReasoningEffort.addOption("High"); - openAIResponsesReasoningEffort.setDefaultValue("Medium"); - openAIResponsesReasoningEffort.setToolTip( - Tr::tr( - "Constrains effort on reasoning for OpenAI gpt-5 and o-series models:\n\n" - "None: No reasoning (gpt-5.1 only)\n" - "Minimal: Minimal reasoning effort (o-series only)\n" - "Low: Low reasoning effort\n" - "Medium: Balanced reasoning (default for most models)\n" - "High: Maximum reasoning effort (gpt-5-pro only supports this)\n\n" - "Note: Reducing effort = faster responses + fewer tokens")); - // Context Settings readFullFile.setSettingsKey(Constants::QR_READ_FULL_FILE); readFullFile.setLabelText(Tr::tr("Read Full File")); @@ -212,14 +96,6 @@ QuickRefactorSettings::QuickRefactorSettings() widgetMaxHeight.setRange(200, 999999); widgetMaxHeight.setDefaultValue(9999); - systemPrompt.setSettingsKey(Constants::QR_SYSTEM_PROMPT); - systemPrompt.setLabelText(Tr::tr("System Prompt:")); - systemPrompt.setDisplayStyle(Utils::StringAspect::TextEditDisplay); - systemPrompt.setDefaultValue( - "You are an expert C++, Qt, and QML code completion assistant. Your task is to provide " - "precise and contextually appropriate code completions to insert depending on user " - "instructions.\n\n"); - useOpenFilesInQuickRefactor.setSettingsKey(Constants::QR_USE_OPEN_FILES_IN_QUICK_REFACTOR); useOpenFilesInQuickRefactor.setLabelText( Tr::tr("Include context from open files in quick refactor")); @@ -236,29 +112,6 @@ QuickRefactorSettings::QuickRefactorSettings() setLayouter([this]() { using namespace Layouting; - auto genGrid = Grid{}; - genGrid.addRow({Row{temperature}}); - genGrid.addRow({Row{maxTokens}}); - - auto advancedGrid = Grid{}; - advancedGrid.addRow({useTopP, topP}); - advancedGrid.addRow({useTopK, topK}); - advancedGrid.addRow({usePresencePenalty, presencePenalty}); - advancedGrid.addRow({useFrequencyPenalty, frequencyPenalty}); - - auto ollamaGrid = Grid{}; - ollamaGrid.addRow({ollamaLivetime}); - ollamaGrid.addRow({contextWindow}); - - auto toolsGrid = Grid{}; - toolsGrid.addRow({useTools}); - toolsGrid.addRow({useThinking}); - toolsGrid.addRow({thinkingBudgetTokens}); - toolsGrid.addRow({thinkingMaxTokens}); - - auto openAIResponsesGrid = Grid{}; - openAIResponsesGrid.addRow({openAIResponsesReasoningEffort}); - auto contextGrid = Grid{}; contextGrid.addRow({Row{readFullFile}}); contextGrid.addRow({ @@ -275,24 +128,9 @@ QuickRefactorSettings::QuickRefactorSettings() return Column{ Row{Stretch{1}, resetToDefaults}, 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("Tools Settings")), Column{Row{toolsGrid, Stretch{1}}}}, - Space{8}, - Group{title(Tr::tr("OpenAI Responses API")), Column{Row{openAIResponsesGrid, Stretch{1}}}}, - Space{8}, Group{title(Tr::tr("Context Settings")), Column{Row{contextGrid, Stretch{1}}}}, Space{8}, Group{title(Tr::tr("Display Settings")), Column{Row{displayGrid, Stretch{1}}}}, - Space{8}, - Group{title(Tr::tr("Prompt Settings")), Column{Row{systemPrompt}}}, - Space{8}, - Group{title(Tr::tr("Ollama Settings")), Column{Row{ollamaGrid, Stretch{1}}}}, Stretch{1}}; }); } @@ -359,23 +197,6 @@ void QuickRefactorSettings::resetSettingsToDefaults() QMessageBox::Yes | QMessageBox::No); if (reply == QMessageBox::Yes) { - resetAspect(temperature); - resetAspect(maxTokens); - resetAspect(useTopP); - resetAspect(topP); - resetAspect(useTopK); - resetAspect(topK); - resetAspect(usePresencePenalty); - resetAspect(presencePenalty); - resetAspect(useFrequencyPenalty); - resetAspect(frequencyPenalty); - resetAspect(ollamaLivetime); - resetAspect(contextWindow); - resetAspect(useTools); - resetAspect(useThinking); - resetAspect(thinkingBudgetTokens); - resetAspect(thinkingMaxTokens); - resetAspect(openAIResponsesReasoningEffort); resetAspect(readFullFile); resetAspect(readFileParts); resetAspect(readStringsBeforeCursor); @@ -386,7 +207,6 @@ void QuickRefactorSettings::resetSettingsToDefaults() resetAspect(widgetMaxWidth); resetAspect(widgetMinHeight); resetAspect(widgetMaxHeight); - resetAspect(systemPrompt); resetAspect(useOpenFilesInQuickRefactor); writeSettings(); } diff --git a/settings/QuickRefactorSettings.hpp b/settings/QuickRefactorSettings.hpp index 45b731c..99cabbd 100644 --- a/settings/QuickRefactorSettings.hpp +++ b/settings/QuickRefactorSettings.hpp @@ -17,38 +17,6 @@ public: ButtonAspect resetToDefaults{this}; - // General Parameters Settings - Utils::DoubleAspect temperature{this}; - Utils::IntegerAspect maxTokens{this}; - - // Advanced Parameters - Utils::BoolAspect useTopP{this}; - Utils::DoubleAspect topP{this}; - - Utils::BoolAspect useTopK{this}; - Utils::IntegerAspect topK{this}; - - Utils::BoolAspect usePresencePenalty{this}; - Utils::DoubleAspect presencePenalty{this}; - - Utils::BoolAspect useFrequencyPenalty{this}; - Utils::DoubleAspect frequencyPenalty{this}; - - // Ollama Settings - Utils::StringAspect ollamaLivetime{this}; - Utils::IntegerAspect contextWindow{this}; - - // Tools Settings - Utils::BoolAspect useTools{this}; - - // Thinking Settings - Utils::BoolAspect useThinking{this}; - Utils::IntegerAspect thinkingBudgetTokens{this}; - Utils::IntegerAspect thinkingMaxTokens{this}; - - // OpenAI Responses API Settings - Utils::SelectionAspect openAIResponsesReasoningEffort{this}; - // Context Settings Utils::BoolAspect readFullFile{this}; Utils::BoolAspect readFileParts{this}; @@ -64,7 +32,6 @@ public: Utils::IntegerAspect widgetMaxHeight{this}; // Prompt Settings - Utils::StringAspect systemPrompt{this}; Utils::BoolAspect useOpenFilesInQuickRefactor{this}; private: diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index 57f2c97..d93000f 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -88,7 +88,6 @@ 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_ENABLE_CHAT_TOOLS[] = "QodeAssist.caEnableChatTools"; const char CA_USE_TOOLS[] = "QodeAssist.caUseTools"; const char TOOLS_MAX_CONTINUATIONS[] = "QodeAssist.toolsMaxContinuations"; const char CA_ALLOW_ACCESS_OUTSIDE_PROJECT[] = "QodeAssist.caAllowAccessOutsideProject"; @@ -125,26 +124,23 @@ const char MCP_CLIENT_EXTRA_PATHS[] = "QodeAssist.mcpClientExtraPaths"; const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions"; const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId"; const char QODE_ASSIST_CODE_COMPLETION_SETTINGS_PAGE_ID[] - = "QodeAssist.2CodeCompletionSettingsPageId"; + = "QodeAssist.4CodeCompletionSettingsPageId"; const char QODE_ASSIST_CHAT_ASSISTANT_SETTINGS_PAGE_ID[] - = "QodeAssist.3ChatAssistantSettingsPageId"; + = "QodeAssist.5ChatAssistantSettingsPageId"; const char QODE_ASSIST_QUICK_REFACTOR_SETTINGS_PAGE_ID[] - = "QodeAssist.4QuickRefactorSettingsPageId"; -const char QODE_ASSIST_TOOLS_SETTINGS_PAGE_ID[] = "QodeAssist.5ToolsSettingsPageId"; -const char QODE_ASSIST_MCP_SETTINGS_PAGE_ID[] = "QodeAssist.6McpSettingsPageId"; -const char QODE_ASSIST_SKILLS_SETTINGS_PAGE_ID[] = "QodeAssist.8SkillsSettingsPageId"; + = "QodeAssist.6QuickRefactorSettingsPageId"; +const char QODE_ASSIST_TOOLS_SETTINGS_PAGE_ID[] = "QodeAssist.7ToolsSettingsPageId"; +const char QODE_ASSIST_MCP_SETTINGS_PAGE_ID[] = "QodeAssist.8McpSettingsPageId"; +const char QODE_ASSIST_SKILLS_SETTINGS_PAGE_ID[] = "QodeAssist.9SkillsSettingsPageId"; const char QODE_ASSIST_GENERAL_OPTIONS_CATEGORY[] = "QodeAssist.Category"; const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist"; // Provider Settings Page ID -const char QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID[] = "QodeAssist.7ProviderSettingsPageId"; +const char QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID[] = "QodeAssist.3ProviderSettingsPageId"; // Agents Settings Page ID -const char QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID[] = "QodeAssist.8AgentsSettingsPageId"; - -// Agent Pipelines (experimental) settings -const char QODE_ASSIST_AGENT_PIPELINES_PAGE_ID[] = "QodeAssist.9AgentPipelinesPageId"; +const char QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID[] = "QodeAssist.2AgentsSettingsPageId"; // Provider API Keys const char OPEN_ROUTER_API_KEY[] = "QodeAssist.openRouterApiKey"; @@ -177,51 +173,8 @@ const char CLAUDE_USE_EXTENDED_CACHE_TTL[] = "QodeAssist.claudeUseExtendedCacheT const char CC_READ_FULL_FILE[] = "QodeAssist.ccReadFullFile"; const char CC_READ_STRINGS_BEFORE_CURSOR[] = "QodeAssist.ccReadStringsBeforeCursor"; const char CC_READ_STRINGS_AFTER_CURSOR[] = "QodeAssist.ccReadStringsAfterCursor"; -const char CC_USE_SYSTEM_PROMPT[] = "QodeAssist.ccUseSystemPrompt"; -const char CC_SYSTEM_PROMPT[] = "QodeAssist.ccSystemPrompt"; -const char CC_SYSTEM_PROMPT_FOR_NON_FIM[] = "QodeAssist.ccSystemPromptForNonFim"; -const char CC_USE_USER_TEMPLATE[] = "QodeAssist.ccUseUserTemplate"; -const char CC_USER_TEMPLATE[] = "QodeAssist.ccUserTemplate"; const char CC_USE_PROJECT_CHANGES_CACHE[] = "QodeAssist.ccUseProjectChangesCache"; const char CC_MAX_CHANGES_CACHE_SIZE[] = "QodeAssist.ccMaxChangesCacheSize"; -const char CA_USE_SYSTEM_PROMPT[] = "QodeAssist.useChatSystemPrompt"; -const char CA_SYSTEM_PROMPT[] = "QodeAssist.chatSystemPrompt"; - -// preset prompt settings -const char CC_TEMPERATURE[] = "QodeAssist.ccTemperature"; -const char CC_MAX_TOKENS[] = "QodeAssist.ccMaxTokens"; -const char CC_USE_TOP_P[] = "QodeAssist.ccUseTopP"; -const char CC_TOP_P[] = "QodeAssist.ccTopP"; -const char CC_USE_TOP_K[] = "QodeAssist.ccUseTopK"; -const char CC_TOP_K[] = "QodeAssist.ccTopK"; -const char CC_USE_PRESENCE_PENALTY[] = "QodeAssist.ccUsePresencePenalty"; -const char CC_PRESENCE_PENALTY[] = "QodeAssist.ccPresencePenalty"; -const char CC_USE_FREQUENCY_PENALTY[] = "QodeAssist.fimUseFrequencyPenalty"; -const char CC_FREQUENCY_PENALTY[] = "QodeAssist.fimFrequencyPenalty"; -const char CC_OLLAMA_LIVETIME[] = "QodeAssist.fimOllamaLivetime"; -const char CC_OLLAMA_CONTEXT_WINDOW[] = "QodeAssist.ccOllamaContextWindow"; - -// OpenAI Responses API Settings -const char CC_OPENAI_RESPONSES_REASONING_EFFORT[] = "QodeAssist.ccOpenAIResponsesReasoningEffort"; - -const char CA_TEMPERATURE[] = "QodeAssist.chatTemperature"; -const char CA_MAX_TOKENS[] = "QodeAssist.chatMaxTokens"; -const char CA_USE_TOP_P[] = "QodeAssist.chatUseTopP"; -const char CA_TOP_P[] = "QodeAssist.chatTopP"; -const char CA_USE_TOP_K[] = "QodeAssist.chatUseTopK"; -const char CA_TOP_K[] = "QodeAssist.chatTopK"; -const char CA_USE_PRESENCE_PENALTY[] = "QodeAssist.chatUsePresencePenalty"; -const char CA_PRESENCE_PENALTY[] = "QodeAssist.chatPresencePenalty"; -const char CA_USE_FREQUENCY_PENALTY[] = "QodeAssist.chatUseFrequencyPenalty"; -const char CA_FREQUENCY_PENALTY[] = "QodeAssist.chatFrequencyPenalty"; -const char CA_OLLAMA_LIVETIME[] = "QodeAssist.chatOllamaLivetime"; -const char CA_OLLAMA_CONTEXT_WINDOW[] = "QodeAssist.caOllamaContextWindow"; -const char CA_ENABLE_THINKING_MODE[] = "QodeAssist.caEnableThinkingMode"; -const char CA_THINKING_BUDGET_TOKENS[] = "QodeAssist.caThinkingBudgetTokens"; -const char CA_THINKING_MAX_TOKENS[] = "QodeAssist.caThinkingMaxTokens"; - -// OpenAI Responses API Settings -const char CA_OPENAI_RESPONSES_REASONING_EFFORT[] = "QodeAssist.caOpenAIResponsesReasoningEffort"; const char CA_TEXT_FONT_FAMILY[] = "QodeAssist.caTextFontFamily"; const char CA_TEXT_FONT_SIZE[] = "QodeAssist.caTextFontSize"; @@ -230,33 +183,10 @@ const char CA_CODE_FONT_SIZE[] = "QodeAssist.caCodeFontSize"; const char CA_TEXT_FORMAT[] = "QodeAssist.caTextFormat"; const char CA_CHAT_RENDERER[] = "QodeAssist.caChatRenderer"; -const char CA_LAST_USED_ROLE[] = "QodeAssist.caLastUsedRole"; - // quick refactor preset prompt settings -const char QR_TEMPERATURE[] = "QodeAssist.qrTemperature"; -const char QR_MAX_TOKENS[] = "QodeAssist.qrMaxTokens"; -const char QR_USE_TOP_P[] = "QodeAssist.qrUseTopP"; -const char QR_TOP_P[] = "QodeAssist.qrTopP"; -const char QR_USE_TOP_K[] = "QodeAssist.qrUseTopK"; -const char QR_TOP_K[] = "QodeAssist.qrTopK"; -const char QR_USE_PRESENCE_PENALTY[] = "QodeAssist.qrUsePresencePenalty"; -const char QR_PRESENCE_PENALTY[] = "QodeAssist.qrPresencePenalty"; -const char QR_USE_FREQUENCY_PENALTY[] = "QodeAssist.qrUseFrequencyPenalty"; -const char QR_FREQUENCY_PENALTY[] = "QodeAssist.qrFrequencyPenalty"; -const char QR_OLLAMA_LIVETIME[] = "QodeAssist.qrOllamaLivetime"; -const char QR_OLLAMA_CONTEXT_WINDOW[] = "QodeAssist.qrOllamaContextWindow"; -const char QR_USE_TOOLS[] = "QodeAssist.qrUseTools"; -const char QR_USE_THINKING[] = "QodeAssist.qrUseThinking"; -const char QR_THINKING_BUDGET_TOKENS[] = "QodeAssist.qrThinkingBudgetTokens"; -const char QR_THINKING_MAX_TOKENS[] = "QodeAssist.qrThinkingMaxTokens"; - -// OpenAI Responses API Settings -const char QR_OPENAI_RESPONSES_REASONING_EFFORT[] = "QodeAssist.qrOpenAIResponsesReasoningEffort"; - const char QR_READ_FULL_FILE[] = "QodeAssist.qrReadFullFile"; const char QR_READ_STRINGS_BEFORE_CURSOR[] = "QodeAssist.qrReadStringsBeforeCursor"; const char QR_READ_STRINGS_AFTER_CURSOR[] = "QodeAssist.qrReadStringsAfterCursor"; -const char QR_SYSTEM_PROMPT[] = "QodeAssist.qrSystemPrompt"; const char QR_USE_OPEN_FILES_IN_QUICK_REFACTOR[] = "QodeAssist.qrUseOpenFilesInQuickRefactor"; const char QR_DISPLAY_MODE[] = "QodeAssist.qrDisplayMode"; const char QR_WIDGET_ORIENTATION[] = "QodeAssist.qrWidgetOrientation"; diff --git a/settings/SettingsDialog.cpp b/settings/SettingsDialog.cpp deleted file mode 100644 index 3f215cb..0000000 --- a/settings/SettingsDialog.cpp +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "SettingsDialog.hpp" - -namespace QodeAssist::Settings { - -SettingsDialog::SettingsDialog(const QString &title, QWidget *parent) - : QDialog(parent) - , m_mainLayout(new QVBoxLayout(this)) - , m_buttonLayout(nullptr) -{ - setWindowTitle(title); - m_mainLayout->setSizeConstraint(QLayout::SetMinAndMaxSize); -} - -QLabel *SettingsDialog::addLabel(const QString &text) -{ - auto label = new QLabel(text, this); - label->setWordWrap(true); - label->setMinimumWidth(300); - m_mainLayout->addWidget(label); - return label; -} - -QLineEdit *SettingsDialog::addInputField(const QString &labelText, const QString &value) -{ - auto inputLayout = new QGridLayout; - auto inputLabel = new QLabel(labelText, this); - auto inputField = new QLineEdit(value, this); - inputField->setMinimumWidth(200); - - inputLayout->addWidget(inputLabel, 0, 0); - inputLayout->addWidget(inputField, 0, 1); - inputLayout->setColumnStretch(1, 1); - m_mainLayout->addLayout(inputLayout); - - return inputField; -} - -void SettingsDialog::addSpacing(int space) -{ - m_mainLayout->addSpacing(space); -} - -QHBoxLayout *SettingsDialog::buttonLayout() -{ - if (!m_buttonLayout) { - m_buttonLayout = new QHBoxLayout; - m_buttonLayout->addStretch(); - m_mainLayout->addLayout(m_buttonLayout); - } - return m_buttonLayout; -} - -QComboBox *SettingsDialog::addComboBox( - const QStringList &items, const QString ¤tText, bool editable) -{ - auto comboBox = new QComboBox(this); - comboBox->addItems(items); - comboBox->setCurrentText(currentText); - comboBox->setMinimumWidth(300); - comboBox->setEditable(editable); - m_mainLayout->addWidget(comboBox); - return comboBox; -} - -QVBoxLayout *SettingsDialog::mainLayout() const -{ - return m_mainLayout; -} - -} // namespace QodeAssist::Settings diff --git a/settings/SettingsDialog.hpp b/settings/SettingsDialog.hpp deleted file mode 100644 index 3c14abd..0000000 --- a/settings/SettingsDialog.hpp +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include -#include -#include -#include -#include -#include - -#include - -namespace QodeAssist::Settings { - -class SettingsDialog : public QDialog -{ -public: - explicit SettingsDialog(const QString &title, QWidget *parent = Core::ICore::dialogParent()); - - QLabel *addLabel(const QString &text); - QLineEdit *addInputField(const QString &labelText, const QString &value); - void addSpacing(int space = 12); - QHBoxLayout *buttonLayout(); - QVBoxLayout *mainLayout() const; - - QComboBox *addComboBox( - const QStringList &items, const QString ¤tText = QString(), bool editable = true); - -private: - QVBoxLayout *m_mainLayout; - QHBoxLayout *m_buttonLayout; -}; - -} // namespace QodeAssist::Settings diff --git a/settings/SettingsTheme.hpp b/settings/SettingsTheme.hpp index 4340de3..a165f0a 100644 --- a/settings/SettingsTheme.hpp +++ b/settings/SettingsTheme.hpp @@ -4,6 +4,7 @@ #pragma once +#include #include #include #include @@ -11,34 +12,32 @@ namespace QodeAssist::Settings { -struct Theme -{ - bool dark = false; - QString listHeaderBg; - QString rowSeparator; - QString rowSelectedBg; - QString codeBg; -}; - inline bool isDarkPalette(const QPalette &p) { return p.color(QPalette::Window).lightness() < 128; } -inline Theme themeFor(const QPalette &p) +// Linear blend a→b by t∈[0,1]; used to derive subtle tints from theme roles. +inline QColor mix(const QColor &a, const QColor &b, double t) { - const bool dark = isDarkPalette(p); - if (dark) - return {true, - QStringLiteral("#262626"), - QStringLiteral("#3a3a3a"), - QStringLiteral("#2c4060"), - QStringLiteral("#1f1f1f")}; - return {false, - QStringLiteral("#f0f0f0"), - QStringLiteral("#dcdcdc"), - QStringLiteral("#cfe2ff"), - QStringLiteral("#f4f4f4")}; + const double s = 1.0 - t; + return QColor::fromRgbF( + a.redF() * s + b.redF() * t, + a.greenF() * s + b.greenF() * t, + a.blueF() * s + b.blueF() * t, + 1.0); +} + +// Serialize a theme color for a Qt stylesheet preserving alpha. Some Qt Creator +// theme roles (e.g. BackgroundColorHover) are semi-transparent; QColor::name() +// drops the alpha and would render them as solid black. +inline QString cssColor(const QColor &c) +{ + return QStringLiteral("rgba(%1, %2, %3, %4)") + .arg(c.red()) + .arg(c.green()) + .arg(c.blue()) + .arg(c.alphaF(), 0, 'f', 3); } inline QFont monospaceFont(int pixelSize = 11) diff --git a/settings/SettingsUiBuilders.cpp b/settings/SettingsUiBuilders.cpp index 717757c..c1de0bc 100644 --- a/settings/SettingsUiBuilders.cpp +++ b/settings/SettingsUiBuilders.cpp @@ -6,6 +6,8 @@ #include "SettingsTheme.hpp" +#include + #include #include #include @@ -22,7 +24,7 @@ void applyMutedSmallCaps(QLabel *label) f.setLetterSpacing(QFont::AbsoluteSpacing, 0.4); label->setFont(f); QPalette p = label->palette(); - p.setColor(QPalette::WindowText, p.color(QPalette::Mid)); + p.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); label->setPalette(p); } @@ -32,11 +34,11 @@ QLabel *makeSectionHeader(const QString &title, QWidget *parent) applyMutedSmallCaps(header); header->setContentsMargins(8, 4, 8, 4); header->setAutoFillBackground(true); - const Theme theme = themeFor(parent ? parent->palette() : QPalette()); header->setStyleSheet( QStringLiteral("QLabel { background:%1; border-top:1px solid %2;" " border-bottom:1px solid %2; }") - .arg(theme.listHeaderBg, theme.rowSeparator)); + .arg(cssColor(Utils::creatorColor(Utils::Theme::BackgroundColorNormal)), + cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)))); return header; } @@ -48,7 +50,7 @@ QLabel *makeHintLabel(const QString &text, QWidget *parent) h->setFont(hf); h->setWordWrap(true); QPalette p = h->palette(); - p.setColor(QPalette::WindowText, p.color(QPalette::Mid)); + p.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); h->setPalette(p); return h; } diff --git a/settings/TagChip.cpp b/settings/TagChip.cpp index dda8ed4..e64875b 100644 --- a/settings/TagChip.cpp +++ b/settings/TagChip.cpp @@ -6,6 +6,9 @@ #include "SettingsTheme.hpp" +#include + +#include #include #include #include @@ -21,14 +24,15 @@ TagChip::TagChip(const QString &tag, int count, QWidget *parent) , m_tag(tag) { setObjectName(QStringLiteral("TagChip")); + setAttribute(Qt::WA_StyledBackground, true); setCursor(Qt::PointingHandCursor); m_label = new QLabel(tag, this); m_label->setFont(monospaceFont(11)); auto *row = new QHBoxLayout(this); - row->setContentsMargins(5, 0, 5, 0); - row->setSpacing(4); + row->setContentsMargins(10, 3, 10, 3); + row->setSpacing(6); row->addWidget(m_label); if (count >= 0) { @@ -49,6 +53,13 @@ void TagChip::setActive(bool on) applyTheme(); } +void TagChip::setCount(int count) +{ + if (!m_count) + return; + m_count->setText(count >= 0 ? QString::number(count) : QString()); +} + void TagChip::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) @@ -56,9 +67,29 @@ void TagChip::mouseReleaseEvent(QMouseEvent *event) QFrame::mouseReleaseEvent(event); } +void TagChip::enterEvent(QEnterEvent *event) +{ + QFrame::enterEvent(event); + if (m_hover) + return; + m_hover = true; + applyTheme(); +} + +void TagChip::leaveEvent(QEvent *event) +{ + QFrame::leaveEvent(event); + if (!m_hover) + return; + m_hover = false; + applyTheme(); +} + void TagChip::changeEvent(QEvent *event) { QFrame::changeEvent(event); + if (m_inApplyTheme) + return; if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange) applyTheme(); } @@ -68,20 +99,47 @@ void TagChip::applyTheme() if (m_inApplyTheme) return; QScopedValueRollback guard(m_inApplyTheme, true); - const Theme theme = themeFor(palette()); - const QString text = palette().color(QPalette::WindowText).name(); - const QString mute = palette().color(QPalette::Mid).name(); - const QString border = m_active ? text : theme.rowSeparator; - const QString bg = m_active ? theme.rowSelectedBg : QStringLiteral("transparent"); + + const QColor text = Utils::creatorColor(Utils::Theme::TextColorNormal); + const QColor muted = Utils::creatorColor(Utils::Theme::PanelTextColorMid); + const QColor line = Utils::creatorColor(Utils::Theme::SplitterColor); + + QString bg; + QColor border; + QColor labelColor; + QColor countColor; + if (m_active) { + bg = cssColor(Utils::creatorColor(Utils::Theme::BackgroundColorSelected)); + border = Utils::creatorColor(Utils::Theme::TextColorLink); + labelColor = text; + countColor = text; + } else if (m_hover) { + bg = cssColor(Utils::creatorColor(Utils::Theme::BackgroundColorHover)); + border = line; + labelColor = text; + countColor = muted; + } else { + bg = QStringLiteral("transparent"); + border = line; + labelColor = text; + countColor = muted; + } + setStyleSheet(QStringLiteral( - "#TagChip { background:%1; border:1px solid %2; }") - .arg(bg, border)); + "#TagChip { background:%1; border:1px solid %2; border-radius:10px; }") + .arg(bg, cssColor(border))); + + QFont lf = m_label->font(); + lf.setBold(m_active); + m_label->setFont(lf); + QPalette lp = m_label->palette(); - lp.setColor(QPalette::WindowText, m_active ? QColor(text) : QColor(mute)); + lp.setColor(QPalette::WindowText, labelColor); m_label->setPalette(lp); + if (m_count) { QPalette cp = m_count->palette(); - cp.setColor(QPalette::WindowText, QColor(mute)); + cp.setColor(QPalette::WindowText, countColor); m_count->setPalette(cp); } } diff --git a/settings/TagChip.hpp b/settings/TagChip.hpp index 94a728b..73d3692 100644 --- a/settings/TagChip.hpp +++ b/settings/TagChip.hpp @@ -8,6 +8,7 @@ #include class QLabel; +class QEnterEvent; namespace QodeAssist::Settings { @@ -18,6 +19,7 @@ public: explicit TagChip(const QString &tag, int count, QWidget *parent = nullptr); void setActive(bool on); + void setCount(int count); QString tag() const { return m_tag; } signals: @@ -25,6 +27,8 @@ signals: protected: void mouseReleaseEvent(QMouseEvent *event) override; + void enterEvent(QEnterEvent *event) override; + void leaveEvent(QEvent *event) override; void changeEvent(QEvent *event) override; private: @@ -32,6 +36,7 @@ private: QString m_tag; bool m_active = false; + bool m_hover = false; bool m_inApplyTheme = false; QLabel *m_label = nullptr; QLabel *m_count = nullptr; diff --git a/settings/TagFilterStrip.cpp b/settings/TagFilterStrip.cpp index 647ed6b..446c05a 100644 --- a/settings/TagFilterStrip.cpp +++ b/settings/TagFilterStrip.cpp @@ -4,10 +4,11 @@ #include "TagFilterStrip.hpp" -#include "SettingsTheme.hpp" #include "SettingsUiBuilders.hpp" #include "TagChip.hpp" +#include + #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -35,14 +37,17 @@ TagFilterStrip::TagFilterStrip(QWidget *parent) } void TagFilterStrip::setAvailableTags(const QMap &countsByTag) +{ + setAvailableTags(countsByTag, m_activeTags); +} + +void TagFilterStrip::setAvailableTags( + const QMap &countsByTag, const QSet &activeTags) { m_counts = countsByTag; - QSet stillExisting; - for (auto it = m_counts.cbegin(); it != m_counts.cend(); ++it) - stillExisting.insert(it.key()); QSet trimmed; - for (const QString &t : m_activeTags) - if (stillExisting.contains(t)) + for (const QString &t : activeTags) + if (m_counts.contains(t)) trimmed.insert(t); const bool activeChanged = trimmed != m_activeTags; if (activeChanged) @@ -52,6 +57,12 @@ void TagFilterStrip::setAvailableTags(const QMap &countsByTag) emit activeTagsChanged(m_activeTags); } +void TagFilterStrip::setVisibleCounts(const QMap &countsByTag) +{ + for (auto it = m_chipByTag.cbegin(); it != m_chipByTag.cend(); ++it) + it.value()->setCount(countsByTag.value(it.key(), 0)); +} + void TagFilterStrip::changeEvent(QEvent *event) { QWidget::changeEvent(event); @@ -82,10 +93,11 @@ void TagFilterStrip::applyTheme() if (m_inApplyTheme) return; QScopedValueRollback guard(m_inApplyTheme, true); - const Theme theme = themeFor(palette()); + const QString bg = Utils::creatorColor(Utils::Theme::BackgroundColorNormal).name(); + const QString line = Utils::creatorColor(Utils::Theme::SplitterColor).name(); setStyleSheet(QStringLiteral("QWidget#TagStrip { background:%1;" " border-bottom:1px solid %2; }") - .arg(theme.listHeaderBg, theme.rowSeparator)); + .arg(bg, line)); } void TagFilterStrip::rebuild() @@ -115,7 +127,18 @@ void TagFilterStrip::rebuild() headerLine->setSpacing(6); auto *title = new QLabel(tr("FILTER BY TAG"), this); applyMutedSmallCaps(title); + title->setToolTip(tr("Agents must carry every selected tag")); headerLine->addWidget(title); + auto *andHint = new QLabel(tr("match all"), this); + { + QFont hf = andHint->font(); + hf.setPointSizeF(hf.pointSizeF() * 0.85); + andHint->setFont(hf); + QPalette hp = andHint->palette(); + hp.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); + andHint->setPalette(hp); + } + headerLine->addWidget(andHint); headerLine->addStretch(1); if (!m_activeTags.isEmpty()) { auto *clear = new QLabel(QStringLiteral("%1").arg(tr("clear")), this); @@ -141,13 +164,14 @@ void TagFilterStrip::rebuild() return a.first.localeAwareCompare(b.first) < 0; }); - auto *grid = new QGridLayout; + auto *gridHost = new QWidget(this); + auto *grid = new QGridLayout(gridHost); grid->setContentsMargins(0, 0, 0, 0); - grid->setHorizontalSpacing(3); - grid->setVerticalSpacing(3); + grid->setHorizontalSpacing(6); + grid->setVerticalSpacing(5); int col = 0, gridRow = 0; for (const auto &[tag, count] : sorted) { - auto *chip = new TagChip(tag, count, this); + auto *chip = new TagChip(tag, count, gridHost); chip->setActive(m_activeTags.contains(tag)); connect(chip, &TagChip::clicked, this, &TagFilterStrip::toggleTag); grid->addWidget(chip, gridRow, col, Qt::AlignLeft); @@ -158,7 +182,23 @@ void TagFilterStrip::rebuild() } } grid->setColumnStretch(4, 1); - m_layout->addLayout(grid); + + constexpr int kMaxVisibleRows = 4; + constexpr int kRowSpacing = 5; + const int rowCount = gridRow + (col > 0 ? 1 : 0); + if (rowCount > kMaxVisibleRows && !m_chipByTag.isEmpty()) { + const int chipHeight = std::max(m_chipByTag.cbegin().value()->sizeHint().height(), 18); + auto *scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setWidget(gridHost); + scroll->setMaximumHeight( + kMaxVisibleRows * chipHeight + (kMaxVisibleRows - 1) * kRowSpacing + 4); + m_layout->addWidget(scroll); + } else { + m_layout->addWidget(gridHost); + } } } // namespace QodeAssist::Settings diff --git a/settings/TagFilterStrip.hpp b/settings/TagFilterStrip.hpp index 8ca75fc..6ffaaad 100644 --- a/settings/TagFilterStrip.hpp +++ b/settings/TagFilterStrip.hpp @@ -23,6 +23,9 @@ public: explicit TagFilterStrip(QWidget *parent = nullptr); void setAvailableTags(const QMap &countsByTag); + void setAvailableTags( + const QMap &countsByTag, const QSet &activeTags); + void setVisibleCounts(const QMap &countsByTag); const QSet &activeTags() const { return m_activeTags; } signals: diff --git a/sources/CMakeLists.txt b/sources/CMakeLists.txt index 9139c6c..b79ea7d 100644 --- a/sources/CMakeLists.txt +++ b/sources/CMakeLists.txt @@ -5,8 +5,6 @@ add_subdirectory(common) add_subdirectory(providers) add_subdirectory(templates) add_subdirectory(agents) +add_subdirectory(Session) add_subdirectory(providersConfig) - -if(QODEASSIST_EXPERIMENTAL) - add_subdirectory(settings) -endif() +add_subdirectory(settings) diff --git a/sources/Session/CMakeLists.txt b/sources/Session/CMakeLists.txt index 0d4329b..353fd0a 100644 --- a/sources/Session/CMakeLists.txt +++ b/sources/Session/CMakeLists.txt @@ -3,12 +3,15 @@ add_library(Session STATIC MessageSerializer.hpp MessageSerializer.cpp PluginBlocks.hpp LLMRequest.hpp + ErrorInfo.hpp ResponseEvent.hpp + ContextAssembler.hpp ContextAssembler.cpp ConversationHistory.hpp ConversationHistory.cpp ResponseRouter.hpp ResponseRouter.cpp Session.hpp Session.cpp SessionManager.hpp SessionManager.cpp SystemPromptBuilder.hpp SystemPromptBuilder.cpp + ToolContributorRegistry.hpp ) target_link_libraries(Session diff --git a/sources/Session/ContextAssembler.cpp b/sources/Session/ContextAssembler.cpp new file mode 100644 index 0000000..8a63cc7 --- /dev/null +++ b/sources/Session/ContextAssembler.cpp @@ -0,0 +1,307 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "ContextAssembler.hpp" + +#include +#include +#include + +#include + +#include "Message.hpp" +#include "PluginBlocks.hpp" + +namespace QodeAssist::ContextAssembler { + +namespace { + +Q_LOGGING_CATEGORY(ctxLog, "qodeassist.context") + +QString roleToWireString(Message::Role role) +{ + switch (role) { + case Message::Role::System: return QStringLiteral("system"); + case Message::Role::User: return QStringLiteral("user"); + case Message::Role::Assistant: return QStringLiteral("assistant"); + } + return QStringLiteral("user"); +} + +bool isReplayableThinking(const LLMQore::ThinkingContent *block) +{ + return !block->signature().isEmpty(); +} + +Templates::ContentBlockEntry makeTextEntry(const QString &text) +{ + Templates::ContentBlockEntry e; + e.kind = Templates::ContentBlockEntry::Kind::Text; + e.text = text; + return e; +} + +QString placeholderFor(const QString &what, const QString &fileName) +{ + return QStringLiteral("[%1 unavailable: %2]").arg(what, fileName); +} + +} // namespace + +QString Manifest::summary() const +{ + QString s = QStringLiteral( + "system=%1ch, history=%2 msgs -> %3 wire, text=%4ch, thinking=%5ch, " + "tools=%6 use/%7 result (%8ch), images=%9") + .arg(systemChars) + .arg(historyMessages) + .arg(wireMessages) + .arg(textChars) + .arg(thinkingChars) + .arg(toolUseBlocks) + .arg(toolResultBlocks) + .arg(toolChars) + .arg(imageBlocks); + if (pinnedBlocks > 0) + s += QStringLiteral(", pinned=%1 (%2ch)").arg(pinnedBlocks).arg(pinnedChars); + if (hasCompletionContext) + s += QStringLiteral(", fim"); + if (unsupportedBlocks > 0) + s += QStringLiteral(", unsupported=%1").arg(unsupportedBlocks); + if (!elided.isEmpty()) + s += QStringLiteral(", elided=%1 [%2]").arg(elided.size()).arg(elided.join(QStringLiteral("; "))); + return s; +} + +Templates::ContextData assemble( + const std::vector &history, + const QString &systemPrompt, + const ContentLoader &loader, + const QVector &pinned, + Manifest *outManifest) +{ + using Templates::ContentBlockEntry; + using Templates::ContextData; + using WireMessage = Templates::Message; + + Manifest manifest; + manifest.historyMessages = static_cast(history.size()); + + ContextData ctx; + if (!systemPrompt.isEmpty()) { + ctx.systemPrompt = systemPrompt; + manifest.systemChars = systemPrompt.size(); + } + + QSet resolvedToolUseIds; + QSet declaredToolUseIds; + for (const auto &m : history) { + for (const auto &blockPtr : m.blocks()) { + if (auto *tr = dynamic_cast(blockPtr.get())) + resolvedToolUseIds.insert(tr->toolUseId()); + if (auto *tu = dynamic_cast(blockPtr.get())) + declaredToolUseIds.insert(tu->id()); + } + } + + QVector wireHistory; + + for (const auto &m : history) { + if (m.role() == Message::Role::System) { + manifest.elided << QStringLiteral("system message skipped"); + continue; + } + + if (auto *cc = m.lastBlockOfType()) { + ctx.prefix = cc->prefix(); + ctx.suffix = cc->suffix(); + manifest.hasCompletionContext = true; + continue; + } + + QVector blockEntries; + + for (const auto &blockPtr : m.blocks()) { + auto *block = blockPtr.get(); + if (!block) + continue; + + if (auto *t = dynamic_cast(block)) { + blockEntries.append(makeTextEntry(t->text())); + manifest.textChars += t->text().size(); + } else if (auto *img = dynamic_cast(block)) { + ContentBlockEntry e; + e.kind = ContentBlockEntry::Kind::Image; + e.imageData = img->data(); + e.mediaType = img->mediaType(); + e.isImageUrl + = (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url); + blockEntries.append(std::move(e)); + ++manifest.imageBlocks; + } else if (auto *si = dynamic_cast(block)) { + const QString base64 = loader ? loader(si->storedPath()) : QString(); + if (base64.isEmpty()) { + blockEntries.append( + makeTextEntry(placeholderFor(QStringLiteral("Image"), si->fileName()))); + manifest.elided + << QStringLiteral("image unavailable: %1").arg(si->fileName()); + qCWarning(ctxLog).noquote() + << "stored image unavailable, placeholder inserted:" << si->fileName(); + continue; + } + ContentBlockEntry e; + e.kind = ContentBlockEntry::Kind::Image; + e.imageData = base64; + e.mediaType = si->mediaType(); + e.isImageUrl = false; + blockEntries.append(std::move(e)); + ++manifest.imageBlocks; + } else if (auto *sa = dynamic_cast(block)) { + const QString stored = loader ? loader(sa->storedPath()) : QString(); + if (stored.isEmpty()) { + blockEntries.append(makeTextEntry( + placeholderFor(QStringLiteral("Attachment"), sa->fileName()))); + manifest.elided + << QStringLiteral("attachment unavailable: %1").arg(sa->fileName()); + qCWarning(ctxLog).noquote() + << "stored attachment unavailable, placeholder inserted:" + << sa->fileName(); + continue; + } + const QString text = QString::fromUtf8(QByteArray::fromBase64(stored.toUtf8())); + blockEntries.append(makeTextEntry( + QStringLiteral("File: %1\n```\n%2\n```").arg(sa->fileName(), text))); + manifest.textChars += text.size(); + } else if (auto *sk = dynamic_cast(block)) { + blockEntries.append(makeTextEntry( + QStringLiteral("# Invoked Skill: %1\n\n%2").arg(sk->skillName(), sk->body()))); + manifest.textChars += sk->body().size(); + } else if (auto *th = dynamic_cast(block)) { + if (!isReplayableThinking(th)) { + manifest.elided << QStringLiteral("unsigned thinking dropped"); + continue; + } + ContentBlockEntry e; + e.kind = ContentBlockEntry::Kind::Thinking; + e.thinking = th->thinking(); + e.signature = th->signature(); + blockEntries.append(std::move(e)); + manifest.thinkingChars += th->thinking().size(); + } else if (auto *rth = dynamic_cast(block)) { + if (rth->signature().isEmpty()) { + manifest.elided << QStringLiteral("unsigned redacted thinking dropped"); + continue; + } + ContentBlockEntry e; + e.kind = ContentBlockEntry::Kind::RedactedThinking; + e.signature = rth->signature(); + blockEntries.append(std::move(e)); + } else if (auto *tu = dynamic_cast(block)) { + if (!resolvedToolUseIds.contains(tu->id())) { + manifest.elided + << QStringLiteral("orphan tool_use dropped: %1").arg(tu->id()); + continue; + } + ContentBlockEntry e; + e.kind = ContentBlockEntry::Kind::ToolUse; + e.toolUseId = tu->id(); + e.toolName = tu->name(); + e.toolInput = tu->input(); + blockEntries.append(std::move(e)); + ++manifest.toolUseBlocks; + manifest.toolChars + += QJsonDocument(tu->input()).toJson(QJsonDocument::Compact).size(); + } else if (auto *tr = dynamic_cast(block)) { + if (!declaredToolUseIds.contains(tr->toolUseId())) { + manifest.elided + << QStringLiteral("orphan tool_result dropped: %1").arg(tr->toolUseId()); + continue; + } + ContentBlockEntry e; + e.kind = ContentBlockEntry::Kind::ToolResult; + e.toolUseId = tr->toolUseId(); + e.result = tr->result(); + blockEntries.append(std::move(e)); + ++manifest.toolResultBlocks; + manifest.toolChars += tr->result().size(); + } else { + ++manifest.unsupportedBlocks; + } + } + + if (blockEntries.isEmpty()) + continue; + + const bool hasNonThinking = std::any_of( + blockEntries.begin(), blockEntries.end(), [](const ContentBlockEntry &e) { + return e.kind != ContentBlockEntry::Kind::Thinking + && e.kind != ContentBlockEntry::Kind::RedactedThinking; + }); + if (!hasNonThinking) { + manifest.elided << QStringLiteral("thinking-only message dropped"); + continue; + } + + WireMessage wm; + wm.role = roleToWireString(m.role()); + wm.blocks = std::move(blockEntries); + wireHistory.append(std::move(wm)); + } + + QVector pinnedEntries; + for (const auto &p : pinned) { + if (p.text.isEmpty()) + continue; + pinnedEntries.append(makeTextEntry(p.text)); + ++manifest.pinnedBlocks; + manifest.pinnedChars += p.text.size(); + } + if (!pinnedEntries.isEmpty()) { + int anchorIndex = -1; + int toolCarrierIndex = -1; + for (int i = wireHistory.size() - 1; i >= 0; --i) { + if (wireHistory[i].role != QLatin1String("user")) + continue; + const auto &blocks = wireHistory[i].blocks; + const bool carriesToolResults = !blocks.isEmpty() + && blocks.first().kind + == ContentBlockEntry::Kind::ToolResult; + if (!carriesToolResults) { + anchorIndex = i; + break; + } + if (toolCarrierIndex < 0) + toolCarrierIndex = i; + } + if (anchorIndex < 0) + anchorIndex = toolCarrierIndex; + if (anchorIndex < 0) { + WireMessage wm; + wm.role = QStringLiteral("user"); + wireHistory.append(std::move(wm)); + anchorIndex = wireHistory.size() - 1; + } + auto &target = wireHistory[anchorIndex].blocks; + qsizetype insertPos = 0; + while (insertPos < target.size() + && target[insertPos].kind == ContentBlockEntry::Kind::ToolResult) { + ++insertPos; + } + for (qsizetype i = 0; i < pinnedEntries.size(); ++i) + target.insert(insertPos + i, pinnedEntries[i]); + } + + manifest.wireMessages = wireHistory.size(); + if (!wireHistory.isEmpty()) + ctx.history = std::move(wireHistory); + + qCDebug(ctxLog).noquote() << manifest.summary(); + + if (outManifest) + *outManifest = std::move(manifest); + + return ctx; +} + +} // namespace QodeAssist::ContextAssembler diff --git a/sources/Session/ContextAssembler.hpp b/sources/Session/ContextAssembler.hpp new file mode 100644 index 0000000..90b485a --- /dev/null +++ b/sources/Session/ContextAssembler.hpp @@ -0,0 +1,58 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include +#include +#include + +#include +#include + +namespace QodeAssist { + +class Message; + +namespace ContextAssembler { + +using ContentLoader = std::function; + +struct PinnedBlock +{ + QString id; + QString text; +}; + +struct Manifest +{ + qsizetype systemChars = 0; + int historyMessages = 0; + int wireMessages = 0; + qsizetype textChars = 0; + qsizetype thinkingChars = 0; + qsizetype toolChars = 0; + qsizetype pinnedChars = 0; + int imageBlocks = 0; + int toolUseBlocks = 0; + int toolResultBlocks = 0; + int pinnedBlocks = 0; + int unsupportedBlocks = 0; + bool hasCompletionContext = false; + QStringList elided; + + QString summary() const; +}; + +Templates::ContextData assemble( + const std::vector &history, + const QString &systemPrompt, + const ContentLoader &loader, + const QVector &pinned = {}, + Manifest *outManifest = nullptr); + +} // namespace ContextAssembler +} // namespace QodeAssist diff --git a/sources/Session/ErrorInfo.hpp b/sources/Session/ErrorInfo.hpp new file mode 100644 index 0000000..e55386d --- /dev/null +++ b/sources/Session/ErrorInfo.hpp @@ -0,0 +1,61 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include + +#include + +namespace QodeAssist { + +enum class ErrorCategory { + Config, + Auth, + Network, + Provider, + Validation, + Tool, +}; + +struct ErrorInfo +{ + ErrorCategory category = ErrorCategory::Provider; + QString message; + QString providerDetail; + + bool isEmpty() const noexcept { return message.isEmpty(); } +}; + +[[nodiscard]] inline ErrorInfo makeError( + ErrorCategory category, QString message, QString providerDetail = QString()) +{ + return ErrorInfo{category, std::move(message), std::move(providerDetail)}; +} + +[[nodiscard]] inline ErrorCategory categorizeProviderError(const QString &raw) +{ + const QString text = raw.toLower(); + + const auto contains = [&text](const char *needle) { + return text.contains(QLatin1String(needle)); + }; + + if (contains("401") || contains("403") || contains("unauthorized") + || contains("forbidden") || contains("api key") || contains("apikey") + || contains("authentication") || contains("invalid token")) + return ErrorCategory::Auth; + + if (contains("timeout") || contains("timed out") || contains("connection") + || contains("could not resolve") || contains("unreachable") + || contains("network") || contains("ssl") || contains("refused")) + return ErrorCategory::Network; + + return ErrorCategory::Provider; +} + +} // namespace QodeAssist + +Q_DECLARE_METATYPE(QodeAssist::ErrorInfo) diff --git a/sources/Session/MessageSerializer.cpp b/sources/Session/MessageSerializer.cpp index 55582df..05091cd 100644 --- a/sources/Session/MessageSerializer.cpp +++ b/sources/Session/MessageSerializer.cpp @@ -26,6 +26,7 @@ constexpr auto kKindToolResult = "tool_result"; constexpr auto kKindStoredImage = "stored_image"; constexpr auto kKindStoredAttachment = "stored_attachment"; constexpr auto kKindFileEdit = "file_edit"; +constexpr auto kKindSkillInvocation = "skill_invocation"; QString roleToString(Message::Role role) { @@ -92,6 +93,10 @@ QJsonObject blockToJson(const LLMQore::ContentBlock &block) obj["type"] = kKindStoredAttachment; obj["fileName"] = sa->fileName(); obj["storedPath"] = sa->storedPath(); + } else if (auto *sk = dynamic_cast(&block)) { + obj["type"] = kKindSkillInvocation; + obj["skillName"] = sk->skillName(); + obj["body"] = sk->body(); } else if (auto *fe = dynamic_cast(&block)) { obj["type"] = kKindFileEdit; obj["editId"] = fe->editId(); @@ -147,6 +152,10 @@ std::unique_ptr blockFromJson(const QJsonObject &obj) return std::make_unique( obj.value("fileName").toString(), obj.value("storedPath").toString()); } + if (type == kKindSkillInvocation) { + return std::make_unique( + obj.value("skillName").toString(), obj.value("body").toString()); + } if (type == kKindFileEdit) { return std::make_unique( obj.value("editId").toString(), diff --git a/sources/Session/PluginBlocks.hpp b/sources/Session/PluginBlocks.hpp index ad03950..b14b15e 100644 --- a/sources/Session/PluginBlocks.hpp +++ b/sources/Session/PluginBlocks.hpp @@ -31,6 +31,24 @@ private: QString m_mediaType; }; +class CompletionContent : public LLMQore::ContentBlock +{ +public: + CompletionContent(QString prefix, QString suffix) + : m_prefix(std::move(prefix)) + , m_suffix(std::move(suffix)) + {} + + QString type() const override { return QStringLiteral("completion"); } + + QString prefix() const { return m_prefix; } + QString suffix() const { return m_suffix; } + +private: + QString m_prefix; + QString m_suffix; +}; + class StoredAttachmentContent : public LLMQore::ContentBlock { public: @@ -49,6 +67,24 @@ private: QString m_storedPath; }; +class SkillInvocationContent : public LLMQore::ContentBlock +{ +public: + SkillInvocationContent(QString skillName, QString body) + : m_skillName(std::move(skillName)) + , m_body(std::move(body)) + {} + + QString type() const override { return QStringLiteral("skill_invocation"); } + + QString skillName() const { return m_skillName; } + QString body() const { return m_body; } + +private: + QString m_skillName; + QString m_body; +}; + class FileEditContent : public LLMQore::ContentBlock { public: @@ -79,7 +115,6 @@ public: QString statusMessage() const { return m_statusMessage; } void setStatus(Status status) { m_status = status; } - void setStatusMessage(QString msg) { m_statusMessage = std::move(msg); } static QString statusToString(Status s) { diff --git a/sources/Session/ResponseEvent.hpp b/sources/Session/ResponseEvent.hpp index cca9951..e12fb3b 100644 --- a/sources/Session/ResponseEvent.hpp +++ b/sources/Session/ResponseEvent.hpp @@ -9,6 +9,8 @@ #include +#include "ErrorInfo.hpp" + namespace QodeAssist { namespace ResponseEvents { @@ -45,6 +47,7 @@ struct ToolCallEnd struct ToolResult { QString toolUseId; + QString name; QString text; bool isError = false; }; @@ -53,11 +56,14 @@ struct Usage { int inputTokens = 0; int outputTokens = 0; + int cachedTokens = 0; + int reasoningTokens = 0; }; struct Error { QString message; + ErrorCategory category = ErrorCategory::Provider; }; struct MessageStop @@ -115,34 +121,33 @@ public: return {Kind::ToolCallStart, ResponseEvents::ToolCallStart{std::move(id), std::move(name)}}; } - static ResponseEvent toolCallArgsDelta(QString id, QString jsonFragment) - { - return { - Kind::ToolCallArgsDelta, - ResponseEvents::ToolCallArgsDelta{std::move(id), std::move(jsonFragment)}}; - } - static ResponseEvent toolCallEnd(QString id, QJsonObject finalArgs) { return { Kind::ToolCallEnd, ResponseEvents::ToolCallEnd{std::move(id), std::move(finalArgs)}}; } - static ResponseEvent toolResult(QString toolUseId, QString text, bool isError = false) + static ResponseEvent toolResult( + QString toolUseId, QString name, QString text, bool isError = false) { return { Kind::ToolResult, - ResponseEvents::ToolResult{std::move(toolUseId), std::move(text), isError}}; + ResponseEvents::ToolResult{ + std::move(toolUseId), std::move(name), std::move(text), isError}}; } - static ResponseEvent usage(int inputTokens, int outputTokens) + static ResponseEvent usage( + int inputTokens, int outputTokens, int cachedTokens = 0, int reasoningTokens = 0) { - return {Kind::Usage, ResponseEvents::Usage{inputTokens, outputTokens}}; + return { + Kind::Usage, + ResponseEvents::Usage{inputTokens, outputTokens, cachedTokens, reasoningTokens}}; } - static ResponseEvent error(QString message) + static ResponseEvent error( + QString message, ErrorCategory category = ErrorCategory::Provider) { - return {Kind::Error, ResponseEvents::Error{std::move(message)}}; + return {Kind::Error, ResponseEvents::Error{std::move(message), category}}; } private: diff --git a/sources/Session/ResponseRouter.cpp b/sources/Session/ResponseRouter.cpp index a7639a1..0e8b703 100644 --- a/sources/Session/ResponseRouter.cpp +++ b/sources/Session/ResponseRouter.cpp @@ -79,7 +79,7 @@ void ResponseRouter::ensureAssistantOpen() if (m_assistantOpen && !m_inToolResults) return; if (m_history) - m_history->append(Message(Message::Role::Assistant)); + m_history->append(Message(Message::Role::Assistant, m_activeId)); emit event(ResponseEvent::messageStart()); m_assistantOpen = true; m_inToolResults = false; @@ -107,15 +107,19 @@ void ResponseRouter::onThinking( } void ResponseRouter::onToolStarted( - const LLMQore::RequestID &id, const QString &toolId, const QString &toolName) + const LLMQore::RequestID &id, + const QString &toolId, + const QString &toolName, + const QJsonObject &arguments) { if (id != m_activeId) return; ensureAssistantOpen(); if (m_history) m_history->appendBlockToLast( - std::make_unique(toolId, toolName)); + std::make_unique(toolId, toolName, arguments)); emit event(ResponseEvent::toolCallStart(toolId, toolName)); + emit event(ResponseEvent::toolCallEnd(toolId, arguments)); } void ResponseRouter::onToolResultReady( @@ -124,7 +128,6 @@ void ResponseRouter::onToolResultReady( const QString &toolName, const QString &result) { - Q_UNUSED(toolName); if (id != m_activeId) return; @@ -141,7 +144,7 @@ void ResponseRouter::onToolResultReady( m_assistantOpen = false; m_inToolResults = true; - emit event(ResponseEvent::toolResult(toolId, result, /*isError=*/false)); + emit event(ResponseEvent::toolResult(toolId, toolName, result, /*isError=*/false)); } void ResponseRouter::onFinalized( @@ -149,6 +152,13 @@ void ResponseRouter::onFinalized( { if (id != m_activeId) return; + if (info.usage) { + emit event(ResponseEvent::usage( + info.usage->promptTokens, + info.usage->completionTokens, + info.usage->cachedPromptTokens, + info.usage->reasoningTokens)); + } emit event(ResponseEvent::messageStop(info.stopReason)); endRequest(); } @@ -157,7 +167,7 @@ void ResponseRouter::onFailed(const LLMQore::RequestID &id, const QString &err) { if (id != m_activeId) return; - emit event(ResponseEvent::error(err)); + emit event(ResponseEvent::error(err, categorizeProviderError(err))); endRequest(); } diff --git a/sources/Session/ResponseRouter.hpp b/sources/Session/ResponseRouter.hpp index 1512ace..4954c40 100644 --- a/sources/Session/ResponseRouter.hpp +++ b/sources/Session/ResponseRouter.hpp @@ -6,6 +6,7 @@ #include +#include #include #include #include @@ -31,7 +32,6 @@ public: void endRequest(); bool isActive() const noexcept { return !m_activeId.isEmpty(); } - LLMQore::RequestID activeRequestId() const noexcept { return m_activeId; } signals: void event(const QodeAssist::ResponseEvent &ev); @@ -41,7 +41,10 @@ private slots: void onThinking( const LLMQore::RequestID &id, const QString &thinking, const QString &signature); void onToolStarted( - const LLMQore::RequestID &id, const QString &toolId, const QString &toolName); + const LLMQore::RequestID &id, + const QString &toolId, + const QString &toolName, + const QJsonObject &arguments); void onToolResultReady( const LLMQore::RequestID &id, const QString &toolId, diff --git a/sources/Session/Session.cpp b/sources/Session/Session.cpp index 83bf52d..197199f 100644 --- a/sources/Session/Session.cpp +++ b/sources/Session/Session.cpp @@ -10,13 +10,10 @@ #include #include -#include - #include "Agent.hpp" #include "AgentConfig.hpp" #include "ContextData.hpp" #include "Message.hpp" -#include "PluginBlocks.hpp" #include "PromptTemplate.hpp" #include "Provider.hpp" #include "ResponseRouter.hpp" @@ -26,26 +23,10 @@ namespace QodeAssist { namespace { -QString roleToLegacyString(Message::Role role) -{ - switch (role) { - case Message::Role::System: return QStringLiteral("system"); - case Message::Role::User: return QStringLiteral("user"); - case Message::Role::Assistant: return QStringLiteral("assistant"); - } - return QStringLiteral("user"); -} +[[maybe_unused]] const int kErrorInfoMetaTypeId = qRegisterMetaType(); } // namespace -Session::Session(QObject *parent) - : QObject(parent) - , m_history(new ConversationHistory(this)) - , m_systemPrompt(new SystemPromptBuilder(this)) -{ - m_invalidReason = QStringLiteral("Session: no agent attached"); -} - Session::Session(Agent *agent, QObject *parent) : Session(agent, /*externalHistory=*/nullptr, parent) {} @@ -81,14 +62,12 @@ Session::Session(Agent *agent, ConversationHistory *externalHistory, QObject *pa m_router = new ResponseRouter(client, m_history, this); connect(m_router, &ResponseRouter::event, this, &Session::onRouterEvent); - - m_systemPrompt->setLayer(QStringLiteral("agent.role"), m_agent->config().role); } Session::~Session() { if (isInFlight()) - cancel(); + teardownInFlight(); } bool Session::isValid() const noexcept @@ -106,6 +85,17 @@ bool Session::isInFlight() const noexcept return !m_inFlight.isEmpty(); } +const ErrorInfo &Session::lastError() const noexcept +{ + return m_lastError; +} + +LLMQore::BaseClient *Session::client() const noexcept +{ + auto *provider = m_agent ? m_agent->provider() : nullptr; + return provider ? provider->client() : nullptr; +} + void Session::setContentLoader(ContentLoader loader) { m_contentLoader = std::move(loader); @@ -116,36 +106,36 @@ void Session::setContextBindings(Templates::ContextRenderer::Bindings bindings) m_contextBindings = std::move(bindings); } -QString Session::renderAgentContext() const +void Session::pinContext(const QString &id, PinnedProvider provider) { - if (!m_agent) - return {}; - const auto &cfg = m_agent->config(); - if (cfg.context.isEmpty()) - return {}; - QString err; - QString rendered = Templates::ContextRenderer::render(cfg.context, m_contextBindings, &err); - if (!err.isEmpty()) - qWarning("[QodeAssist] agent.context render failed: %s", qUtf8Printable(err)); - return rendered; + if (!provider) { + unpinContext(id); + return; + } + for (auto &entry : m_pinnedProviders) { + if (entry.first == id) { + entry.second = std::move(provider); + return; + } + } + m_pinnedProviders.emplace_back(id, std::move(provider)); } -LLMQore::RequestID Session::sendText(const QString &text) +void Session::unpinContext(const QString &id) { - std::vector> blocks; - if (!text.isEmpty()) - blocks.push_back(std::make_unique(text)); - return send(std::move(blocks)); + std::erase_if(m_pinnedProviders, [&id](const auto &entry) { return entry.first == id; }); } -LLMQore::RequestID Session::send( - std::vector> userBlocks, - std::optional toolsOverride) +LLMQore::RequestID Session::send(std::vector> userBlocks) { - if (!isValid() || userBlocks.empty()) + if (!isValid()) { + m_lastError = makeError(ErrorCategory::Config, invalidReason()); return {}; - if (!m_history) + } + if (userBlocks.empty() || !m_history) { + m_lastError = makeError(ErrorCategory::Validation, QStringLiteral("Session: nothing to send")); return {}; + } if (isInFlight()) cancel(); @@ -155,10 +145,32 @@ LLMQore::RequestID Session::send( msg.appendBlock(std::move(b)); m_history->append(std::move(msg)); - return dispatch(toolsOverride); + return dispatch(); +} + +QVector Session::materializePinned() const +{ + QVector pinned; + pinned.reserve(static_cast(m_pinnedProviders.size())); + for (const auto &entry : m_pinnedProviders) { + const QString text = entry.second(); + if (!text.isEmpty()) + pinned.append({entry.first, text}); + } + return pinned; } void Session::cancel() +{ + if (m_inFlight.isEmpty()) + return; + + const auto id = m_inFlight; + teardownInFlight(); + emit cancelled(id); +} + +void Session::teardownInFlight() { if (m_inFlight.isEmpty()) return; @@ -169,30 +181,59 @@ void Session::cancel() m_router->endRequest(); if (m_agent && m_agent->provider()) m_agent->provider()->cancelRequest(id); - emit failed(id, QStringLiteral("Cancelled by user")); } -LLMQore::RequestID Session::sendCompletion(Templates::ContextData ctx) +LLMQore::RequestID Session::dispatch() { - if (!isValid()) - return {}; - if (isInFlight()) - cancel(); - - if (m_history) - m_history->clear(); - - auto *provider = m_agent->provider(); - auto *tmpl = m_agent->promptTemplate(); const auto &cfg = m_agent->config(); - QJsonObject payload{{QStringLiteral("model"), cfg.model}}; - if (!provider->prepareRequest(payload, tmpl, ctx, /*tools=*/false, /*thinking=*/false)) - return {}; + if (cfg.systemPrompt.isEmpty()) { + m_systemPrompt->clearLayer(QStringLiteral("agent.system")); + } else { + QString renderErr; + const QString renderedContext = Templates::ContextRenderer::render( + cfg.systemPrompt, m_contextBindings, &renderErr); + if (!renderErr.isEmpty()) { + m_lastError = makeError( + ErrorCategory::Validation, + QStringLiteral("Agent '%1' system_prompt render failed: %2") + .arg(cfg.name, renderErr)); + qWarning("[QodeAssist] %s", qUtf8Printable(m_lastError.message)); + return {}; + } + if (renderedContext.isEmpty()) + m_systemPrompt->clearLayer(QStringLiteral("agent.system")); + else + m_systemPrompt->setLayer( + QStringLiteral("agent.system"), renderedContext, SystemPromptBuilder::kAgentPriority); + } - const auto id = provider->sendRequest(QUrl(provider->url()), payload, cfg.endpoint); - if (id.isEmpty()) + return dispatchContext(assembleContext(), cfg.enableTools); +} + +LLMQore::RequestID Session::dispatchContext(const Templates::ContextData &ctx, bool tools) +{ + m_lastError = {}; + + auto *provider = m_agent->provider(); + const auto &cfg = m_agent->config(); + + QString prepareErr; + const QJsonObject payload = buildPayload(ctx, tools, &prepareErr); + if (payload.isEmpty()) { + m_lastError = makeError(ErrorCategory::Validation, prepareErr, prepareErr); return {}; + } + + QString endpoint = cfg.endpoint; + endpoint.replace(QStringLiteral("${MODEL}"), cfg.model); + const auto id = provider->sendRequest(QUrl(provider->url()), payload, endpoint); + if (id.isEmpty()) { + m_lastError = makeError( + ErrorCategory::Provider, + QStringLiteral("Provider '%1' failed to start the request").arg(provider->name())); + return {}; + } m_inFlight = id; if (m_router) @@ -201,165 +242,29 @@ LLMQore::RequestID Session::sendCompletion(Templates::ContextData ctx) return id; } -LLMQore::RequestID Session::dispatch(std::optional toolsOverride) +QJsonObject Session::buildPayload( + const Templates::ContextData &ctx, bool tools, QString *errOut) const { auto *provider = m_agent->provider(); auto *tmpl = m_agent->promptTemplate(); const auto &cfg = m_agent->config(); - const QString renderedContext = renderAgentContext(); - if (renderedContext.isEmpty()) - m_systemPrompt->clearLayer(QStringLiteral("agent.context")); - else - m_systemPrompt->setLayer(QStringLiteral("agent.context"), renderedContext); - - Templates::ContextData ctx = toLegacyContext(); QJsonObject payload{{QStringLiteral("model"), cfg.model}}; - - const bool tools = toolsOverride.value_or(cfg.enableTools); - if (!provider->prepareRequest(payload, tmpl, ctx, tools, cfg.enableThinking)) + QString prepareErr; + if (!provider->prepareRequest(payload, tmpl, ctx, tools, &prepareErr)) { + if (errOut) + *errOut = prepareErr; return {}; - - const auto id = provider->sendRequest(QUrl(provider->url()), payload, cfg.endpoint); - if (id.isEmpty()) - return {}; - - m_inFlight = id; - if (m_router) - m_router->beginRequest(id); - emit started(id); - return id; + } + return payload; } -Templates::ContextData Session::toLegacyContext() const +Templates::ContextData Session::assembleContext() const { if (!m_history) return {}; - return buildLegacyContext(m_history->messages(), m_systemPrompt->compose(), m_contentLoader); -} - -Templates::ContextData Session::buildLegacyContext( - const std::vector &history, - const QString &systemPrompt, - const ContentLoader &loader) -{ - using Templates::ContentBlockEntry; - using Templates::ContextData; - using LegacyMessage = Templates::Message; - - ContextData ctx; - if (!systemPrompt.isEmpty()) - ctx.systemPrompt = systemPrompt; - - QSet resolvedToolUseIds; - QSet declaredToolUseIds; - for (const auto &m : history) { - for (const auto &blockPtr : m.blocks()) { - if (auto *tr = dynamic_cast(blockPtr.get())) - resolvedToolUseIds.insert(tr->toolUseId()); - if (auto *tu = dynamic_cast(blockPtr.get())) - declaredToolUseIds.insert(tu->id()); - } - } - - QVector hist; - - for (const auto &m : history) { - QVector blockEntries; - - for (const auto &blockPtr : m.blocks()) { - auto *block = blockPtr.get(); - if (!block) - continue; - - if (auto *t = dynamic_cast(block)) { - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::Text; - e.text = t->text(); - blockEntries.append(std::move(e)); - } else if (auto *img = dynamic_cast(block)) { - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::Image; - e.imageData = img->data(); - e.mediaType = img->mediaType(); - e.isImageUrl - = (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url); - blockEntries.append(std::move(e)); - } else if (auto *si = dynamic_cast(block)) { - if (!loader) - continue; - const QString base64 = loader(si->storedPath()); - if (base64.isEmpty()) - continue; - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::Image; - e.imageData = base64; - e.mediaType = si->mediaType(); - e.isImageUrl = false; - blockEntries.append(std::move(e)); - } else if (auto *sa = dynamic_cast(block)) { - if (!loader) - continue; - const QString text = loader(sa->storedPath()); - if (text.isEmpty()) - continue; - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::Text; - e.text = QStringLiteral("File: %1\n```\n%2\n```") - .arg(sa->fileName(), text); - blockEntries.append(std::move(e)); - } else if (auto *th = dynamic_cast(block)) { - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::Thinking; - e.thinking = th->thinking(); - e.signature = th->signature(); - blockEntries.append(std::move(e)); - } else if (auto *rth = dynamic_cast(block)) { - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::RedactedThinking; - e.signature = rth->signature(); - blockEntries.append(std::move(e)); - } else if (auto *tu = dynamic_cast(block)) { - if (!resolvedToolUseIds.contains(tu->id())) - continue; - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::ToolUse; - e.toolUseId = tu->id(); - e.toolName = tu->name(); - e.toolInput = tu->input(); - blockEntries.append(std::move(e)); - } else if (auto *tr = dynamic_cast(block)) { - if (!declaredToolUseIds.contains(tr->toolUseId())) - continue; - ContentBlockEntry e; - e.kind = ContentBlockEntry::Kind::ToolResult; - e.toolUseId = tr->toolUseId(); - e.result = tr->result(); - blockEntries.append(std::move(e)); - } - } - - if (blockEntries.isEmpty()) - continue; - - const bool hasNonThinking = std::any_of( - blockEntries.begin(), blockEntries.end(), [](const ContentBlockEntry &e) { - return e.kind != ContentBlockEntry::Kind::Thinking - && e.kind != ContentBlockEntry::Kind::RedactedThinking; - }); - if (!hasNonThinking) - continue; - - LegacyMessage lm; - lm.role = roleToLegacyString(m.role()); - lm.blocks = std::move(blockEntries); - hist.append(std::move(lm)); - } - - if (!hist.isEmpty()) - ctx.history = std::move(hist); - - return ctx; + return ContextAssembler::assemble( + m_history->messages(), m_systemPrompt->compose(), m_contentLoader, materializePinned()); } void Session::onRouterEvent(const ResponseEvent &ev) @@ -378,9 +283,11 @@ void Session::onRouterEvent(const ResponseEvent &ev) } else if (ev.kind() == ResponseEvent::Kind::Error) { const auto *err = ev.as(); const QString msg = err ? err->message : QStringLiteral("unknown error"); + const ErrorCategory category = err ? err->category : ErrorCategory::Provider; + m_lastError = makeError(category, msg, msg); const auto id = m_inFlight; m_inFlight.clear(); - emit failed(id, msg); + emit failed(id, m_lastError); } } diff --git a/sources/Session/Session.hpp b/sources/Session/Session.hpp index 7f2e49b..3c09c56 100644 --- a/sources/Session/Session.hpp +++ b/sources/Session/Session.hpp @@ -14,12 +14,13 @@ #include #include -#include #include -#include +#include #include +#include "ContextAssembler.hpp" #include "ConversationHistory.hpp" +#include "ErrorInfo.hpp" #include "ResponseEvent.hpp" namespace QodeAssist { @@ -33,8 +34,6 @@ class Session : public QObject Q_OBJECT Q_DISABLE_COPY_MOVE(Session) public: - explicit Session(QObject *parent = nullptr); - Session( Agent *agent, ConversationHistory *externalHistory = nullptr, @@ -47,26 +46,25 @@ public: bool isValid() const noexcept; QString invalidReason() const; bool isInFlight() const noexcept; + const ErrorInfo &lastError() const noexcept; - using ContentLoader = std::function; + using ContentLoader = ContextAssembler::ContentLoader; void setContentLoader(ContentLoader loader); + using PinnedProvider = std::function; + void pinContext(const QString &id, PinnedProvider provider); + void unpinContext(const QString &id); + Agent *agent() noexcept { return m_agent; } ConversationHistory *history() const noexcept { return m_history; } SystemPromptBuilder *systemPrompt() const noexcept { return m_systemPrompt; } + LLMQore::BaseClient *client() const noexcept; + void setContextBindings(Templates::ContextRenderer::Bindings bindings); - QString renderAgentContext() const; + LLMQore::RequestID send(std::vector> userBlocks); - LLMQore::RequestID send( - std::vector> userBlocks, - std::optional toolsOverride = std::nullopt); - - LLMQore::RequestID sendText(const QString &text); - - LLMQore::RequestID sendCompletion(Templates::ContextData ctx); - void cancel(); signals: @@ -74,14 +72,19 @@ signals: void started(const LLMQore::RequestID &id); void finished(const LLMQore::RequestID &id, const QString &stopReason); - void failed(const LLMQore::RequestID &id, const QString &error); + void failed(const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error); + void cancelled(const LLMQore::RequestID &id); private slots: void onRouterEvent(const QodeAssist::ResponseEvent &ev); private: - LLMQore::RequestID dispatch(std::optional toolsOverride = std::nullopt); - Templates::ContextData toLegacyContext() const; + LLMQore::RequestID dispatch(); + LLMQore::RequestID dispatchContext(const Templates::ContextData &ctx, bool tools); + void teardownInFlight(); + Templates::ContextData assembleContext() const; + QVector materializePinned() const; + QJsonObject buildPayload(const Templates::ContextData &ctx, bool tools, QString *errOut) const; Agent *m_agent = nullptr; // child if non-null QPointer m_history; // child if internal, external otherwise @@ -90,17 +93,11 @@ private: LLMQore::RequestID m_inFlight; QString m_invalidReason; + ErrorInfo m_lastError; Templates::ContextRenderer::Bindings m_contextBindings; - -public: - static Templates::ContextData buildLegacyContext( - const std::vector &history, - const QString &systemPrompt, - const ContentLoader &loader = ContentLoader{}); - -private: ContentLoader m_contentLoader; + std::vector> m_pinnedProviders; }; } // namespace QodeAssist diff --git a/sources/Session/SessionManager.cpp b/sources/Session/SessionManager.cpp index 5af412f..a913ecb 100644 --- a/sources/Session/SessionManager.cpp +++ b/sources/Session/SessionManager.cpp @@ -4,16 +4,17 @@ #include "SessionManager.hpp" +#include +#include + #include "Agent.hpp" #include "AgentFactory.hpp" +#include "ConversationHistory.hpp" #include "Session.hpp" +#include "SystemPromptBuilder.hpp" namespace QodeAssist { -SessionManager::SessionManager(QObject *parent) - : QObject(parent) -{} - SessionManager::SessionManager(AgentFactory *agentFactory, QObject *parent) : QObject(parent) , m_agentFactory(agentFactory) @@ -21,14 +22,6 @@ SessionManager::SessionManager(AgentFactory *agentFactory, QObject *parent) SessionManager::~SessionManager() = default; -Session *SessionManager::createSession() -{ - auto *session = new Session(this); - m_sessions.append(session); - emit sessionCreated(session); - return session; -} - Session *SessionManager::createSession(const QString &agentName, QString *errorOut) { return createSession(agentName, /*externalHistory=*/nullptr, errorOut); @@ -66,6 +59,64 @@ Session *SessionManager::createSession( return session; } +Session *SessionManager::acquire(const QString &agentName, QString *errorOut) +{ + auto &bucket = m_pool[agentName]; + while (!bucket.isEmpty()) { + QPointer pooled = bucket.takeLast(); + if (pooled && pooled->isValid()) { + resetSession(pooled); + m_sessions.append(pooled); + return pooled.data(); + } + if (pooled) + pooled->deleteLater(); + } + + return createSession(agentName, /*externalHistory=*/nullptr, errorOut); +} + +void SessionManager::release(Session *session) +{ + if (!session) + return; + + const int idx = m_sessions.indexOf(session); + if (idx < 0) + return; + m_sessions.removeAt(idx); + + if (session->isInFlight()) + session->cancel(); + + session->disconnect(); + resetSession(session); + + const QString agentName + = session->agent() ? session->agent()->config().name : QString(); + QList> &bucket = m_pool[agentName]; + if (agentName.isEmpty() || bucket.size() >= kMaxPooledPerAgent) { + emit sessionRemoved(session); + session->deleteLater(); + } else { + bucket.append(session); + } +} + +void SessionManager::resetSession(Session *session) +{ + if (!session) + return; + if (auto *history = session->history()) + history->clear(); + if (auto *systemPrompt = session->systemPrompt()) + systemPrompt->clear(); + if (auto *client = session->client()) { + if (auto *tools = client->tools()) + tools->removeAllTools(); + } +} + void SessionManager::removeSession(Session *session) { if (!session) @@ -83,24 +134,4 @@ void SessionManager::removeSession(Session *session) session->deleteLater(); } -QList SessionManager::sessions() const -{ - QList out; - out.reserve(m_sessions.size()); - for (const auto &p : m_sessions) { - if (p) - out.append(p.data()); - } - return out; -} - -void SessionManager::cancelAll() -{ - const auto snapshot = m_sessions; - for (const auto &p : snapshot) { - if (p && p->isInFlight()) - p->cancel(); - } -} - } // namespace QodeAssist diff --git a/sources/Session/SessionManager.hpp b/sources/Session/SessionManager.hpp index c36b546..0e19e73 100644 --- a/sources/Session/SessionManager.hpp +++ b/sources/Session/SessionManager.hpp @@ -4,11 +4,14 @@ #pragma once +#include #include #include #include #include +#include "ToolContributorRegistry.hpp" + namespace QodeAssist { class AgentFactory; @@ -20,14 +23,10 @@ class SessionManager : public QObject Q_OBJECT Q_DISABLE_COPY_MOVE(SessionManager) public: - explicit SessionManager(QObject *parent = nullptr); - - SessionManager(AgentFactory *agentFactory, QObject *parent = nullptr); + explicit SessionManager(AgentFactory *agentFactory, QObject *parent = nullptr); ~SessionManager() override; - Session *createSession(); - Session *createSession(const QString &agentName, QString *errorOut = nullptr); Session *createSession( @@ -35,19 +34,27 @@ public: ConversationHistory *externalHistory, QString *errorOut = nullptr); + Session *acquire(const QString &agentName, QString *errorOut = nullptr); + void release(Session *session); + void removeSession(Session *session); - QList sessions() const; - - void cancelAll(); + ToolContributorRegistry &toolContributors() noexcept { return m_toolContributors; } + const ToolContributorRegistry &toolContributors() const noexcept { return m_toolContributors; } signals: void sessionCreated(Session *session); void sessionRemoved(Session *session); private: + void resetSession(Session *session); + + static constexpr int kMaxPooledPerAgent = 2; + QPointer m_agentFactory; QList> m_sessions; + QHash>> m_pool; + ToolContributorRegistry m_toolContributors; }; } // namespace QodeAssist diff --git a/sources/Session/SystemPromptBuilder.cpp b/sources/Session/SystemPromptBuilder.cpp index 1573695..05fb023 100644 --- a/sources/Session/SystemPromptBuilder.cpp +++ b/sources/Session/SystemPromptBuilder.cpp @@ -4,30 +4,34 @@ #include "SystemPromptBuilder.hpp" +#include + namespace QodeAssist { SystemPromptBuilder::SystemPromptBuilder(QObject *parent) : QObject(parent) {} -void SystemPromptBuilder::setLayer(const QString &name, const QString &text) +void SystemPromptBuilder::setLayer(const QString &name, const QString &text, int priority) { - for (auto &pair : m_layers) { - if (pair.first == name) { - if (pair.second == text) return; - pair.second = text; + for (auto &layer : m_layers) { + if (layer.name == name) { + if (layer.text == text && layer.priority == priority) + return; + layer.text = text; + layer.priority = priority; emit layersChanged(); return; } } - m_layers.append({name, text}); + m_layers.append({name, text, priority}); emit layersChanged(); } void SystemPromptBuilder::clearLayer(const QString &name) { for (auto it = m_layers.begin(); it != m_layers.end(); ++it) { - if (it->first == name) { + if (it->name == name) { m_layers.erase(it); emit layersChanged(); return; @@ -44,8 +48,8 @@ void SystemPromptBuilder::clear() QString SystemPromptBuilder::layer(const QString &name) const { - for (const auto &pair : m_layers) { - if (pair.first == name) return pair.second; + for (const auto &l : m_layers) { + if (l.name == name) return l.text; } return {}; } @@ -54,17 +58,22 @@ QStringList SystemPromptBuilder::layerNames() const { QStringList out; out.reserve(m_layers.size()); - for (const auto &pair : m_layers) out.append(pair.first); + for (const auto &l : m_layers) out.append(l.name); return out; } QString SystemPromptBuilder::compose(const QString &separator) const { + QVector ordered = m_layers; + std::stable_sort( + ordered.begin(), ordered.end(), + [](const Layer &a, const Layer &b) { return a.priority < b.priority; }); + QStringList parts; - parts.reserve(m_layers.size()); - for (const auto &pair : m_layers) { - if (!pair.second.isEmpty()) - parts.append(pair.second); + parts.reserve(ordered.size()); + for (const auto &l : ordered) { + if (!l.text.isEmpty()) + parts.append(l.text); } return parts.join(separator); } diff --git a/sources/Session/SystemPromptBuilder.hpp b/sources/Session/SystemPromptBuilder.hpp index 85c414a..e1c271d 100644 --- a/sources/Session/SystemPromptBuilder.hpp +++ b/sources/Session/SystemPromptBuilder.hpp @@ -15,9 +15,12 @@ class SystemPromptBuilder : public QObject { Q_OBJECT public: + static constexpr int kAgentPriority = 0; + static constexpr int kDefaultPriority = 100; + explicit SystemPromptBuilder(QObject *parent = nullptr); - void setLayer(const QString &name, const QString &text); + void setLayer(const QString &name, const QString &text, int priority = kDefaultPriority); void clearLayer(const QString &name); void clear(); @@ -31,7 +34,14 @@ signals: void layersChanged(); private: - QVector> m_layers; + struct Layer + { + QString name; + QString text; + int priority = kDefaultPriority; + }; + + QVector m_layers; }; } // namespace QodeAssist diff --git a/sources/Session/ToolContributorRegistry.hpp b/sources/Session/ToolContributorRegistry.hpp new file mode 100644 index 0000000..748fa82 --- /dev/null +++ b/sources/Session/ToolContributorRegistry.hpp @@ -0,0 +1,41 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +namespace LLMQore { +class ToolsManager; +} + +namespace QodeAssist { + +class ToolContributorRegistry +{ +public: + using Contributor = std::function; + + void add(Contributor contributor) + { + if (contributor) + m_contributors.push_back(std::move(contributor)); + } + + void contribute(LLMQore::ToolsManager *tools) const + { + if (!tools) + return; + for (const auto &contributor : m_contributors) + contributor(tools); + } + + void clear() { m_contributors.clear(); } + +private: + std::vector m_contributors; +}; + +} // namespace QodeAssist diff --git a/sources/agents/Agent.cpp b/sources/agents/Agent.cpp index dc062a8..dc5eebd 100644 --- a/sources/agents/Agent.cpp +++ b/sources/agents/Agent.cpp @@ -34,9 +34,8 @@ QString AgentConfig::validate(const AgentConfig &config) return QStringLiteral("Agent config '%1' has no model").arg(config.name); if (config.endpoint.isEmpty()) return QStringLiteral("Agent config '%1' has no endpoint").arg(config.name); - if (config.messageFormat.isEmpty()) { - return QStringLiteral("Agent config '%1' has no [template].message_format") - .arg(config.name); + if (config.body.isEmpty()) { + return QStringLiteral("Agent config '%1' has no [body]").arg(config.name); } return {}; } @@ -56,6 +55,9 @@ Agent::Agent(AgentConfig config, Providers::Provider *providerOwned, QObject *pa return; } m_provider->setParent(this); + m_provider->setPromptCaching( + m_config.cachePrompt, m_config.cacheTtl == QLatin1StringView{"1h"}, + m_config.cacheBreakpoints); QString tmplErr; m_promptTemplate = JsonPromptTemplate::fromConfig(m_config, &tmplErr); diff --git a/sources/agents/AgentConfig.hpp b/sources/agents/AgentConfig.hpp index 5d97700..d506159 100644 --- a/sources/agents/AgentConfig.hpp +++ b/sources/agents/AgentConfig.hpp @@ -19,7 +19,7 @@ struct AgentConfig QString providerInstance; QString model; QString endpoint; - QString role; + QString systemPrompt; QStringList tags; struct Match @@ -39,17 +39,16 @@ struct AgentConfig bool enableThinking = false; bool enableTools = false; + bool cachePrompt = false; + QString cacheTtl; + QStringList cacheBreakpoints; - QString messageFormat; - QJsonObject sampling; - QJsonObject thinking; - QString context; + QJsonObject body; QString extendsName; bool abstract = false; bool hidden = false; QString sourcePath; - bool overridesBundled = false; bool isUserSource() const { return !sourcePath.startsWith(QLatin1StringView{":/"}); } static QString validate(const AgentConfig &config); diff --git a/sources/agents/AgentFactory.cpp b/sources/agents/AgentFactory.cpp index 9f88582..eca95e3 100644 --- a/sources/agents/AgentFactory.cpp +++ b/sources/agents/AgentFactory.cpp @@ -4,26 +4,91 @@ #include "AgentFactory.hpp" +#include #include +#include #include #include +#include #include "Agent.hpp" #include "AgentLoader.hpp" +#include "Logger.hpp" #include "Provider.hpp" #include "ProviderFactory.hpp" -#include "Logger.hpp" -#include "ProviderSecretsStore.hpp" #include "ProviderInstance.hpp" #include "ProviderInstanceFactory.hpp" +#include "ProviderSecretsStore.hpp" -static inline void initAgentsResource() { Q_INIT_RESOURCE(agents); } +static inline void initAgentsResource() +{ + Q_INIT_RESOURCE(agents); +} namespace { Q_LOGGING_CATEGORY(agentFactoryLog, "qodeassist.agentfactory") -QString agentQrcPrefix() { return QStringLiteral(":/agents"); } +QString agentQrcPrefix() +{ + return QStringLiteral(":/agents"); +} + +constexpr auto kModelOverrideGroup = "QodeAssist/AgentModelOverrides"; + +Utils::Key modelOverrideKey(const QString &name) +{ + return Utils::Key( + QStringLiteral("%1/%2").arg(QLatin1StringView(kModelOverrideGroup), name).toUtf8()); +} + +QString readModelOverride(const QString &name) +{ + auto *s = Core::ICore::settings(); + if (!s) + return {}; + return s->value(modelOverrideKey(name)).toString(); +} + +void writeModelOverride(const QString &name, const QString &model) +{ + auto *s = Core::ICore::settings(); + if (!s) + return; + if (model.isEmpty()) + s->remove(modelOverrideKey(name)); + else + s->setValue(modelOverrideKey(name), model); + s->sync(); +} + +constexpr auto kProviderOverrideGroup = "QodeAssist/AgentProviderOverrides"; + +Utils::Key providerOverrideKey(const QString &name) +{ + return Utils::Key( + QStringLiteral("%1/%2").arg(QLatin1StringView(kProviderOverrideGroup), name).toUtf8()); +} + +QString readProviderOverride(const QString &name) +{ + auto *s = Core::ICore::settings(); + if (!s) + return {}; + return s->value(providerOverrideKey(name)).toString(); +} + +void writeProviderOverride(const QString &name, const QString &providerInstance) +{ + auto *s = Core::ICore::settings(); + if (!s) + return; + if (providerInstance.isEmpty()) + s->remove(providerOverrideKey(name)); + else + s->setValue(providerOverrideKey(name), providerInstance); + s->sync(); +} } // namespace namespace QodeAssist { @@ -44,8 +109,12 @@ AgentFactory::~AgentFactory() = default; QString AgentFactory::userAgentsDir() { - return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/agents")) - .toFSPathString(); + return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/agents")).toFSPathString(); +} + +QString AgentFactory::userConfigDir() +{ + return Core::ICore::userResourcePath(QStringLiteral("qodeassist")).toFSPathString(); } void AgentFactory::reload() @@ -53,6 +122,7 @@ void AgentFactory::reload() Q_ASSERT(thread() == QThread::currentThread()); clear(); + QDir().mkpath(userAgentsDir()); auto result = Agents::AgentLoader::load(agentQrcPrefix(), userAgentsDir()); for (const QString &err : result.errors) LOG_MESSAGE(QString("[Agents] error: %1").arg(err)); @@ -66,8 +136,18 @@ void AgentFactory::reload() LOG_MESSAGE(QString("[Agents] Loaded: %1").arg(cfg.name)); registerConfig(std::move(cfg)); } - m_errors = std::move(result.errors); - m_warnings = std::move(result.warnings); + for (auto &cfg : m_configs) { + m_baseModelByName.insert(cfg.name, cfg.model); + const QString overrideModel = readModelOverride(cfg.name); + if (!overrideModel.isEmpty()) + cfg.model = overrideModel; + + m_baseProviderByName.insert(cfg.name, cfg.providerInstance); + const QString overrideProvider = readProviderOverride(cfg.name); + if (!overrideProvider.isEmpty()) + cfg.providerInstance = overrideProvider; + } + emit agentsChanged(); } void AgentFactory::registerConfig(AgentConfig config) @@ -101,7 +181,8 @@ QStringList AgentFactory::configNames() const QStringList out; out.reserve(static_cast(m_configs.size())); for (const auto &c : m_configs) { - if (c.hidden) continue; + if (c.hidden) + continue; out.append(c.name); } return out; @@ -124,18 +205,16 @@ Providers::Provider *buildProviderForAgent( } return nullptr; } - const Providers::ProviderInstance *inst - = instanceFactory->instanceByName(cfg.providerInstance); + const Providers::ProviderInstance *inst = instanceFactory->instanceByName(cfg.providerInstance); if (!inst) { if (errorOut) { - *errorOut = QStringLiteral( - "Agent '%1' references unknown provider instance '%2'") + *errorOut = QStringLiteral("Agent '%1' references unknown provider instance '%2'") .arg(cfg.name, cfg.providerInstance); } return nullptr; } - const QString validation = Providers::ProviderInstance::validate( - *inst, Providers::ProviderFactory::knownNames()); + const QString validation + = Providers::ProviderInstance::validate(*inst, Providers::ProviderFactory::knownNames()); if (!validation.isEmpty()) { if (errorOut) *errorOut = validation; @@ -165,8 +244,8 @@ Agent *AgentFactory::create(const QString &name, QObject *parent, QString *error *errorOut = QStringLiteral("Agent '%1' is not registered").arg(name); return nullptr; } - Providers::Provider *provider = buildProviderForAgent( - *cfg, m_instanceFactory.data(), m_secrets.data(), errorOut); + Providers::Provider *provider + = buildProviderForAgent(*cfg, m_instanceFactory.data(), m_secrets.data(), errorOut); if (!provider) return nullptr; auto agent = std::make_unique(*cfg, provider, /*parent=*/nullptr); @@ -179,23 +258,24 @@ Agent *AgentFactory::create(const QString &name, QObject *parent, QString *error return agent.release(); } -Agent *AgentFactory::createFromFile( - const QString &tomlPath, QObject *parent, QString *errorOut) const +Agent *AgentFactory::createFromFile(const QString &tomlPath, QObject *parent, QString *errorOut) const { QString parseErr; QStringList warnings; - auto cfgOpt = Agents::AgentLoader::parseFile(tomlPath, &parseErr, &warnings); + auto cfgOpt = Agents::AgentLoader::parseFile(tomlPath, agentQrcPrefix(), &parseErr, &warnings); if (!cfgOpt) { - if (errorOut) *errorOut = parseErr; + if (errorOut) + *errorOut = parseErr; return nullptr; } - Providers::Provider *provider = buildProviderForAgent( - *cfgOpt, m_instanceFactory.data(), m_secrets.data(), errorOut); + Providers::Provider *provider + = buildProviderForAgent(*cfgOpt, m_instanceFactory.data(), m_secrets.data(), errorOut); if (!provider) return nullptr; auto agent = std::make_unique(std::move(*cfgOpt), provider, /*parent=*/nullptr); if (!agent->isValid()) { - if (errorOut) *errorOut = agent->invalidReason(); + if (errorOut) + *errorOut = agent->invalidReason(); return nullptr; } agent->setParent(parent); @@ -207,8 +287,8 @@ void AgentFactory::clear() Q_ASSERT(thread() == QThread::currentThread()); m_configs.clear(); m_indexByName.clear(); - m_errors.clear(); - m_warnings.clear(); + m_baseModelByName.clear(); + m_baseProviderByName.clear(); } Providers::ProviderInstanceFactory *AgentFactory::instanceFactory() const noexcept @@ -216,9 +296,69 @@ Providers::ProviderInstanceFactory *AgentFactory::instanceFactory() const noexce return m_instanceFactory.data(); } -Providers::ProviderSecretsStore *AgentFactory::secretsStore() const noexcept +bool AgentFactory::setAgentModelOverride(const QString &name, const QString &model, QString *error) { - return m_secrets.data(); + Q_ASSERT(thread() == QThread::currentThread()); + + const auto it = m_indexByName.constFind(name); + if (it == m_indexByName.constEnd()) { + if (error) + *error = QStringLiteral("Agent '%1' is not registered").arg(name); + return false; + } + AgentConfig &cfg = m_configs[it.value()]; + const QString effective = model.isEmpty() ? m_baseModelByName.value(name, cfg.model) : model; + if (cfg.model == effective && readModelOverride(name) == model) + return true; + + writeModelOverride(name, model); + cfg.model = effective; + emit agentModelChanged(name); + return true; +} + +QString AgentFactory::agentModelOverride(const QString &name) const +{ + return readModelOverride(name); +} + +void AgentFactory::clearAgentModelOverride(const QString &name) +{ + writeModelOverride(name, QString()); +} + +bool AgentFactory::setAgentProviderOverride( + const QString &name, const QString &providerInstance, QString *error) +{ + Q_ASSERT(thread() == QThread::currentThread()); + + const auto it = m_indexByName.constFind(name); + if (it == m_indexByName.constEnd()) { + if (error) + *error = QStringLiteral("Agent '%1' is not registered").arg(name); + return false; + } + AgentConfig &cfg = m_configs[it.value()]; + const QString effective = providerInstance.isEmpty() + ? m_baseProviderByName.value(name, cfg.providerInstance) + : providerInstance; + if (cfg.providerInstance == effective && readProviderOverride(name) == providerInstance) + return true; + + writeProviderOverride(name, providerInstance); + cfg.providerInstance = effective; + emit agentProviderChanged(name); + return true; +} + +QString AgentFactory::agentProviderOverride(const QString &name) const +{ + return readProviderOverride(name); +} + +void AgentFactory::clearAgentProviderOverride(const QString &name) +{ + writeProviderOverride(name, QString()); } } // namespace QodeAssist diff --git a/sources/agents/AgentFactory.hpp b/sources/agents/AgentFactory.hpp index 670c133..601a475 100644 --- a/sources/agents/AgentFactory.hpp +++ b/sources/agents/AgentFactory.hpp @@ -21,7 +21,7 @@ class Agent; namespace Providers { class ProviderInstanceFactory; class ProviderSecretsStore; -} +} // namespace Providers class AgentFactory : public QObject { @@ -37,6 +37,7 @@ public: void reload(); [[nodiscard]] static QString userAgentsDir(); + [[nodiscard]] static QString userConfigDir(); [[nodiscard]] const AgentConfig *configByName(const QString &name) const; [[nodiscard]] QStringList configNames() const; @@ -47,20 +48,30 @@ public: Agent *createFromFile( const QString &tomlPath, QObject *parent, QString *errorOut = nullptr) const; - [[nodiscard]] QStringList lastLoadErrors() const { return m_errors; } - [[nodiscard]] QStringList lastLoadWarnings() const { return m_warnings; } - void registerConfig(AgentConfig config); void clear(); + bool setAgentModelOverride(const QString &name, const QString &model, QString *error = nullptr); + [[nodiscard]] QString agentModelOverride(const QString &name) const; + void clearAgentModelOverride(const QString &name); + + bool setAgentProviderOverride( + const QString &name, const QString &providerInstance, QString *error = nullptr); + [[nodiscard]] QString agentProviderOverride(const QString &name) const; + void clearAgentProviderOverride(const QString &name); + [[nodiscard]] Providers::ProviderInstanceFactory *instanceFactory() const noexcept; - [[nodiscard]] Providers::ProviderSecretsStore *secretsStore() const noexcept; + +signals: + void agentsChanged(); + void agentModelChanged(const QString &name); + void agentProviderChanged(const QString &name); private: std::vector m_configs; QHash m_indexByName; - QStringList m_errors; - QStringList m_warnings; + QHash m_baseModelByName; + QHash m_baseProviderByName; QPointer m_instanceFactory; QPointer m_secrets; }; diff --git a/sources/agents/AgentLoader.cpp b/sources/agents/AgentLoader.cpp index e62a722..c43fef6 100644 --- a/sources/agents/AgentLoader.cpp +++ b/sources/agents/AgentLoader.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -120,10 +121,12 @@ AgentConfig configFromMerged(const QJsonObject &obj) cfg.providerInstance = obj.value("provider_instance").toString(); cfg.model = obj.value("model").toString(); cfg.endpoint = obj.value("endpoint").toString(); - cfg.role = obj.value("role").toString(); - cfg.context = obj.value("context").toString(); + cfg.systemPrompt = obj.value("system_prompt").toString(); cfg.enableThinking = obj.value("enable_thinking").toBool(false); cfg.enableTools = obj.value("enable_tools").toBool(false); + cfg.cachePrompt = obj.value("cache_prompt").toBool(false); + cfg.cacheTtl = obj.value("cache_ttl").toString(); + cfg.cacheBreakpoints = stringArray(obj.value("cache_breakpoints")); cfg.tags = stringArray(obj.value("tags")); const QJsonObject matchObj = obj.value("match").toObject(); @@ -135,10 +138,7 @@ AgentConfig configFromMerged(const QJsonObject &obj) cfg.abstract = obj.value("abstract").toBool(false); cfg.hidden = obj.value("hidden").toBool(false); - const QJsonObject tpl = obj.value("template").toObject(); - cfg.messageFormat = tpl.value("message_format").toString(); - cfg.sampling = tpl.value("sampling").toObject(); - cfg.thinking = tpl.value("thinking").toObject(); + cfg.body = obj.value("body").toObject(); return cfg; } @@ -146,11 +146,111 @@ struct RawEntry { QJsonObject obj; QString filePath; - bool overridesBundled = false; + bool isUserLayer = false; }; constexpr int kMaxExtendsDepth = 32; +void lintUnknownKeys(const QJsonObject &obj, const QString &filePath, QStringList &warnings) +{ + static const QSet kTopLevelKeys = { + QStringLiteral("schema_version"), QStringLiteral("name"), + QStringLiteral("description"), QStringLiteral("provider_instance"), + QStringLiteral("model"), QStringLiteral("endpoint"), + QStringLiteral("system_prompt"), QStringLiteral("tags"), + QStringLiteral("match"), QStringLiteral("enable_thinking"), + QStringLiteral("enable_tools"), QStringLiteral("cache_prompt"), + QStringLiteral("cache_ttl"), QStringLiteral("cache_breakpoints"), + QStringLiteral("body"), + QStringLiteral("extends"), QStringLiteral("abstract"), + QStringLiteral("hidden")}; + static const QSet kMatchKeys = { + QStringLiteral("file_patterns"), + QStringLiteral("path_patterns"), + QStringLiteral("project_names")}; + + for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) { + if (!kTopLevelKeys.contains(it.key())) { + warnings.append(QStringLiteral("Unknown key '%1' in %2 — ignored (typo?)") + .arg(it.key(), filePath)); + } + } + const QJsonObject matchObj = obj.value("match").toObject(); + for (auto it = matchObj.constBegin(); it != matchObj.constEnd(); ++it) { + if (!kMatchKeys.contains(it.key())) { + warnings.append(QStringLiteral("Unknown key 'match.%1' in %2 — ignored (typo?)") + .arg(it.key(), filePath)); + } + } + + static const QSet kCacheBreakpoints = { + QStringLiteral("system"), QStringLiteral("tools"), QStringLiteral("history")}; + for (const QJsonValue &bp : obj.value("cache_breakpoints").toArray()) { + if (bp.isString() && !kCacheBreakpoints.contains(bp.toString())) { + warnings.append(QStringLiteral("Unknown cache_breakpoint '%1' in %2 — ignored " + "(use system/tools/history)") + .arg(bp.toString(), filePath)); + } + } +} + +void scanDir( + const QString &dir, + bool isUserLayer, + QHash &raw, + QStringList &errors, + QStringList *warnings) +{ + if (dir.isEmpty()) return; + QDir d(dir); + if (!d.exists()) return; + const QStringList files = d.entryList({"*.toml"}, QDir::Files); + for (const QString &fname : files) { + const QString fullPath = d.filePath(fname); + QString err; + auto objOpt = parseTomlFile(fullPath, &err); + if (!objOpt) { + errors.append(err); + continue; + } + const QString name = objOpt->value("name").toString(); + if (name.isEmpty()) { + errors.append(QStringLiteral("Agent at %1 has no 'name'").arg(fullPath)); + continue; + } + if (warnings) + lintUnknownKeys(*objOpt, fullPath, *warnings); + const auto existing = raw.constFind(name); + if (existing != raw.constEnd() && existing->isUserLayer != isUserLayer) { + errors.append( + QStringLiteral("Agent '%1' at %2 has the same name as a bundled agent — " + "bundled agents cannot be replaced; rename it and use " + "'extends' to build on the bundled one") + .arg(name, fullPath)); + continue; + } + if (warnings && existing != raw.constEnd()) { + warnings->append( + QStringLiteral("Agent '%1' is defined in both %2 and %3 — %3 wins") + .arg(name, existing->filePath, fullPath)); + } + raw.insert(name, {*objOpt, fullPath, isUserLayer}); + } +} + +QJsonObject mergeChild(const QJsonObject &parentMerged, const QJsonObject &self, const QString &name) +{ + QJsonObject merged = deepMerge(parentMerged, self); + merged["name"] = name; + for (const QString &key : {QStringLiteral("abstract"), QStringLiteral("hidden")}) { + if (self.contains(key)) + merged[key] = self.value(key); + else + merged.remove(key); + } + return merged; +} + QJsonObject resolveExtends( const QString &name, const QHash &raw, @@ -169,7 +269,7 @@ QJsonObject resolveExtends( return {}; } if (!raw.contains(name)) { - errors.append(QStringLiteral("Unknown parent agent '%1'").arg(name)); + errors.append(QStringLiteral("Unknown agent '%1'").arg(name)); return {}; } visiting.insert(name); @@ -177,15 +277,15 @@ QJsonObject resolveExtends( QJsonObject self = raw.value(name).obj; const QString parent = self.value("extends").toString(); if (!parent.isEmpty()) { + if (!raw.contains(parent)) { + errors.append(QStringLiteral("Agent '%1' extends unknown agent '%2' (%3)") + .arg(name, parent, raw.value(name).filePath)); + visiting.remove(name); + return {}; + } const QJsonObject parentMerged = resolveExtends(parent, raw, visiting, errors, depth + 1); - QJsonObject merged = deepMerge(parentMerged, self); - merged["name"] = name; - if (self.contains("abstract")) - merged["abstract"] = self.value("abstract"); - else - merged.remove("abstract"); - self = merged; + self = mergeChild(parentMerged, self, name); } visiting.remove(name); return self; @@ -194,12 +294,49 @@ QJsonObject resolveExtends( } // namespace std::optional AgentLoader::parseFile( - const QString &path, QString *error, QStringList * /*warnings*/) + const QString &path, + const QString &qrcPrefix, + QString *error, + QStringList *warnings) { auto objOpt = parseTomlFile(path, error); if (!objOpt) return std::nullopt; - AgentConfig cfg = configFromMerged(*objOpt); + + const QString name = objOpt->value("name").toString(); + if (name.isEmpty()) { + if (error) *error = QStringLiteral("Agent at %1 has no 'name'").arg(path); + return std::nullopt; + } + if (warnings) + lintUnknownKeys(*objOpt, path, *warnings); + + QHash raw; + QStringList scanErrors; + scanDir(qrcPrefix, /*isUserLayer=*/false, raw, scanErrors, nullptr); + scanDir(QFileInfo(path).absolutePath(), /*isUserLayer=*/true, raw, scanErrors, nullptr); + raw.insert(name, {*objOpt, path, true}); + + QSet visiting; + QStringList resolveErrors; + const QJsonObject merged = resolveExtends(name, raw, visiting, resolveErrors); + if (!resolveErrors.isEmpty() || merged.isEmpty()) { + if (error) { + *error = resolveErrors.isEmpty() + ? QStringLiteral("Agent '%1' resolved to an empty config").arg(name) + : resolveErrors.join(QStringLiteral("; ")); + } + return std::nullopt; + } + + AgentConfig cfg = configFromMerged(merged); cfg.sourcePath = path; + if (cfg.abstract) { + if (error) { + *error = QStringLiteral("Agent '%1' is abstract — extend it instead of " + "loading it directly").arg(name); + } + return std::nullopt; + } return cfg; } @@ -208,31 +345,8 @@ AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QStrin LoadResult result; QHash raw; - auto scan = [&](const QString &dir, bool isUserLayer) { - if (dir.isEmpty()) return; - QDir d(dir); - if (!d.exists()) return; - const QStringList files = d.entryList({"*.toml"}, QDir::Files); - for (const QString &fname : files) { - const QString fullPath = d.filePath(fname); - QString err; - auto objOpt = parseTomlFile(fullPath, &err); - if (!objOpt) { - result.errors.append(err); - continue; - } - const QString name = objOpt->value("name").toString(); - if (name.isEmpty()) { - result.errors.append(QStringLiteral("Agent at %1 has no 'name'").arg(fullPath)); - continue; - } - const bool overrides = isUserLayer && raw.contains(name); - raw.insert(name, {*objOpt, fullPath, overrides}); - } - }; - - scan(qrcPrefix, /*isUserLayer=*/false); - scan(userDir, /*isUserLayer=*/true); + scanDir(qrcPrefix, /*isUserLayer=*/false, raw, result.errors, &result.warnings); + scanDir(userDir, /*isUserLayer=*/true, raw, result.errors, &result.warnings); for (auto it = raw.constBegin(); it != raw.constEnd(); ++it) { const QString &name = it.key(); @@ -243,7 +357,6 @@ AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QStrin AgentConfig cfg = configFromMerged(merged); cfg.sourcePath = it.value().filePath; - cfg.overridesBundled = it.value().overridesBundled; if (cfg.abstract) continue; diff --git a/sources/agents/AgentLoader.hpp b/sources/agents/AgentLoader.hpp index 9f26eca..4f5a5fe 100644 --- a/sources/agents/AgentLoader.hpp +++ b/sources/agents/AgentLoader.hpp @@ -25,7 +25,10 @@ public: static LoadResult load(const QString &qrcPrefix, const QString &userDir); static std::optional parseFile( - const QString &path, QString *error, QStringList *warnings = nullptr); + const QString &path, + const QString &qrcPrefix, + QString *error, + QStringList *warnings = nullptr); }; } // namespace QodeAssist::Agents diff --git a/sources/agents/AgentRouter.cpp b/sources/agents/AgentRouter.cpp index 7135eb4..58607c6 100644 --- a/sources/agents/AgentRouter.cpp +++ b/sources/agents/AgentRouter.cpp @@ -5,6 +5,9 @@ #include "AgentRouter.hpp" #include +#include +#include +#include #include #include "AgentFactory.hpp" @@ -13,16 +16,29 @@ namespace QodeAssist::AgentRouter { namespace { +QRegularExpression compiledGlob(const QString &pattern) +{ + static QHash cache; + static QMutex mutex; + QMutexLocker lock(&mutex); + const auto it = cache.constFind(pattern); + if (it != cache.constEnd()) + return *it; + const QRegularExpression re( + QRegularExpression::anchoredPattern( + QRegularExpression::wildcardToRegularExpression( + pattern, QRegularExpression::NonPathWildcardConversion)), + QRegularExpression::CaseInsensitiveOption); + cache.insert(pattern, re); + return re; +} + bool matchesAnyGlob(const QStringList &patterns, const QString &subject) { if (subject.isEmpty()) return false; for (const QString &pat : patterns) { - const QRegularExpression re( - QRegularExpression::anchoredPattern( - QRegularExpression::wildcardToRegularExpression( - pat, QRegularExpression::NonPathWildcardConversion)), - QRegularExpression::CaseInsensitiveOption); + const QRegularExpression re = compiledGlob(pat); if (re.isValid() && re.match(subject).hasMatch()) return true; } diff --git a/sources/agents/ContextRenderer.cpp b/sources/agents/ContextRenderer.cpp index abdcc66..5d38f98 100644 --- a/sources/agents/ContextRenderer.cpp +++ b/sources/agents/ContextRenderer.cpp @@ -4,13 +4,13 @@ #include "ContextRenderer.hpp" -#include #include #include #include #include #include +#include #include @@ -23,70 +23,82 @@ QString substituteVars(const QString &src, const Bindings &b) QString out = src; if (!b.projectDir.isEmpty()) out.replace(QStringLiteral("${PROJECT_DIR}"), b.projectDir); - if (!b.homeDir.isEmpty()) - out.replace(QStringLiteral("${HOME}"), b.homeDir); + if (!b.configDir.isEmpty()) + out.replace(QStringLiteral("${CONFIG_DIR}"), b.configDir); return out; } bool isPathAllowed(const QString &requestedPath, const Bindings &b) { + if (requestedPath.startsWith(QLatin1String(":/"))) + return true; + const QString target = QDir::cleanPath(requestedPath); auto isUnder = [&target](const QString &root) { - if (root.isEmpty()) return false; + if (root.isEmpty()) + return false; const QString cleanRoot = QDir::cleanPath(root); - if (target == cleanRoot) return true; + if (target == cleanRoot) + return true; return target.startsWith(cleanRoot + QLatin1Char('/')); }; - if (isUnder(b.projectDir)) return true; - if (!b.homeDir.isEmpty() && isUnder(b.homeDir + QStringLiteral("/qodeassist"))) + if (isUnder(b.projectDir)) + return true; + if (isUnder(b.configDir)) return true; return false; } -void registerReadFile(inja::Environment &env, const Bindings &b) -{ - const Bindings capturedBindings = b; - env.add_callback("read_file", 1, [capturedBindings](inja::Arguments &args) -> nlohmann::json { - const std::string raw = args.at(0)->get(); - QString path = QString::fromStdString(raw); - - if (!capturedBindings.projectDir.isEmpty()) - path.replace(QStringLiteral("${PROJECT_DIR}"), capturedBindings.projectDir); - if (!capturedBindings.homeDir.isEmpty()) - path.replace(QStringLiteral("${HOME}"), capturedBindings.homeDir); - - if (!isPathAllowed(path, capturedBindings)) { - qWarning("[QodeAssist] context.read_file: path not in allowed roots: %s", - qUtf8Printable(path)); - return std::string{}; - } - QFile f(path); - if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) - return std::string{}; - return f.readAll().toStdString(); - }); -} - QString expandAndResolvePath(const QString &raw, const Bindings &b) { QString p = raw; if (!b.projectDir.isEmpty()) p.replace(QStringLiteral("${PROJECT_DIR}"), b.projectDir); - if (!b.homeDir.isEmpty()) - p.replace(QStringLiteral("${HOME}"), b.homeDir); + if (!b.configDir.isEmpty()) + p.replace(QStringLiteral("${CONFIG_DIR}"), b.configDir); return p; } +[[noreturn]] void throwOutsideRoots(const char *fn, const QString &path) +{ + throw std::runtime_error( + QStringLiteral("%1: path is outside the allowed read roots " + "(the project directory, ~/qodeassist, or bundled :/ resources): %2") + .arg(QString::fromLatin1(fn), path) + .toStdString()); +} + +void registerReadFile(inja::Environment &env, const Bindings &b) +{ + const Bindings caps = b; + env.add_callback("read_file", 1, [caps](inja::Arguments &args) -> nlohmann::json { + const QString path + = expandAndResolvePath(QString::fromStdString(args.at(0)->get()), caps); + + if (!isPathAllowed(path, caps)) + throwOutsideRoots("read_file", path); + + QFile f(path); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + throw std::runtime_error( + QStringLiteral("read_file: cannot open file (missing or unreadable): %1") + .arg(path) + .toStdString()); + } + return f.readAll().toStdString(); + }); +} + void registerFileExists(inja::Environment &env, const Bindings &b) { const Bindings caps = b; env.add_callback("file_exists", 1, [caps](inja::Arguments &args) -> nlohmann::json { - const QString p = expandAndResolvePath( - QString::fromStdString(args.at(0)->get()), caps); + const QString p + = expandAndResolvePath(QString::fromStdString(args.at(0)->get()), caps); if (!isPathAllowed(p, caps)) - return false; + throwOutsideRoots("file_exists", p); return QFileInfo::exists(p); }); } @@ -94,21 +106,18 @@ void registerFileExists(inja::Environment &env, const Bindings &b) void registerReadDir(inja::Environment &env, const Bindings &b) { const Bindings caps = b; - + env.add_callback("read_dir", 1, [caps](inja::Arguments &args) -> nlohmann::json { - const QString p = expandAndResolvePath( - QString::fromStdString(args.at(0)->get()), caps); - if (!isPathAllowed(p, caps)) { - qWarning("[QodeAssist] context.read_dir: path not in allowed roots: %s", - qUtf8Printable(p)); - return nlohmann::json::array(); - } + const QString p + = expandAndResolvePath(QString::fromStdString(args.at(0)->get()), caps); + if (!isPathAllowed(p, caps)) + throwOutsideRoots("read_dir", p); QDir dir(p); if (!dir.exists()) return nlohmann::json::array(); nlohmann::json out = nlohmann::json::array(); - const QStringList entries = dir.entryList( - QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + const QStringList entries + = dir.entryList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); for (const QString &name : entries) out.push_back(name.toStdString()); return out; @@ -137,9 +146,7 @@ void registerStringHelpers(inja::Environment &env) .toStdString(); }); env.add_callback("dirname", 1, [](inja::Arguments &args) -> nlohmann::json { - return QFileInfo(QString::fromStdString(args.at(0)->get())) - .path() - .toStdString(); + return QFileInfo(QString::fromStdString(args.at(0)->get())).path().toStdString(); }); env.add_callback("ext", 1, [](inja::Arguments &args) -> nlohmann::json { return QFileInfo(QString::fromStdString(args.at(0)->get())) @@ -157,12 +164,10 @@ void registerStringHelpers(inja::Environment &env) void registerSandbox(inja::Environment &env) { - env.set_search_included_templates_in_files(false); env.set_include_callback( [](const std::filesystem::path &, const std::string &name) -> inja::Template { - throw inja::FileError( - "include is disabled in QodeAssist context: '" + name + "'"); + throw inja::FileError("include is disabled in QodeAssist context: '" + name + "'"); }); env.set_line_statement("@@@inja@@@"); @@ -196,7 +201,9 @@ QString render(const QString &templateSource, const Bindings &bindings, QString } try { - const std::string rendered = env.render(tpl, nlohmann::json::object()); + nlohmann::json data = nlohmann::json::object(); + data["language"] = bindings.language.toStdString(); + const std::string rendered = env.render(tpl, data); return QString::fromStdString(rendered); } catch (const std::exception &e) { if (error) { diff --git a/sources/agents/ContextRenderer.hpp b/sources/agents/ContextRenderer.hpp index e4fb2fd..ee09090 100644 --- a/sources/agents/ContextRenderer.hpp +++ b/sources/agents/ContextRenderer.hpp @@ -11,10 +11,10 @@ namespace QodeAssist::Templates::ContextRenderer { struct Bindings { QString projectDir; - QString homeDir; + QString configDir; + QString language; }; -QString render(const QString &templateSource, const Bindings &bindings, - QString *error = nullptr); +QString render(const QString &templateSource, const Bindings &bindings, QString *error = nullptr); } // namespace QodeAssist::Templates::ContextRenderer diff --git a/sources/agents/agents.qrc b/sources/agents/agents.qrc index 8bf4c2d..b74c547 100644 --- a/sources/agents/agents.qrc +++ b/sources/agents/agents.qrc @@ -1,9 +1,32 @@ + claude_base_chat.toml + claude_chat.toml + claude_opus_chat.toml + claude_opus_max.toml + claude_completion.toml + claude_compression.toml + claude_quick_refactor.toml + openai_base_chat.toml + openai_chat_completions.toml + openai_responses_base.toml + openai_responses_chat.toml + google_base_chat.toml + google_chat.toml ollama_base_chat.toml + ollama_chat.toml + ollama_chat_completion.toml ollama_base_fim.toml - ollama_gemma4_e4b_chat.toml - ollama_codellama_7b_code_fim.toml - ollama_codellama_13b_qml_fim.toml + ollama_fim.toml + + + roles/qt-cpp-developer.md + roles/code-completion.md + roles/code-completion-c-like.md + roles/code-completion-qml.md + + + tasks/chat-compressor.md + tasks/code-completion.md diff --git a/sources/agents/claude_base_chat.toml b/sources/agents/claude_base_chat.toml new file mode 100644 index 0000000..f07ace3 --- /dev/null +++ b/sources/agents/claude_base_chat.toml @@ -0,0 +1,46 @@ +schema_version = 1 + +name = "Claude Base Chat" +description = "Anthropic Messages API request body (/v1/messages). Abstract — extend it and set model." +abstract = true + +provider_instance = "Claude" +endpoint = "/v1/messages" + +[body] +stream = true +system = """{% if existsIn(ctx, "system_prompt") %}{{ tojson(ctx.system_prompt) }}{% endif %}""" +messages = """ +[ +{% for msg in ctx.history %} +{ + "role": {{ tojson(msg.role) }}, + "content": [ + {% for b in msg.content_blocks %} + {% if b.type == "text" %} + { "type": "text", "text": {{ tojson(b.text) }} }, + {% else if b.type == "thinking" %} + { "type": "thinking", "thinking": {{ tojson(b.thinking) }}, "signature": {{ tojson(b.signature) }} }, + {% else if b.type == "redacted_thinking" %} + { "type": "redacted_thinking", "data": {{ tojson(b.data) }} }, + {% else if b.type == "tool_use" %} + { "type": "tool_use", "id": {{ tojson(b.id) }}, "name": {{ tojson(b.name) }}, "input": {{ tojson(b.input) }} }, + {% else if b.type == "tool_result" %} + { "type": "tool_result", "tool_use_id": {{ tojson(b.tool_use_id) }}, "content": {{ tojson(b.content) }} }, + {% else if b.type == "image" %} + { + "type": "image", + "source": + {% if b.is_url %} + { "type": "url", "url": {{ tojson(b.data) }} } + {% else %} + { "type": "base64", "media_type": {{ tojson(b.media_type) }}, "data": {{ tojson(b.data) }} } + {% endif %} + }, + {% endif %} + {% endfor %} + ] +}, +{% endfor %} +] +""" diff --git a/sources/agents/claude_chat.toml b/sources/agents/claude_chat.toml new file mode 100644 index 0000000..cbb2c68 --- /dev/null +++ b/sources/agents/claude_chat.toml @@ -0,0 +1,20 @@ +schema_version = 1 + +extends = "Claude Base Chat" +name = "Claude Sonnet Chat" +description = "Anthropic Claude — coding chat with adaptive thinking." + +model = "claude-sonnet-4-6" +enable_tools = true +enable_thinking = true +cache_prompt = true +cache_breakpoints = ["system", "tools", "history"] +tags = ["chat", "claude", "cloud"] + +system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}""" + +[body] +max_tokens = 16000 +temperature = 1 +thinking = { type = "adaptive", display = "summarized" } +output_config = { effort = "high" } diff --git a/sources/agents/claude_completion.toml b/sources/agents/claude_completion.toml new file mode 100644 index 0000000..378df97 --- /dev/null +++ b/sources/agents/claude_completion.toml @@ -0,0 +1,30 @@ +schema_version = 1 + +extends = "Claude Base Chat" +name = "Claude Code Completion" +description = "Anthropic Claude — code completion using the master message format over the Messages API." + +model = "claude-haiku-4-5" +tags = ["completion", "claude", "cloud"] + +system_prompt = """ +{%- if language == "qml" %}{{ read_file(":/roles/code-completion-qml.md") }} +{%- else if language == "c-like" %}{{ read_file(":/roles/code-completion-c-like.md") }} +{%- else %}{{ read_file(":/roles/code-completion.md") }} +{%- endif %} +{{ read_file(":/tasks/code-completion.md") }}""" + +[body] +max_tokens = 512 +temperature = 0 +stop_sequences = [""] +messages = """ +[ + { + "role": "user", + "content": [ + { "type": "text", "text": {{ tojson("Here is the code context with insertion points:\\n\\n" + ctx.prefix + "" + ctx.suffix + "\\n") }} } + ] + } +] +""" diff --git a/sources/agents/claude_compression.toml b/sources/agents/claude_compression.toml new file mode 100644 index 0000000..764806e --- /dev/null +++ b/sources/agents/claude_compression.toml @@ -0,0 +1,15 @@ +schema_version = 1 + +extends = "Claude Base Chat" +name = "Claude Chat Compression" +description = "Anthropic Claude Haiku — fast, low-cost conversation summarization for shorter chats. Carries the summary system prompt; no tools, no thinking." + +model = "claude-haiku-4-5" +enable_tools = false +tags = ["compression", "claude", "cloud"] + +system_prompt = """{{ read_file(":/tasks/chat-compressor.md") }}""" + +[body] +max_tokens = 32000 +temperature = 0.3 diff --git a/sources/agents/claude_opus_chat.toml b/sources/agents/claude_opus_chat.toml new file mode 100644 index 0000000..655708c --- /dev/null +++ b/sources/agents/claude_opus_chat.toml @@ -0,0 +1,20 @@ +schema_version = 1 + +extends = "Claude Base Chat" +name = "Claude Opus xHigh Chat" +description = "Anthropic Claude Opus 4.8 — coding chat with adaptive thinking at xhigh effort, tuned for agentic and coding work." + +model = "claude-opus-4-8" +enable_tools = true +enable_thinking = true +cache_prompt = true +cache_breakpoints = ["system", "tools", "history"] +tags = ["chat", "claude", "cloud"] + +system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}""" + +[body] +max_tokens = 64000 +temperature = 1 +thinking = { type = "adaptive", display = "summarized" } +output_config = { effort = "xhigh" } diff --git a/sources/agents/claude_opus_max.toml b/sources/agents/claude_opus_max.toml new file mode 100644 index 0000000..a9c711c --- /dev/null +++ b/sources/agents/claude_opus_max.toml @@ -0,0 +1,20 @@ +schema_version = 1 + +extends = "Claude Base Chat" +name = "Claude Opus Max Chat" +description = "Anthropic Claude Opus 4.8 — maximum-capability coding chat; adaptive thinking at max effort with 128k output. For frontier problems; higher cost and latency." + +model = "claude-opus-4-8" +enable_tools = true +enable_thinking = true +cache_prompt = true +cache_breakpoints = ["system", "tools", "history"] +tags = ["chat", "claude", "cloud"] + +system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}""" + +[body] +max_tokens = 128000 +temperature = 1 +thinking = { type = "adaptive", display = "summarized" } +output_config = { effort = "max" } diff --git a/sources/agents/claude_quick_refactor.toml b/sources/agents/claude_quick_refactor.toml new file mode 100644 index 0000000..82b3d56 --- /dev/null +++ b/sources/agents/claude_quick_refactor.toml @@ -0,0 +1,13 @@ +schema_version = 1 + +extends = "Claude Base Chat" +name = "Claude Quick Refactor" +description = "Anthropic Claude — deterministic inline refactor. QuickRefactorHandler supplies the refactor system prompt; output is cleaned and inserted into the editor. No thinking; tools off." + +model = "claude-sonnet-4-6" +enable_tools = false +tags = ["refactor", "claude", "cloud"] + +[body] +max_tokens = 16000 +temperature = 0.2 diff --git a/sources/agents/google_base_chat.toml b/sources/agents/google_base_chat.toml new file mode 100644 index 0000000..086cabc --- /dev/null +++ b/sources/agents/google_base_chat.toml @@ -0,0 +1,37 @@ +schema_version = 1 + +name = "Google Base Chat" +description = "Google Gemini generateContent request body. Abstract — extend it and set model." +abstract = true + +provider_instance = "Google AI" +endpoint = "/models/${MODEL}:streamGenerateContent?alt=sse" + +[body] +system_instruction = """{% if existsIn(ctx, "system_prompt") %}{ "parts": [ { "text": {{ tojson(ctx.system_prompt) }} } ] }{% endif %}""" +contents = """ +[ +{% for msg in ctx.history %} +{ + "role": {% if msg.role == "assistant" %}"model"{% else %}"user"{% endif %}, + "parts": [ {% for b in msg.content_blocks %}{% if b.type == "text" %} +{ "text": {{ tojson(b.text) }} }, +{% else if b.type == "thinking" %} +{ "text": {{ tojson(b.thinking) }}, "thought": true, "thoughtSignature": {{ tojson(b.signature) }} }, +{% else if b.type == "tool_use" %} +{ "functionCall": { "name": {{ tojson(b.name) }}, "args": {{ tojson(b.input) }} } }, +{% else if b.type == "tool_result" %} +{ "functionResponse": { "name": {{ tojson(b.name) }}, "response": { "result": {{ tojson(b.content) }} } } }, +{% else if b.type == "image" %} + {% if b.is_url %} + { "file_data": { "mime_type": {{ tojson(b.media_type) }}, "file_uri": {{ tojson(b.data) }} } }, + {% else %} + { "inline_data": { "mime_type": {{ tojson(b.media_type) }}, "data": {{ tojson(b.data) }} } }, + {% endif %} +{% else %} +{ "text": "" }, +{% endif %}{% endfor %} ] +}, +{% endfor %} +] +""" diff --git a/sources/agents/google_chat.toml b/sources/agents/google_chat.toml new file mode 100644 index 0000000..463cf1b --- /dev/null +++ b/sources/agents/google_chat.toml @@ -0,0 +1,17 @@ +schema_version = 1 + +extends = "Google Base Chat" +name = "Google Chat" +description = "Google Gemini 2.5 Flash — coding chat with thinking." + +model = "gemini-2.5-flash" +enable_tools = true +enable_thinking = true +tags = ["chat", "gemini", "google", "cloud"] + +system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}""" + +[body.generationConfig] +maxOutputTokens = 16000 +temperature = 1 +thinkingConfig = { includeThoughts = true, thinkingBudget = 8192 } diff --git a/sources/agents/ollama_base_chat.toml b/sources/agents/ollama_base_chat.toml index 5c89228..c392159 100644 --- a/sources/agents/ollama_base_chat.toml +++ b/sources/agents/ollama_base_chat.toml @@ -1,44 +1,52 @@ schema_version = 1 name = "Ollama Base Chat" -description = "Shared base for Ollama /api/chat profiles." - -abstract = true +description = "Ollama native /api/chat request body. Abstract — extend it and set model." +abstract = true provider_instance = "Ollama (Native)" endpoint = "/api/chat" -tags = ["ollama", "local"] - -[template] -message_format = """ -{ - "messages": [ - {%- if existsIn(ctx, "system_prompt") %} - { - "role": "system", - "content": {{ tojson(ctx.system_prompt) }} - }{% if length(ctx.history) > 0 %},{% endif %} - {%- endif %} - {%- for msg in ctx.history %} - { - "role": {{ tojson(msg.role) }}, - "content": {{ tojson(msg.content) }}{% if existsIn(msg, "images") %}, - "images": [ - {%- for img in msg.images %} - {{ tojson(img.data) }}{% if not loop.is_last %},{% endif %} - {%- endfor %} - ]{% endif %} - }{% if not loop.is_last %},{% endif %} - {%- endfor %} - ] -} -""" - -[template.sampling] +[body] stream = true - -[template.sampling.options] -num_predict = 2048 -temperature = 0.7 -keep_alive = "5m" +messages = """ +[ +{% if existsIn(ctx, "system_prompt") %} +{ "role": "system", "content": {{ tojson(ctx.system_prompt) }} }, +{% endif %} +{% for msg in ctx.history %} + {% set tcalls = filter_by_type(msg.content_blocks, "tool_use") %} + {% set tresults = filter_by_type(msg.content_blocks, "tool_result") %} + {% if length(tresults) > 0 %} + {% for b in tresults %} + { + "role": "tool", + "content": {{ tojson(b.content) }} + {% if b.name != "" %} + , "tool_name": {{ tojson(b.name) }} + {% endif %} + }, + {% endfor %} + {% else %} + { + "role": {{ tojson(msg.role) }}, + "content": {{ tojson(msg.content) }} + {% if length(tcalls) > 0 %} + , "tool_calls": [ + {% for b in tcalls %} + { "type": "function", "function": { "name": {{ tojson(b.name) }}, "arguments": {{ tojson(b.input) }} } }, + {% endfor %} + ] + {% endif %} + {% if existsIn(msg, "images") %} + , "images": [ + {% for img in msg.images %} + {{ tojson(img.data) }}, + {% endfor %} + ] + {% endif %} + }, + {% endif %} +{% endfor %} +] +""" diff --git a/sources/agents/ollama_base_fim.toml b/sources/agents/ollama_base_fim.toml index 72b9d2f..454ed44 100644 --- a/sources/agents/ollama_base_fim.toml +++ b/sources/agents/ollama_base_fim.toml @@ -1,32 +1,14 @@ schema_version = 1 name = "Ollama FIM Base" -description = "Shared base for Ollama native FIM (/api/generate) profiles." - -abstract = true +description = "Ollama native /api/generate FIM request body. Abstract — extend it and set model." +abstract = true provider_instance = "Ollama (Native)" endpoint = "/api/generate" -tags = ["ollama", "local", "fim"] - -[template] -message_format = """ -{ - "prompt": {{ tojson(ctx.prefix) }}, - "suffix": {{ tojson(ctx.suffix) }} - {%- if existsIn(ctx, "system_prompt") %}, - "system": {{ tojson(ctx.system_prompt) }} - {%- endif %} -} -""" - -[template.sampling] +[body] stream = true - -[template.sampling.options] -num_predict = 512 -temperature = 0.2 -top_p = 0.9 -keep_alive = "5m" -stop = [""] +system = """{% if existsIn(ctx, "system_prompt") %}{{ tojson(ctx.system_prompt) }}{% endif %}""" +prompt = """{{ tojson(ctx.prefix) }}""" +suffix = """{% if existsIn(ctx, "suffix") %}{{ tojson(ctx.suffix) }}{% endif %}""" diff --git a/sources/agents/ollama_chat.toml b/sources/agents/ollama_chat.toml new file mode 100644 index 0000000..bd18886 --- /dev/null +++ b/sources/agents/ollama_chat.toml @@ -0,0 +1,15 @@ +schema_version = 1 + +extends = "Ollama Base Chat" +name = "Ollama Chat" +description = "Local Ollama coding chat (qwen2.5-coder)." + +model = "qwen2.5-coder:7b" +tags = ["chat", "ollama", "local"] + +system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}""" + +[body.options] +num_predict = 2048 +temperature = 0.7 +keep_alive = "5m" diff --git a/sources/agents/ollama_chat_completion.toml b/sources/agents/ollama_chat_completion.toml new file mode 100644 index 0000000..89dd982 --- /dev/null +++ b/sources/agents/ollama_chat_completion.toml @@ -0,0 +1,32 @@ +schema_version = 1 + +extends = "Ollama Base Chat" +name = "Ollama FIM-on-chat" +description = "Local Ollama code completion over /api/chat using the message format — FIM-on-chat." + +model = "qwen2.5-coder:7b" +tags = ["completion", "ollama", "local", "fim"] + +system_prompt = """ +{%- if language == "qml" %}{{ read_file(":/roles/code-completion-qml.md") }} +{%- else if language == "c-like" %}{{ read_file(":/roles/code-completion-c-like.md") }} +{%- else %}{{ read_file(":/roles/code-completion.md") }} +{%- endif %} +{{ read_file(":/tasks/code-completion.md") }}""" + +[body] +messages = """ +[ +{% if existsIn(ctx, "system_prompt") %} +{ "role": "system", "content": {{ tojson(ctx.system_prompt) }} }, +{% endif %} +{ "role": "user", "content": {{ tojson("Here is the code context with insertion points:\\n\\n" + ctx.prefix + "" + ctx.suffix + "\\n") }} } +] +""" + +[body.options] +num_predict = 512 +temperature = 0 +num_ctx = 8192 +keep_alive = "5m" +stop = [""] diff --git a/sources/agents/ollama_codellama_13b_qml_fim.toml b/sources/agents/ollama_codellama_13b_qml_fim.toml deleted file mode 100644 index b7538a9..0000000 --- a/sources/agents/ollama_codellama_13b_qml_fim.toml +++ /dev/null @@ -1,40 +0,0 @@ -schema_version = 1 - -name = "Qt CodeLlama 13B QML FIM" -description = "Local Qt-Company-tuned CodeLlama 13B for QML FIM completion." - -provider_instance = "Ollama (Native)" -endpoint = "/api/generate" - -model = "theqtcompany/codellama-13b-qml:latest" - -tags = ["fim", "ollama", "local", "codellama", "qml", "qt"] - -[match] -file_patterns = ["*.qml"] - -[template] -message_format = """ -{ - "prompt": {%- if existsIn(ctx, "suffix") and length(ctx.suffix) > 0 -%} - {{ tojson("" + ctx.suffix + "
" + ctx.prefix + "") }}
-  {%- else -%}
-    {{ tojson("
" + ctx.prefix + "") }}
-  {%- endif %}
-  {%- if existsIn(ctx, "system_prompt") %},
-  "system": {{ tojson(ctx.system_prompt) }}
-  {%- endif %}
-}
-"""
-
-[template.sampling]
-stream = true
-
-[template.sampling.options]
-num_predict    = 500
-temperature    = 0
-top_p          = 1
-repeat_penalty = 1.05
-keep_alive  = "5m"
-
-stop = ["", "
", "
", "
", "< EOT >", "\\end", "", "", "##"] diff --git a/sources/agents/ollama_codellama_7b_code_fim.toml b/sources/agents/ollama_codellama_7b_code_fim.toml deleted file mode 100644 index 235ef4a..0000000 --- a/sources/agents/ollama_codellama_7b_code_fim.toml +++ /dev/null @@ -1,34 +0,0 @@ -schema_version = 1 - -name = "CodeLlama 7B Code FIM" -description = "Local CodeLlama 7B (code variant) on Ollama, FIM completion via PRE/SUF/MID markers." - -provider_instance = "Ollama (Native)" -endpoint = "/api/generate" - -model = "codellama:7b-code" - -tags = ["fim", "ollama", "local", "codellama"] - -[match] -file_patterns = ["*.cpp", "*.cc", "*.cxx", "*.c", "*.h", "*.hpp", "*.hxx", "*.inl"] - -[template] -message_format = """ -{ - "prompt": {{ tojson("
 " + ctx.prefix + " " + ctx.suffix + " ") }}
-  {%- if existsIn(ctx, "system_prompt") %},
-  "system": {{ tojson(ctx.system_prompt) }}
-  {%- endif %}
-}
-"""
-
-[template.sampling]
-stream = true
-
-[template.sampling.options]
-num_predict = 512
-temperature = 0.2
-top_p       = 0.9
-keep_alive  = "5m"
-stop = ["", "
", "", ""]
diff --git a/sources/agents/ollama_fim.toml b/sources/agents/ollama_fim.toml
new file mode 100644
index 0000000..bdfb205
--- /dev/null
+++ b/sources/agents/ollama_fim.toml
@@ -0,0 +1,16 @@
+schema_version = 1
+
+extends     = "Ollama FIM Base"
+name        = "Ollama FIM"
+description = "Local Ollama FIM code completion (qwen2.5-coder)."
+
+model = "qwen2.5-coder:7b"
+tags  = ["completion", "ollama", "local", "fim"]
+
+[body.options]
+num_predict = 512
+temperature = 0.2
+top_p       = 0.9
+num_ctx     = 8192
+keep_alive  = "5m"
+stop        = [""]
diff --git a/sources/agents/ollama_gemma4_e4b_chat.toml b/sources/agents/ollama_gemma4_e4b_chat.toml
deleted file mode 100644
index e41fe22..0000000
--- a/sources/agents/ollama_gemma4_e4b_chat.toml
+++ /dev/null
@@ -1,36 +0,0 @@
-schema_version = 1
-
-name    = "Ollama gemma4:e4b Chat"
-extends = "Ollama Base Chat"
-
-description = "Local Gemma 4 E4B on Ollama /api/chat — coding chat assistant."
-
-model = "gemma4:e4b"
-
-role = """
-You are a helpful coding assistant integrated into Qt Creator.
-Answer concisely. When the user shares code, prefer concrete diffs or
-minimal patches over rewriting whole files. Use markdown code blocks
-with language tags so the IDE can render them.
-"""
-
-enable_thinking = true
-enable_tools    = true
-
-tags = ["chat", "ollama", "local", "gemma"]
-
-context = """
-{%- set readme      = read_file("${PROJECT_DIR}/README.md")        -%}
-
-{%- if length(readme) > 0 %}
-## Project README.md
-{{ readme }}
-{%- endif %}
-"""
-
-[template.sampling.options]
-num_predict = 4096
-temperature = 1
-top_k       = 64
-top_p       = 0.95
-num_ctx     = 8192
diff --git a/sources/agents/openai_base_chat.toml b/sources/agents/openai_base_chat.toml
new file mode 100644
index 0000000..a48877e
--- /dev/null
+++ b/sources/agents/openai_base_chat.toml
@@ -0,0 +1,62 @@
+schema_version = 1
+
+name        = "OpenAI Base Chat"
+description = "OpenAI Chat Completions request body (/chat/completions). Abstract — extend it and set model."
+abstract    = true
+
+provider_instance = "OpenAI (Chat Completions)"
+endpoint          = "/chat/completions"
+
+[body]
+stream = true
+messages = """
+[
+{% if existsIn(ctx, "system_prompt") %}
+{ "role": "system", "content": {{ tojson(ctx.system_prompt) }} },
+{% endif %}
+{% for msg in ctx.history %}
+  {% if msg.role == "assistant" %}
+  {% set tcalls = filter_by_type(msg.content_blocks, "tool_use") %}
+  {
+    "role": "assistant",
+    "content": {% if msg.content != "" %}{{ tojson(msg.content) }}{% else %}null{% endif %}
+    {% if length(tcalls) > 0 %}
+    , "tool_calls": [
+      {% for b in tcalls %}
+      {
+        "id": {{ tojson(b.id) }},
+        "type": "function",
+        "function": {
+          "name": {{ tojson(b.name) }},
+          "arguments": {{ tojson(tojson(b.input)) }}
+        }
+      },
+      {% endfor %}
+    ]
+    {% endif %}
+  },
+  {% else if length(filter_by_type(msg.content_blocks, "tool_result")) > 0 %}
+  {% for b in filter_by_type(msg.content_blocks, "tool_result") %}
+  { "role": "tool", "tool_call_id": {{ tojson(b.tool_use_id) }}, "content": {{ tojson(b.content) }} },
+  {% endfor %}
+  {% else %}
+  {% if existsIn(msg, "images") %}
+  { "role": "user", "content": [
+    {% if msg.content != "" %}
+    { "type": "text", "text": {{ tojson(msg.content) }} },
+    {% endif %}
+    {% for img in msg.images %}
+    {% if img.is_url %}
+    { "type": "image_url", "image_url": { "url": {{ tojson(img.data) }} } },
+    {% else %}
+    { "type": "image_url", "image_url": { "url": "data:{{ img.media_type }};base64,{{ img.data }}" } },
+    {% endif %}
+    {% endfor %}
+  ] },
+  {% else %}
+  { "role": "user", "content": {{ tojson(msg.content) }} },
+  {% endif %}
+  {% endif %}
+{% endfor %}
+]
+"""
diff --git a/sources/agents/openai_chat_completions.toml b/sources/agents/openai_chat_completions.toml
new file mode 100644
index 0000000..14787cf
--- /dev/null
+++ b/sources/agents/openai_chat_completions.toml
@@ -0,0 +1,16 @@
+schema_version = 1
+
+extends     = "OpenAI Base Chat"
+name        = "OpenAI Chat Completions"
+description = "OpenAI GPT-4o — coding chat via Chat Completions."
+
+model           = "gpt-4o"
+enable_tools    = true
+enable_thinking = true
+tags            = ["chat", "openai", "cloud"]
+
+system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}"""
+
+[body]
+max_tokens  = 8192
+temperature = 0.7
diff --git a/sources/agents/openai_responses_base.toml b/sources/agents/openai_responses_base.toml
new file mode 100644
index 0000000..b7548f2
--- /dev/null
+++ b/sources/agents/openai_responses_base.toml
@@ -0,0 +1,46 @@
+schema_version = 1
+
+name        = "OpenAI Responses Base"
+description = "OpenAI Responses API request body (/responses). Abstract — extend it and set model."
+abstract    = true
+
+provider_instance = "OpenAI (Responses API)"
+endpoint          = "/responses"
+
+[body]
+stream = true
+instructions = """{% if existsIn(ctx, "system_prompt") %}{{ tojson(ctx.system_prompt) }}{% endif %}"""
+input = """
+[
+{% for msg in ctx.history %}
+  {% if msg.role == "assistant" %}
+    {% if msg.content != "" %}
+    { "role": "assistant", "content": {{ tojson(msg.content) }} },
+    {% endif %}
+    {% for b in filter_by_type(msg.content_blocks, "tool_use") %}
+    { "type": "function_call", "call_id": {{ tojson(b.id) }}, "name": {{ tojson(b.name) }}, "arguments": {{ tojson(tojson(b.input)) }} },
+    {% endfor %}
+  {% else if length(filter_by_type(msg.content_blocks, "tool_result")) > 0 %}
+    {% for b in filter_by_type(msg.content_blocks, "tool_result") %}
+    { "type": "function_call_output", "call_id": {{ tojson(b.tool_use_id) }}, "output": {{ tojson(b.content) }} },
+    {% endfor %}
+  {% else %}
+    {% if existsIn(msg, "images") %}
+    { "role": "user", "content": [
+      { "type": "input_text", "text": {{ tojson(msg.content) }} }
+      {% for img in msg.images %}
+      ,
+      {% if img.is_url %}
+      { "type": "input_image", "detail": "auto", "image_url": {{ tojson(img.data) }} }
+      {% else %}
+      { "type": "input_image", "detail": "auto", "image_url": "data:{{ img.media_type }};base64,{{ img.data }}" }
+      {% endif %}
+      {% endfor %}
+    ] },
+    {% else %}
+    { "role": "user", "content": {{ tojson(msg.content) }} },
+    {% endif %}
+  {% endif %}
+{% endfor %}
+]
+"""
diff --git a/sources/agents/openai_responses_chat.toml b/sources/agents/openai_responses_chat.toml
new file mode 100644
index 0000000..019a207
--- /dev/null
+++ b/sources/agents/openai_responses_chat.toml
@@ -0,0 +1,16 @@
+schema_version = 1
+
+extends     = "OpenAI Responses Base"
+name        = "OpenAI Responses Chat"
+description = "OpenAI o4-mini — reasoning coding chat via the Responses API."
+
+model           = "o4-mini"
+enable_tools    = true
+enable_thinking = true
+tags            = ["chat", "openai", "responses", "cloud"]
+
+system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}"""
+
+[body]
+max_output_tokens = 25000
+reasoning         = { effort = "medium", summary = "auto" }
diff --git a/sources/agents/roles/code-completion-c-like.md b/sources/agents/roles/code-completion-c-like.md
new file mode 100644
index 0000000..766c771
--- /dev/null
+++ b/sources/agents/roles/code-completion-c-like.md
@@ -0,0 +1 @@
+You are an expert C++, Qt, and QML code completion assistant. Your task is to provide precise and contextually appropriate code completions.
diff --git a/sources/agents/roles/code-completion-qml.md b/sources/agents/roles/code-completion-qml.md
new file mode 100644
index 0000000..0831113
--- /dev/null
+++ b/sources/agents/roles/code-completion-qml.md
@@ -0,0 +1 @@
+You are an expert QML and Qt Quick code completion assistant. Your task is to provide precise and contextually appropriate code completions.
diff --git a/sources/agents/roles/code-completion.md b/sources/agents/roles/code-completion.md
new file mode 100644
index 0000000..d5af4eb
--- /dev/null
+++ b/sources/agents/roles/code-completion.md
@@ -0,0 +1 @@
+You are an expert code completion assistant. Your task is to provide precise and contextually appropriate code completions.
diff --git a/sources/agents/roles/qt-cpp-developer.md b/sources/agents/roles/qt-cpp-developer.md
new file mode 100644
index 0000000..ab4945e
--- /dev/null
+++ b/sources/agents/roles/qt-cpp-developer.md
@@ -0,0 +1,32 @@
+You are an experienced Qt/C++ developer working in the Qt Creator IDE.
+
+Your workflow:
+1. **Analyze** - understand the problem and what needs to be done
+2. **Propose solution** - explain your approach in 2-3 sentences
+3. **Wait for approval** - don't write code until the solution is confirmed
+4. **Implement** - write clean, minimal code that solves the task
+
+When analyzing:
+- Ask clarifying questions if requirements are unclear
+- Check existing code for similar patterns
+- Consider edge cases and potential issues
+
+When proposing:
+- Explain what you'll change and why
+- Mention files you'll modify
+- Note any architectural implications
+
+When implementing:
+- Use C++20, Qt6, follow existing codebase style
+- Write only what's needed (MVP approach)
+- Include file paths and necessary changes
+- Handle errors properly
+- Make sure it compiles
+
+Keep it practical:
+- Short explanations, let code speak
+- No over-engineering or unnecessary refactoring
+- No TODOs, debug code, or unfinished work
+- Point out non-obvious things
+
+You're a pragmatic team member who thinks before coding.
diff --git a/sources/agents/tasks/chat-compressor.md b/sources/agents/tasks/chat-compressor.md
new file mode 100644
index 0000000..0580523
--- /dev/null
+++ b/sources/agents/tasks/chat-compressor.md
@@ -0,0 +1,10 @@
+You are a helpful assistant that creates concise summaries of conversations. Your summaries preserve key information, technical details, and the flow of discussion.
+
+Summarize the conversation provided by the user. Produce a summary that:
+
+1. Preserves all important context, decisions, and key information
+2. Maintains technical details, code snippets, file references, and specific examples
+3. Keeps the chronological flow of the discussion
+4. Is significantly shorter than the original (aim for 30-40% of original length)
+5. Is written in clear, structured format
+6. Uses markdown formatting for better readability
diff --git a/sources/agents/tasks/code-completion.md b/sources/agents/tasks/code-completion.md
new file mode 100644
index 0000000..88d0285
--- /dev/null
+++ b/sources/agents/tasks/code-completion.md
@@ -0,0 +1,20 @@
+Core Requirements:
+1. Continue code exactly from the cursor position, ensuring it properly connects with any existing code after the cursor
+2. Never repeat existing code before or after the cursor
+
+Specific Guidelines:
+- For function calls: Complete parameters with appropriate types and names
+- For class members: Respect access modifiers and class conventions
+- Respect existing indentation and formatting
+- Consider scope and visibility of referenced symbols
+- Ensure seamless integration with code both before and after the cursor
+
+Context Format:
+
+...code before the cursor......code after the cursor...
+
+
+Response Format:
+- No explanations or comments
+- Only include new characters needed to create valid code
+- Should be codeblock with language
diff --git a/sources/common/CMakeLists.txt b/sources/common/CMakeLists.txt
index fdb1a7b..d8997c6 100644
--- a/sources/common/CMakeLists.txt
+++ b/sources/common/CMakeLists.txt
@@ -2,6 +2,7 @@ add_library(Common INTERFACE)
 
 target_sources(Common INTERFACE
     ContextData.hpp
+    ResponseCleaner.hpp
 )
 
 target_include_directories(Common INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})
diff --git a/pluginllmcore/ResponseCleaner.hpp b/sources/common/ResponseCleaner.hpp
similarity index 97%
rename from pluginllmcore/ResponseCleaner.hpp
rename to sources/common/ResponseCleaner.hpp
index 74f4c30..8b2642c 100644
--- a/pluginllmcore/ResponseCleaner.hpp
+++ b/sources/common/ResponseCleaner.hpp
@@ -8,7 +8,7 @@
 #include 
 #include 
 
-namespace QodeAssist::PluginLLMCore {
+namespace QodeAssist {
 
 class ResponseCleaner
 {
@@ -100,5 +100,4 @@ private:
     }
 };
 
-} // namespace QodeAssist::PluginLLMCore
-
+} // namespace QodeAssist
diff --git a/sources/external/llmqore b/sources/external/llmqore
index 48e6dfb..ea44041 160000
--- a/sources/external/llmqore
+++ b/sources/external/llmqore
@@ -1 +1 @@
-Subproject commit 48e6dfb30db49162f5ebbdbedaa9049f5cfd077c
+Subproject commit ea44041b24f5220e6529812ecdb0a901080810f8
diff --git a/sources/providers/CMakeLists.txt b/sources/providers/CMakeLists.txt
index ce2e329..ea622b4 100644
--- a/sources/providers/CMakeLists.txt
+++ b/sources/providers/CMakeLists.txt
@@ -2,6 +2,8 @@ add_library(Providers STATIC
     ProviderID.hpp
     Provider.hpp Provider.cpp
     ProviderFactory.hpp ProviderFactory.cpp
+    GenericProvider.hpp GenericProvider.cpp
+    ClaudeCacheControl.hpp
 )
 
 target_link_libraries(Providers
diff --git a/providers/ClaudeCacheControl.hpp b/sources/providers/ClaudeCacheControl.hpp
similarity index 84%
rename from providers/ClaudeCacheControl.hpp
rename to sources/providers/ClaudeCacheControl.hpp
index f116375..40a5b4e 100644
--- a/providers/ClaudeCacheControl.hpp
+++ b/sources/providers/ClaudeCacheControl.hpp
@@ -8,6 +8,7 @@
 #include 
 #include 
 #include 
+#include 
 
 namespace QodeAssist::Providers::ClaudeCacheControl {
 
@@ -79,12 +80,16 @@ inline void applyToHistory(QJsonObject &request, const QJsonObject &cacheControl
     request["messages"] = messages;
 }
 
-inline void apply(QJsonObject &request, bool extendedTtl)
+inline void apply(QJsonObject &request, bool extendedTtl, const QStringList &breakpoints)
 {
     const QJsonObject cacheControl = buildBreakpoint(extendedTtl);
-    applyToSystem(request, cacheControl);
-    applyToTools(request, cacheControl);
-    applyToHistory(request, cacheControl);
+    const bool all = breakpoints.isEmpty();
+    if (all || breakpoints.contains(QStringLiteral("system")))
+        applyToSystem(request, cacheControl);
+    if (all || breakpoints.contains(QStringLiteral("tools")))
+        applyToTools(request, cacheControl);
+    if (all || breakpoints.contains(QStringLiteral("history")))
+        applyToHistory(request, cacheControl);
 }
 
 } // namespace QodeAssist::Providers::ClaudeCacheControl
diff --git a/sources/providers/GenericProvider.cpp b/sources/providers/GenericProvider.cpp
new file mode 100644
index 0000000..5f66e2e
--- /dev/null
+++ b/sources/providers/GenericProvider.cpp
@@ -0,0 +1,110 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "GenericProvider.hpp"
+
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "ProviderFactory.hpp"
+
+namespace QodeAssist::Providers {
+
+GenericProvider::GenericProvider(
+    QString name, ProviderID id, const ClientFactory &clientFactory, QObject *parent)
+    : Provider(parent)
+    , m_name(std::move(name))
+    , m_id(id)
+    , m_client(clientFactory(this))
+{}
+
+QString GenericProvider::name() const
+{
+    return m_name;
+}
+
+ProviderID GenericProvider::providerID() const
+{
+    return m_id;
+}
+
+::LLMQore::BaseClient *GenericProvider::client() const
+{
+    return m_client;
+}
+
+QFuture> GenericProvider::getInstalledModels(const QString &url)
+{
+    m_client->setUrl(url);
+    m_client->setApiKey(apiKey());
+    return m_client->listModels();
+}
+
+RequestID GenericProvider::sendRequest(
+    const QUrl &url, const QJsonObject &payload, const QString &endpoint)
+{
+    // Gemini carries the model in the URL and rejects unknown body fields, so
+    // the model/stream keys injected by the generic pipeline must be dropped.
+    if (m_id == ProviderID::GoogleAI) {
+        QJsonObject cleaned = payload;
+        cleaned.remove("model");
+        cleaned.remove("stream");
+        return Provider::sendRequest(url, cleaned, endpoint);
+    }
+    return Provider::sendRequest(url, payload, endpoint);
+}
+
+namespace {
+
+template
+GenericProvider::ClientFactory makeFactory()
+{
+    return [](QObject *parent) -> ::LLMQore::BaseClient * {
+        return new ClientT(QString(), QString(), QString(), parent);
+    };
+}
+
+} // namespace
+
+void registerBuiltinProviders()
+{
+    const auto reg = [](const QString &api,
+                        ProviderID id,
+                        GenericProvider::ClientFactory factory) {
+        ProviderFactory::registerType(api, [=](QObject *parent) -> Provider * {
+            return new GenericProvider(api, id, factory, parent);
+        });
+    };
+
+    reg("Claude", ProviderID::Claude, makeFactory<::LLMQore::ClaudeClient>());
+    reg("Google AI", ProviderID::GoogleAI, makeFactory<::LLMQore::GoogleAIClient>());
+    reg("llama.cpp", ProviderID::LlamaCpp, makeFactory<::LLMQore::LlamaCppClient>());
+    reg("LM Studio (Chat Completions)", ProviderID::LMStudio,
+        makeFactory<::LLMQore::OpenAIClient>());
+    reg("LM Studio (Responses API)", ProviderID::OpenAIResponses,
+        makeFactory<::LLMQore::OpenAIResponsesClient>());
+    reg("Mistral AI", ProviderID::MistralAI, makeFactory<::LLMQore::MistralClient>());
+    reg("Codestral", ProviderID::MistralAI, makeFactory<::LLMQore::MistralClient>());
+    reg("Ollama (Native)", ProviderID::Ollama, makeFactory<::LLMQore::OllamaClient>());
+    reg("Ollama (OpenAI-compatible)", ProviderID::OpenAICompatible,
+        makeFactory<::LLMQore::OpenAIClient>());
+    reg("OpenAI (Chat Completions)", ProviderID::OpenAI,
+        makeFactory<::LLMQore::OpenAIClient>());
+    reg("OpenAI (Responses API)", ProviderID::OpenAIResponses,
+        makeFactory<::LLMQore::OpenAIResponsesClient>());
+    reg("OpenAI Compatible", ProviderID::OpenAICompatible,
+        makeFactory<::LLMQore::OpenAIClient>());
+    reg("OpenRouter", ProviderID::OpenRouter, makeFactory<::LLMQore::OpenAIClient>());
+}
+
+} // namespace QodeAssist::Providers
diff --git a/sources/providers/GenericProvider.hpp b/sources/providers/GenericProvider.hpp
new file mode 100644
index 0000000..407222a
--- /dev/null
+++ b/sources/providers/GenericProvider.hpp
@@ -0,0 +1,50 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include 
+
+#include "Provider.hpp"
+
+namespace LLMQore {
+class BaseClient;
+}
+
+namespace QodeAssist::Providers {
+
+// A configuration-driven provider: it owns an LLMQore client and exposes a
+// fixed identity. Concrete behaviour (request shape) comes from the agent's
+// prompt template via Provider::prepareRequest, so a single class covers
+// every client_api by varying the client factory + metadata.
+class GenericProvider : public Provider
+{
+    Q_OBJECT
+public:
+    using ClientFactory = std::function<::LLMQore::BaseClient *(QObject *)>;
+
+    GenericProvider(
+        QString name,
+        ProviderID id,
+        const ClientFactory &clientFactory,
+        QObject *parent = nullptr);
+
+    QString name() const override;
+    QFuture> getInstalledModels(const QString &url) override;
+    ProviderID providerID() const override;
+    ::LLMQore::BaseClient *client() const override;
+
+    RequestID sendRequest(
+        const QUrl &url, const QJsonObject &payload, const QString &endpoint) override;
+
+private:
+    QString m_name;
+    ProviderID m_id;
+    ::LLMQore::BaseClient *m_client;
+};
+
+// Registers every built-in client_api into ProviderFactory. Must be called once
+// at plugin startup before any agent/session is created.
+void registerBuiltinProviders();
+
+} // namespace QodeAssist::Providers
diff --git a/sources/providers/Provider.cpp b/sources/providers/Provider.cpp
index a83c785..8455106 100644
--- a/sources/providers/Provider.cpp
+++ b/sources/providers/Provider.cpp
@@ -4,9 +4,11 @@
 
 #include "Provider.hpp"
 
+#include "ClaudeCacheControl.hpp"
 #include "PromptTemplate.hpp"
 
 #include 
+#include 
 #include 
 
 #include 
@@ -25,24 +27,27 @@ bool Provider::prepareRequest(
     PromptTemplate *prompt,
     const ContextData &context,
     bool isToolsEnabled,
-    bool isThinkingEnabled)
+    QString *errorOut)
 {
-    if (!prompt) {
-        LOG_MESSAGE(QString("Provider '%1': null template").arg(name()));
+    const auto fail = [errorOut](const QString &message) {
+        LOG_MESSAGE(message);
+        if (errorOut)
+            *errorOut = message;
         return false;
-    }
+    };
+
+    if (!prompt)
+        return fail(QString("Provider '%1': null template").arg(name()));
 
     if (!prompt->isSupportProvider(providerID())) {
-        LOG_MESSAGE(QString("Template '%1' doesn't support provider '%2'")
+        return fail(QString("Template '%1' doesn't support provider '%2'")
                         .arg(prompt->name(), name()));
-        return false;
     }
 
-    if (!prompt->buildFullRequest(request, context, isThinkingEnabled)) {
-        LOG_MESSAGE(
-            QString("Provider '%1': template '%2' failed to build request")
+    if (!prompt->buildFullRequest(request, context)) {
+        return fail(
+            QString("Provider '%1': template '%2' failed to build request (see log)")
                 .arg(name(), prompt->name()));
-        return false;
     }
 
     if (isToolsEnabled) {
@@ -51,9 +56,23 @@ bool Provider::prepareRequest(
             request["tools"] = toolsDefinitions;
         }
     }
+
+    if (m_promptCachingEnabled)
+        ClaudeCacheControl::apply(
+            request, m_promptCachingExtendedTtl, m_promptCacheBreakpoints);
+
     return true;
 }
 
+void Provider::setPromptCaching(bool enabled, bool extendedTtl, const QStringList &breakpoints)
+{
+    m_promptCachingEnabled = enabled;
+    m_promptCachingExtendedTtl = enabled && extendedTtl;
+    m_promptCacheBreakpoints = breakpoints;
+    if (auto *claude = qobject_cast<::LLMQore::ClaudeClient *>(client()))
+        claude->setUseExtendedCacheTTL(m_promptCachingExtendedTtl);
+}
+
 RequestID Provider::sendRequest(
     const QUrl &url, const QJsonObject &payload, const QString &endpoint)
 {
diff --git a/sources/providers/Provider.hpp b/sources/providers/Provider.hpp
index 633035a..07c1c1f 100644
--- a/sources/providers/Provider.hpp
+++ b/sources/providers/Provider.hpp
@@ -4,10 +4,10 @@
 
 #pragma once
 
-#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include "ContextData.hpp"
@@ -31,15 +31,6 @@ using Templates::ContextData;
 using Templates::PromptTemplate;
 using LLMQore::RequestID;
 
-enum class ProviderCapability {
-    Tools        = 0x1,
-    Thinking     = 0x2,
-    Image        = 0x4,
-    ModelListing = 0x8,
-};
-Q_DECLARE_FLAGS(ProviderCapabilities, ProviderCapability)
-Q_DECLARE_OPERATORS_FOR_FLAGS(ProviderCapabilities)
-
 class Provider : public QObject
 {
     Q_OBJECT
@@ -61,10 +52,9 @@ public:
         PromptTemplate *prompt,
         const ContextData &context,
         bool isToolsEnabled,
-        bool isThinkingEnabled);
+        QString *errorOut = nullptr);
     virtual QFuture> getInstalledModels(const QString &url) = 0;
     virtual ProviderID providerID() const = 0;
-    virtual ProviderCapabilities capabilities() const { return {}; }
 
     virtual ::LLMQore::BaseClient *client() const = 0;
 
@@ -73,9 +63,15 @@ public:
     void cancelRequest(const RequestID &requestId);
     ::LLMQore::ToolsManager *toolsManager() const;
 
+    void setPromptCaching(
+        bool enabled, bool extendedTtl, const QStringList &breakpoints = {});
+
 private:
     QString m_url;
     QString m_apiKey;
+    bool m_promptCachingEnabled = false;
+    bool m_promptCachingExtendedTtl = false;
+    QStringList m_promptCacheBreakpoints;
 };
 
 } // namespace QodeAssist::Providers
diff --git a/sources/providersConfig/ProviderInstanceFactory.cpp b/sources/providersConfig/ProviderInstanceFactory.cpp
index 23ecadd..daa924e 100644
--- a/sources/providersConfig/ProviderInstanceFactory.cpp
+++ b/sources/providersConfig/ProviderInstanceFactory.cpp
@@ -90,10 +90,8 @@ void ProviderInstanceFactory::reload()
 void ProviderInstanceFactory::rebuildIndexes()
 {
     m_nameIndex.clear();
-    m_instanceNamesCache.clear();
     m_knownClientApisCache.clear();
     m_nameIndex.reserve(static_cast(m_instances.size()));
-    m_instanceNamesCache.reserve(static_cast(m_instances.size()));
 
     std::sort(m_instances.begin(), m_instances.end(),
               [](const ProviderInstance &a, const ProviderInstance &b) {
@@ -104,7 +102,6 @@ void ProviderInstanceFactory::rebuildIndexes()
     for (qsizetype i = 0; i < static_cast(m_instances.size()); ++i) {
         const ProviderInstance &inst = m_instances[i];
         m_nameIndex.insert(inst.name.toCaseFolded(), i);
-        m_instanceNamesCache.append(inst.name);
         if (!seenApis.contains(inst.clientApi)) {
             seenApis.insert(inst.clientApi);
             m_knownClientApisCache.append(inst.clientApi);
@@ -133,29 +130,6 @@ void ProviderInstanceFactory::rewatchUserDir()
         m_watcher->addPath(fi.absoluteFilePath());
 }
 
-void ProviderInstanceFactory::registerInstance(ProviderInstance instance)
-{
-    Q_ASSERT_X(QThread::currentThread() == thread(),
-               Q_FUNC_INFO, "ProviderInstanceFactory must be used from its owner thread");
-    const QString validation = ProviderInstance::validate(instance, knownClientApis());
-    if (!validation.isEmpty()) {
-        qCWarning(providerInstanceFactoryLog).noquote()
-            << "Refusing to register provider instance:" << validation;
-        return;
-    }
-    const QString name = instance.name;
-    for (auto &existing : m_instances) {
-        if (existing.name == name) {
-            existing = std::move(instance);
-            emit instanceChanged(name);
-            return;
-        }
-    }
-    m_instances.push_back(std::move(instance));
-    rebuildIndexes();
-    emit instanceChanged(name);
-}
-
 const ProviderInstance *ProviderInstanceFactory::instanceByName(const QString &name) const
 {
     const auto it = m_nameIndex.constFind(name.toCaseFolded());
@@ -164,21 +138,6 @@ const ProviderInstance *ProviderInstanceFactory::instanceByName(const QString &n
     return &m_instances[it.value()];
 }
 
-QStringList ProviderInstanceFactory::instanceNames() const
-{
-    return m_instanceNamesCache;
-}
-
-QStringList ProviderInstanceFactory::instanceNamesForClientApi(const QString &clientApi) const
-{
-    QStringList out;
-    for (const auto &inst : m_instances) {
-        if (inst.clientApi == clientApi)
-            out.append(inst.name);
-    }
-    return out;
-}
-
 QStringList ProviderInstanceFactory::knownClientApis() const
 {
     return m_knownClientApisCache;
@@ -190,7 +149,6 @@ void ProviderInstanceFactory::clear()
                Q_FUNC_INFO, "ProviderInstanceFactory must be used from its owner thread");
     m_instances.clear();
     m_nameIndex.clear();
-    m_instanceNamesCache.clear();
     m_knownClientApisCache.clear();
     m_errors.clear();
     m_warnings.clear();
diff --git a/sources/providersConfig/ProviderInstanceFactory.hpp b/sources/providersConfig/ProviderInstanceFactory.hpp
index 6163a32..a22208b 100644
--- a/sources/providersConfig/ProviderInstanceFactory.hpp
+++ b/sources/providersConfig/ProviderInstanceFactory.hpp
@@ -31,8 +31,6 @@ public:
     [[nodiscard]] static QString userInstancesDir();
 
     [[nodiscard]] const ProviderInstance *instanceByName(const QString &name) const;
-    [[nodiscard]] QStringList instanceNames() const;
-    [[nodiscard]] QStringList instanceNamesForClientApi(const QString &clientApi) const;
     [[nodiscard]] QStringList knownClientApis() const;
     [[nodiscard]] const std::vector &instances() const noexcept
     {
@@ -42,7 +40,6 @@ public:
     [[nodiscard]] QStringList lastLoadErrors() const { return m_errors; }
     [[nodiscard]] QStringList lastLoadWarnings() const { return m_warnings; }
 
-    void registerInstance(ProviderInstance instance);
     void clear();
 
 signals:
@@ -55,7 +52,6 @@ private:
 
     std::vector m_instances;
     QHash m_nameIndex;
-    QStringList m_instanceNamesCache;
     QStringList m_knownClientApisCache;
     QStringList m_errors;
     QStringList m_warnings;
diff --git a/sources/providersConfig/ProviderLauncher.cpp b/sources/providersConfig/ProviderLauncher.cpp
index d1231d3..d66426c 100644
--- a/sources/providersConfig/ProviderLauncher.cpp
+++ b/sources/providersConfig/ProviderLauncher.cpp
@@ -237,11 +237,6 @@ ProviderLauncher::State ProviderLauncher::state(const QString &instanceName) con
     return slot ? slot->state : Idle;
 }
 
-bool ProviderLauncher::isReady(const QString &instanceName) const
-{
-    return state(instanceName) == Ready;
-}
-
 QString ProviderLauncher::lastError(const QString &instanceName) const
 {
     const Slot *slot = m_slots.value(instanceName, nullptr);
@@ -254,20 +249,6 @@ QByteArray ProviderLauncher::scrollback(const QString &instanceName) const
     return slot ? slot->scrollback : QByteArray{};
 }
 
-QStringList ProviderLauncher::activeInstances() const
-{
-    QStringList out;
-    for (auto it = m_slots.constBegin(); it != m_slots.constEnd(); ++it) {
-        if (it.value()->state != Idle)
-            out.append(it.key());
-    }
-    std::sort(out.begin(), out.end(),
-              [](const QString &a, const QString &b) {
-                  return a.compare(b, Qt::CaseInsensitive) < 0;
-              });
-    return out;
-}
-
 void ProviderLauncher::launchProcess(Slot *slot)
 {
     const LaunchConfig &cfg = slot->cfg;
diff --git a/sources/providersConfig/ProviderLauncher.hpp b/sources/providersConfig/ProviderLauncher.hpp
index 371de40..f0baf4a 100644
--- a/sources/providersConfig/ProviderLauncher.hpp
+++ b/sources/providersConfig/ProviderLauncher.hpp
@@ -48,12 +48,9 @@ public:
     void restart(const QString &instanceName, const LaunchConfig &cfg);
 
     [[nodiscard]] State state(const QString &instanceName) const;
-    [[nodiscard]] bool isReady(const QString &instanceName) const;
     [[nodiscard]] QString lastError(const QString &instanceName) const;
     [[nodiscard]] QByteArray scrollback(const QString &instanceName) const;
 
-    [[nodiscard]] QStringList activeInstances() const;
-
 signals:
     void stateChanged(const QString &instanceName, State newState);
     void bytesReceived(const QString &instanceName, const QByteArray &chunk);
diff --git a/sources/settings/AgentPipelinesPage.cpp b/sources/settings/AgentPipelinesPage.cpp
deleted file mode 100644
index 31400d1..0000000
--- a/sources/settings/AgentPipelinesPage.cpp
+++ /dev/null
@@ -1,274 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#include "AgentPipelinesPage.hpp"
-
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include "../../Version.hpp"
-#include "AgentRosterWidget.hpp"
-#include "AgentsSettingsPage.hpp"
-#include "Logger.hpp"
-#include "PipelinesConfig.hpp"
-#include "SettingsConstants.hpp"
-#include "SettingsTr.hpp"
-
-#include 
-
-namespace QodeAssist::Settings {
-
-AgentPipelinesPageNavigator::AgentPipelinesPageNavigator(QObject *parent)
-    : QObject(parent)
-{}
-
-namespace {
-
-constexpr int kSaveDebounceMs = 300;
-
-struct SlotMeta
-{
-    const char *title;
-    const char *hint;
-};
-
-const SlotMeta kSlotMeta[] = {
-    {TrConstants::CODE_COMPLETION,  TrConstants::SLOT_HINT_CODE_COMPLETION},
-    {TrConstants::CHAT_ASSISTANT,   TrConstants::SLOT_HINT_CHAT_ASSISTANT},
-    {TrConstants::CHAT_COMPRESSION, TrConstants::SLOT_HINT_CHAT_COMPRESSION},
-    {TrConstants::QUICK_REFACTOR,   TrConstants::SLOT_HINT_QUICK_REFACTOR},
-};
-
-class AgentPipelinesPageWidget : public Core::IOptionsPageWidget
-{
-    Q_OBJECT
-    Q_DISABLE_COPY_MOVE(AgentPipelinesPageWidget)
-public:
-    AgentPipelinesPageWidget(
-        const QPointer &agentFactory,
-        const QPointer &navigator,
-        const QPointer &agentsNavigator)
-        : m_agentFactory(agentFactory)
-        , m_navigator(navigator)
-        , m_agentsNavigator(agentsNavigator)
-    {
-        m_titleLabel = new QLabel(Tr::tr(TrConstants::AGENT_PIPELINES), this);
-        QFont tf = m_titleLabel->font();
-        tf.setBold(true);
-        tf.setPixelSize(13);
-        m_titleLabel->setFont(tf);
-
-        m_resetBtn = new QPushButton(Tr::tr(TrConstants::RESET_TO_DEFAULTS), this);
-
-        auto *headerRow = new QHBoxLayout;
-        headerRow->setContentsMargins(0, 0, 0, 0);
-        headerRow->setSpacing(8);
-        headerRow->addWidget(m_titleLabel);
-        headerRow->addStretch(1);
-        headerRow->addWidget(m_resetBtn);
-
-        auto *headerSep = new QFrame(this);
-        headerSep->setFrameShape(QFrame::HLine);
-        headerSep->setFrameShadow(QFrame::Sunken);
-
-        m_rosters[0] = new AgentRosterWidget(this);
-        m_rosters[1] = new AgentRosterWidget(this);
-        m_rosters[2] = new AgentRosterWidget(this);
-        m_rosters[3] = new AgentRosterWidget(this);
-
-        for (int i = 0; i < kRosterCount; ++i)
-            m_rosters[i]->setSlot(Tr::tr(kSlotMeta[i].title), Tr::tr(kSlotMeta[i].hint), {});
-
-        auto *content = new QWidget(this);
-        auto *contentLay = new QVBoxLayout(content);
-        contentLay->setContentsMargins(0, 0, 0, 0);
-        contentLay->setSpacing(12);
-        for (int i = 0; i < kRosterCount; ++i)
-            contentLay->addWidget(m_rosters[i]);
-        contentLay->addStretch(1);
-
-        auto *scroll = new QScrollArea(this);
-        scroll->setWidgetResizable(true);
-        scroll->setFrameShape(QFrame::NoFrame);
-        scroll->setWidget(content);
-
-        auto *root = new QVBoxLayout(this);
-        root->setContentsMargins(8, 8, 8, 8);
-        root->setSpacing(6);
-        root->addLayout(headerRow);
-        root->addWidget(headerSep);
-        root->addWidget(scroll, 1);
-
-        m_saveDebounce = new QTimer(this);
-        m_saveDebounce->setSingleShot(true);
-        m_saveDebounce->setInterval(kSaveDebounceMs);
-        connect(m_saveDebounce, &QTimer::timeout, this, [this]() { persistRosters(); });
-
-        loadFromSettings();
-
-        connect(m_resetBtn, &QPushButton::clicked, this, &AgentPipelinesPageWidget::onReset);
-
-        for (int i = 0; i < kRosterCount; ++i) {
-            connect(m_rosters[i], &AgentRosterWidget::editAgentRequested, this,
-                    &AgentPipelinesPageWidget::onEditAgent);
-            connect(m_rosters[i], &AgentRosterWidget::rosterChanged, this,
-                    [this](const QStringList &) { m_saveDebounce->start(); });
-        }
-    }
-
-    ~AgentPipelinesPageWidget() override
-    {
-        if (m_saveDebounce && m_saveDebounce->isActive()) {
-            m_saveDebounce->stop();
-            persistRosters();
-        }
-    }
-
-    void apply() final
-    {
-        if (m_saveDebounce && m_saveDebounce->isActive())
-            m_saveDebounce->stop();
-        persistRosters();
-    }
-
-private:
-    static constexpr int kRosterCount = 4;
-
-    void persistRosters()
-    {
-        PipelineRosters rosters;
-        rosters.codeCompletion = m_rosters[0]->roster();
-        rosters.chatAssistant = m_rosters[1]->roster();
-        rosters.chatCompression = m_rosters[2]->roster();
-        rosters.quickRefactor = m_rosters[3]->roster();
-        QString err;
-        if (!PipelinesConfig::save(rosters, &err)) {
-            LOG_MESSAGE(QStringLiteral("[Pipelines] save failed (%1): %2")
-                            .arg(PipelinesConfig::filePath(), err));
-            if (!m_saveErrorShown) {
-                m_saveErrorShown = true;
-                QMessageBox::warning(
-                    Core::ICore::dialogParent(),
-                    Tr::tr(TrConstants::AGENT_PIPELINES),
-                    tr("Failed to save pipelines.toml:\n%1\n\n"
-                       "Further save failures will only be logged.")
-                        .arg(err));
-            }
-        } else {
-            m_saveErrorShown = false;
-        }
-    }
-
-    void onReset()
-    {
-        const auto reply = QMessageBox::question(
-            Core::ICore::dialogParent(),
-            Tr::tr(TrConstants::RESET_SETTINGS),
-            Tr::tr(TrConstants::CONFIRMATION),
-            QMessageBox::Yes | QMessageBox::No);
-
-        if (reply != QMessageBox::Yes)
-            return;
-
-        QString err;
-        if (!PipelinesConfig::save(PipelineRosters::defaults(), &err))
-            LOG_MESSAGE(QStringLiteral("[Pipelines] failed to reset rosters: %1").arg(err));
-
-        m_saveErrorShown = false;
-        loadFromSettings();
-    }
-
-    void onEditAgent(const QString &name)
-    {
-        if (m_agentsNavigator)
-            m_agentsNavigator->requestSelectAgent(name);
-        if (m_navigator)
-            emit m_navigator->editAgentRequested(name);
-
-#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 83)
-        Core::ICore::showSettings(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID);
-#else
-        Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID);
-#endif
-    }
-
-    void loadFromSettings()
-    {
-        const PipelinesLoadResult lr = PipelinesConfig::load();
-        if (lr.status == PipelinesLoadStatus::ParseError
-            || lr.status == PipelinesLoadStatus::SchemaError) {
-            QMessageBox::warning(
-                Core::ICore::dialogParent(),
-                Tr::tr(TrConstants::AGENT_PIPELINES),
-                tr("pipelines.toml has issues — using defaults for affected entries:\n%1\n\n"
-                   "Click OK to continue. Changes you make here will overwrite the file.")
-                    .arg(lr.message));
-        }
-
-        AgentFactory *factory = m_agentFactory.data();
-        m_rosters[0]->setRoster(lr.rosters.codeCompletion, factory);
-        m_rosters[1]->setRoster(lr.rosters.chatAssistant, factory);
-        m_rosters[2]->setRoster(lr.rosters.chatCompression, factory);
-        m_rosters[3]->setRoster(lr.rosters.quickRefactor, factory);
-    }
-
-    QPointer m_agentFactory;
-    QPointer m_navigator;
-    QPointer m_agentsNavigator;
-
-    QLabel *m_titleLabel = nullptr;
-    QPushButton *m_resetBtn = nullptr;
-
-    AgentRosterWidget *m_rosters[kRosterCount] = {};
-
-    QTimer *m_saveDebounce = nullptr;
-    bool m_saveErrorShown = false;
-};
-
-class AgentPipelinesOptionsPage final : public Core::IOptionsPage
-{
-public:
-    AgentPipelinesOptionsPage(
-        AgentFactory *agentFactory,
-        AgentPipelinesPageNavigator *navigator,
-        AgentsPageNavigator *agentsNavigator)
-    {
-        setId(Constants::QODE_ASSIST_AGENT_PIPELINES_PAGE_ID);
-        setDisplayName(Tr::tr(TrConstants::AGENT_PIPELINES));
-        setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY);
-        const QPointer factoryPtr(agentFactory);
-        const QPointer navPtr(navigator);
-        const QPointer agentsNavPtr(agentsNavigator);
-        setWidgetCreator([factoryPtr, navPtr, agentsNavPtr] {
-            return new AgentPipelinesPageWidget(factoryPtr, navPtr, agentsNavPtr);
-        });
-    }
-};
-
-} // namespace
-
-std::unique_ptr createAgentPipelinesSettingsPage(
-    AgentFactory *agentFactory,
-    AgentPipelinesPageNavigator *navigator,
-    AgentsPageNavigator *agentsNavigator)
-{
-    return std::make_unique(
-        agentFactory, navigator, agentsNavigator);
-}
-
-} // namespace QodeAssist::Settings
-
-#include "AgentPipelinesPage.moc"
diff --git a/sources/settings/AgentPipelinesPage.hpp b/sources/settings/AgentPipelinesPage.hpp
deleted file mode 100644
index 17b6afe..0000000
--- a/sources/settings/AgentPipelinesPage.hpp
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-
-#include 
-#include 
-
-namespace Core {
-class IOptionsPage;
-}
-
-namespace QodeAssist {
-class AgentFactory;
-}
-
-namespace QodeAssist::Settings {
-
-class AgentsPageNavigator;
-
-class AgentPipelinesPageNavigator : public QObject
-{
-    Q_OBJECT
-    Q_DISABLE_COPY_MOVE(AgentPipelinesPageNavigator)
-public:
-    explicit AgentPipelinesPageNavigator(QObject *parent = nullptr);
-
-signals:
-    void editAgentRequested(const QString &agentName);
-};
-
-std::unique_ptr createAgentPipelinesSettingsPage(
-    AgentFactory *agentFactory,
-    AgentPipelinesPageNavigator *navigator,
-    AgentsPageNavigator *agentsNavigator);
-
-} // namespace QodeAssist::Settings
diff --git a/sources/settings/AgentRosterWidget.cpp b/sources/settings/AgentRosterWidget.cpp
index ebb134e..d2b99f1 100644
--- a/sources/settings/AgentRosterWidget.cpp
+++ b/sources/settings/AgentRosterWidget.cpp
@@ -16,6 +16,7 @@
 #include 
 
 #include 
+#include 
 
 #include 
 #include 
@@ -23,6 +24,7 @@
 #include 
 
 #include "AgentSelectionDialog.hpp"
+#include "SettingsTheme.hpp"
 #include "SettingsTr.hpp"
 
 namespace QodeAssist::Settings {
@@ -73,73 +75,67 @@ struct Theme
 
 Theme::Pill Theme::pill(PillKind k) const
 {
-    if (dark) {
-        switch (k) {
-        case PillKind::Template: return {{0x2c, 0x3f, 0x5a}, {0xcf, 0xe1, 0xf7}, {0x4a, 0x62, 0x86}};
-        case PillKind::On:       return {{0x2f, 0x45, 0x30}, {0xbc, 0xe0, 0xbd}, {0x4a, 0x6c, 0x4b}};
-        case PillKind::Off:      return {{0x3a, 0x3a, 0x3a}, {0x8a, 0x8a, 0x8a}, {0x4a, 0x4a, 0x4a}};
-        case PillKind::User:     return {{0x4a, 0x3f, 0x24}, {0xe6, 0xcd, 0x92}, {0x6a, 0x5a, 0x30}};
-        case PillKind::Active:   return {{0x3a, 0x6a, 0x28}, {0xff, 0xff, 0xff}, {0x4a, 0x80, 0x38}};
-        case PillKind::Match:    return {{0x27, 0x38, 0x4a}, {0xbd, 0xd7, 0xee}, {0x3f, 0x5a, 0x78}};
-        case PillKind::Tag:      return {{0x2e, 0x2e, 0x3a}, {0xb9, 0xb9, 0xcf}, {0x46, 0x46, 0x5a}};
-        case PillKind::Neutral:  return {{0x3a, 0x3a, 0x3a}, {0xcf, 0xcf, 0xcf}, {0x4a, 0x4a, 0x4a}};
-        }
-    } else {
-        switch (k) {
-        case PillKind::Template: return {{0xdb, 0xe7, 0xf6}, {0x1f, 0x3f, 0x73}, {0xa8, 0xc1, 0xe0}};
-        case PillKind::On:       return {{0xdb, 0xe9, 0xd3}, {0x2c, 0x5a, 0x1c}, {0xa3, 0xbc, 0x97}};
-        case PillKind::Off:      return {{0xec, 0xec, 0xec}, {0x7a, 0x7a, 0x7a}, {0xc8, 0xc8, 0xc8}};
-        case PillKind::User:     return {{0xf0, 0xe4, 0xcf}, {0x75, 0x54, 0x1a}, {0xcd, 0xb9, 0x8a}};
-        case PillKind::Active:   return {{0x2c, 0x5a, 0x1c}, {0xff, 0xff, 0xff}, {0x2c, 0x5a, 0x1c}};
-        case PillKind::Match:    return {{0xd6, 0xe8, 0xf7}, {0x1a, 0x4a, 0x7a}, {0x8a, 0xb1, 0xd5}};
-        case PillKind::Tag:      return {{0xe7, 0xe7, 0xf2}, {0x46, 0x46, 0x6e}, {0xc1, 0xc1, 0xd5}};
-        case PillKind::Neutral:  return {{0xe3, 0xe3, 0xe3}, {0x3a, 0x3a, 0x3a}, {0xbc, 0xbc, 0xbc}};
-        }
+    const QColor bg = Utils::creatorColor(Utils::Theme::BackgroundColorNormal);
+    const QColor fg = Utils::creatorColor(Utils::Theme::TextColorNormal);
+    const QColor muted = Utils::creatorColor(Utils::Theme::PanelTextColorMid);
+    const QColor faint = Utils::creatorColor(Utils::Theme::TextColorDisabled);
+    const QColor link = Utils::creatorColor(Utils::Theme::TextColorLink);
+    const QColor selBg = Utils::creatorColor(Utils::Theme::BackgroundColorSelected);
+    const QColor line = Utils::creatorColor(Utils::Theme::SplitterColor);
+    const bool isDark = bg.lightness() < 128;
+
+    const auto tint = [&](const QColor &role) -> Pill {
+        return {mix(bg, role, 0.16), isDark ? mix(fg, role, 0.65) : role, mix(bg, role, 0.42)};
+    };
+
+    switch (k) {
+    case PillKind::Template:
+        return tint(link);
+    case PillKind::On:
+        return tint(Utils::creatorColor(Utils::Theme::IconsRunColor));
+    case PillKind::Off:
+        return {mix(bg, faint, 0.12), faint, mix(bg, faint, 0.30)};
+    case PillKind::User:
+        return tint(Utils::creatorColor(Utils::Theme::IconsWarningColor));
+    case PillKind::Active:
+        return {selBg, fg, link};
+    case PillKind::Match:
+        return tint(link);
+    case PillKind::Tag:
+    case PillKind::Neutral:
+        return {mix(bg, muted, 0.14), muted, mix(bg, muted, 0.32)};
     }
-    return {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}};
+    return {bg, fg, line};
 }
 
-Theme themeFor(const QWidget *w)
+Theme themeFor(const QWidget *)
 {
-    const QPalette pal = w ? w->palette() : QApplication::palette();
-    const bool dark = pal.color(QPalette::Window).lightness() < 128;
+    const QColor bg = Utils::creatorColor(Utils::Theme::BackgroundColorNormal);
+    const QColor fg = Utils::creatorColor(Utils::Theme::TextColorNormal);
+    const QColor muted = Utils::creatorColor(Utils::Theme::PanelTextColorMid);
+    const QColor faint = Utils::creatorColor(Utils::Theme::TextColorDisabled);
+    const QColor line = Utils::creatorColor(Utils::Theme::SplitterColor);
+    const QColor selBg = Utils::creatorColor(Utils::Theme::BackgroundColorSelected);
+    const bool isDark = bg.lightness() < 128;
+
     Theme t;
-    t.dark = dark;
-    if (dark) {
-        t.pageBg = {0x2b, 0x2b, 0x2b};
-        t.cardBg = {0x2f, 0x2f, 0x2f};
-        t.cardBorder = {0x4a, 0x4a, 0x4a};
-        t.groupBorder = {0x4a, 0x4a, 0x4a};
-        t.rowSeparator = {0x3a, 0x3a, 0x3a};
-        t.rowMatchBg = {0x2d, 0x3d, 0x24};
-        t.listHeader = {0x25, 0x25, 0x25};
-        t.text = {0xe6, 0xe6, 0xe6};
-        t.textSoft = {0xc2, 0xc2, 0xc2};
-        t.textMute = {0x9a, 0x9a, 0x9a};
-        t.textFaint = {0x7a, 0x7a, 0x7a};
-        t.matchChipBg = {0x26, 0x26, 0x26};
-        t.matchChipBorder = {0x3a, 0x3a, 0x3a};
-        t.matchChipText = {0xc2, 0xc2, 0xc2};
-        t.codeBg = {0x26, 0x26, 0x26};
-        t.codeBorder = {0x3a, 0x3a, 0x3a};
-    } else {
-        t.pageBg = {0xed, 0xed, 0xed};
-        t.cardBg = {0xf8, 0xf8, 0xf8};
-        t.cardBorder = {0xbd, 0xbd, 0xbd};
-        t.groupBorder = {0xb8, 0xb8, 0xb8};
-        t.rowSeparator = {0xe0, 0xe0, 0xe0};
-        t.rowMatchBg = {0xe8, 0xf3, 0xdf};
-        t.listHeader = {0xe8, 0xe8, 0xe8};
-        t.text = {0x1c, 0x1c, 0x1c};
-        t.textSoft = {0x3a, 0x3a, 0x3a};
-        t.textMute = {0x5a, 0x5a, 0x5a};
-        t.textFaint = {0x8a, 0x8a, 0x8a};
-        t.matchChipBg = {0xea, 0xea, 0xea};
-        t.matchChipBorder = {0xcf, 0xcf, 0xcf};
-        t.matchChipText = {0x3a, 0x3a, 0x3a};
-        t.codeBg = {0xea, 0xea, 0xea};
-        t.codeBorder = {0xcf, 0xcf, 0xcf};
-    }
+    t.dark = isDark;
+    t.pageBg = mix(bg, isDark ? QColor(Qt::black) : QColor(Qt::white), 0.04);
+    t.cardBg = bg;
+    t.cardBorder = line;
+    t.groupBorder = line;
+    t.rowSeparator = line;
+    t.rowMatchBg = selBg;
+    t.listHeader = mix(bg, fg, 0.05);
+    t.text = fg;
+    t.textSoft = muted;
+    t.textMute = muted;
+    t.textFaint = faint;
+    t.matchChipBg = mix(bg, fg, 0.06);
+    t.matchChipBorder = line;
+    t.matchChipText = muted;
+    t.codeBg = mix(bg, fg, 0.06);
+    t.codeBorder = line;
     return t;
 }
 
@@ -153,7 +149,7 @@ QString pillStyle(const Theme &t, PillKind k)
     const auto p = t.pill(k);
     return QStringLiteral(
                "background:%1; color:%2; border:1px solid %3;"
-               "padding:0px 5px; border-radius:2px;"
+               "padding:1px 6px; border-radius:3px;"
                "font-size:10px;")
         .arg(hex(p.bg), hex(p.fg), hex(p.border));
 }
@@ -199,9 +195,11 @@ public:
     AgentRosterRow(int index,
                    const QString &name,
                    const AgentConfig *cfg,
+                   const QString &model,
                    bool active,
                    bool first,
                    bool last,
+                   bool orderable,
                    const Theme &theme,
                    QWidget *parent = nullptr);
 
@@ -217,7 +215,7 @@ private:
                                bool active,
                                bool isUser,
                                const Theme &t);
-    QWidget *buildMetaLine(const AgentConfig *cfg, bool active, const Theme &t);
+    QWidget *buildMetaLine(const AgentConfig *cfg, bool active, bool showMatch, const Theme &t);
     QWidget *buildActions(const Theme &t, bool first, bool last);
 
     int m_index;
@@ -226,9 +224,11 @@ private:
 AgentRosterRow::AgentRosterRow(int index,
                                const QString &name,
                                const AgentConfig *cfg,
+                               const QString &model,
                                bool active,
                                bool first,
                                bool last,
+                               bool orderable,
                                const Theme &theme,
                                QWidget *parent)
     : QFrame(parent), m_index(index)
@@ -246,56 +246,57 @@ AgentRosterRow::AgentRosterRow(int index,
     outer->setContentsMargins(8, 6, 8, 6);
     outer->setSpacing(8);
 
-    auto *moveCol = new QVBoxLayout;
-    moveCol->setContentsMargins(0, 0, 0, 0);
-    moveCol->setSpacing(1);
-    auto makeArrow = [&](const QString &glyph, const QString &tip, bool enabled) {
-        auto *b = new QToolButton(this);
-        b->setText(glyph);
-        b->setToolTip(tip);
-        b->setEnabled(enabled);
-        b->setAutoRaise(true);
-        b->setFixedSize(18, 14);
-        QFont f = b->font();
-        f.setPointSizeF(f.pointSizeF() * 0.75);
-        b->setFont(f);
-        return b;
-    };
-    auto *upBtn = makeArrow(QStringLiteral("▲"), tr("Move up"), !first);
-    auto *dnBtn = makeArrow(QStringLiteral("▼"), tr("Move down"), !last);
-    moveCol->addWidget(upBtn);
-    moveCol->addWidget(dnBtn);
-    outer->addLayout(moveCol);
+    if (orderable) {
+        auto *moveCol = new QVBoxLayout;
+        moveCol->setContentsMargins(0, 0, 0, 0);
+        moveCol->setSpacing(1);
+        auto makeArrow = [&](const QString &glyph, const QString &tip, bool enabled) {
+            auto *b = new QToolButton(this);
+            b->setText(glyph);
+            b->setToolTip(tip);
+            b->setEnabled(enabled);
+            b->setAutoRaise(true);
+            b->setFixedSize(18, 14);
+            QFont f = b->font();
+            f.setPointSizeF(f.pointSizeF() * 0.75);
+            b->setFont(f);
+            return b;
+        };
+        auto *upBtn = makeArrow(QStringLiteral("▲"), tr("Move up"), !first);
+        auto *dnBtn = makeArrow(QStringLiteral("▼"), tr("Move down"), !last);
+        moveCol->addWidget(upBtn);
+        moveCol->addWidget(dnBtn);
+        outer->addLayout(moveCol);
 
-    auto *idxLbl = new QLabel(QStringLiteral("%1.").arg(index + 1), this);
-    QFont monoFont = QFontDatabase::systemFont(QFontDatabase::FixedFont);
-    idxLbl->setFont(monoFont);
-    QPalette idxPal = idxLbl->palette();
-    idxPal.setColor(QPalette::WindowText, active ? theme.text : theme.textFaint);
-    idxLbl->setPalette(idxPal);
-    idxLbl->setFixedWidth(22);
-    idxLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
-    outer->addWidget(idxLbl);
+        connect(upBtn, &QToolButton::clicked, this, [this]() { emit moveUpRequested(m_index); });
+        connect(dnBtn, &QToolButton::clicked, this, [this]() { emit moveDownRequested(m_index); });
+
+        auto *idxLbl = new QLabel(QStringLiteral("%1.").arg(index + 1), this);
+        QFont monoFont = QFontDatabase::systemFont(QFontDatabase::FixedFont);
+        idxLbl->setFont(monoFont);
+        QPalette idxPal = idxLbl->palette();
+        idxPal.setColor(QPalette::WindowText, active ? theme.text : theme.textFaint);
+        idxLbl->setPalette(idxPal);
+        idxLbl->setFixedWidth(22);
+        idxLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
+        outer->addWidget(idxLbl);
+    }
 
     auto *body = new QVBoxLayout;
     body->setContentsMargins(0, 0, 0, 0);
     body->setSpacing(2);
 
     const QString displayName = cfg ? cfg->name : tr("%1 (missing)").arg(name);
-    const QString model = cfg ? cfg->model : QString();
     const bool isUser = cfg && cfg->isUserSource();
 
     body->addWidget(buildIdentityLine(displayName, model, active, isUser, theme));
-    body->addWidget(buildMetaLine(cfg, active, theme));
+    body->addWidget(buildMetaLine(cfg, active, /*showMatch*/ orderable, theme));
     outer->addLayout(body, /*stretch*/ 1);
 
     outer->addWidget(buildActions(theme, first, last));
 
     if (cfg && !cfg->description.isEmpty())
         setToolTip(cfg->description);
-
-    connect(upBtn, &QToolButton::clicked, this, [this]() { emit moveUpRequested(m_index); });
-    connect(dnBtn, &QToolButton::clicked, this, [this]() { emit moveDownRequested(m_index); });
 }
 
 QWidget *AgentRosterRow::buildIdentityLine(const QString &displayName,
@@ -333,7 +334,8 @@ QWidget *AgentRosterRow::buildIdentityLine(const QString &displayName,
     return w;
 }
 
-QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, const Theme &t)
+QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, bool showMatch,
+                                       const Theme &t)
 {
     auto *w = new QWidget(this);
     auto *line = new QHBoxLayout(w);
@@ -341,31 +343,33 @@ QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, cons
     line->setSpacing(4);
 
     if (cfg) {
-        const auto sm = summarise(cfg->match);
-        auto *chip = new QLabel(w);
-        const QColor bg = active ? t.pill(PillKind::Match).bg : t.matchChipBg;
-        const QColor bd = active ? t.pill(PillKind::Match).border : t.matchChipBorder;
-        const QColor fg = active ? t.pill(PillKind::Match).fg : t.matchChipText;
-        QString chipText = QStringLiteral(
-                               "%1 "
-                               "%3: %4")
-                               .arg(sm.icon,
-                                    hex(active ? t.pill(PillKind::Match).fg : t.textFaint),
-                                    sm.kind,
-                                    sm.value.toHtmlEscaped());
-        chip->setTextFormat(Qt::RichText);
-        chip->setText(chipText);
-        chip->setStyleSheet(QStringLiteral("background:%1; color:%2; border:1px solid %3;"
-                                           "padding:0px 6px; border-radius:2px; font-size:11px;")
-                                .arg(hex(bg), hex(fg), hex(bd)));
-        chip->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
-        line->addWidget(chip);
+        if (showMatch) {
+            const auto sm = summarise(cfg->match);
+            auto *chip = new QLabel(w);
+            const QColor bg = active ? t.pill(PillKind::Match).bg : t.matchChipBg;
+            const QColor bd = active ? t.pill(PillKind::Match).border : t.matchChipBorder;
+            const QColor fg = active ? t.pill(PillKind::Match).fg : t.matchChipText;
+            QString chipText = QStringLiteral(
+                                   "%1 "
+                                   "%3: %4")
+                                   .arg(sm.icon,
+                                        hex(active ? t.pill(PillKind::Match).fg : t.textFaint),
+                                        sm.kind,
+                                        sm.value.toHtmlEscaped());
+            chip->setTextFormat(Qt::RichText);
+            chip->setText(chipText);
+            chip->setStyleSheet(QStringLiteral("background:%1; color:%2; border:1px solid %3;"
+                                               "padding:1px 6px; border-radius:3px; font-size:11px;")
+                                    .arg(hex(bg), hex(fg), hex(bd)));
+            chip->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
+            line->addWidget(chip);
 
-        auto *arrow = new QLabel(QStringLiteral("→"), w);
-        QPalette ap = arrow->palette();
-        ap.setColor(QPalette::WindowText, t.textFaint);
-        arrow->setPalette(ap);
-        line->addWidget(arrow);
+            auto *arrow = new QLabel(QStringLiteral("→"), w);
+            QPalette ap = arrow->palette();
+            ap.setColor(QPalette::WindowText, t.textFaint);
+            arrow->setPalette(ap);
+            line->addWidget(arrow);
+        }
 
         if (!cfg->providerInstance.isEmpty())
             line->addWidget(makePill(cfg->providerInstance, PillKind::Template, t, w));
@@ -432,13 +436,10 @@ AgentRosterWidget::AgentRosterWidget(QWidget *parent)
     auto *titleRow = new QHBoxLayout;
     titleRow->setContentsMargins(0, 0, 0, 0);
     titleRow->setSpacing(6);
-    m_accentDot = new QLabel(this);
-    m_accentDot->setFixedSize(10, 10);
     m_titleLabel = new QLabel(this);
     QFont tf = m_titleLabel->font();
     tf.setBold(true);
     m_titleLabel->setFont(tf);
-    titleRow->addWidget(m_accentDot);
     titleRow->addWidget(m_titleLabel);
     titleRow->addStretch(1);
     outer->addLayout(titleRow);
@@ -456,13 +457,13 @@ AgentRosterWidget::AgentRosterWidget(QWidget *parent)
     m_rowsFrame = new QFrame(this);
     m_rowsFrame->setObjectName(QStringLiteral("rosterCard"));
     m_rowsFrame->setStyleSheet(
-        QStringLiteral("QFrame#rosterCard { background:%1; border:1px solid %2; border-radius:2px; }")
+        QStringLiteral("QFrame#rosterCard { background:%1; border:1px solid %2; border-radius:4px; }")
             .arg(hex(t.cardBg), hex(t.cardBorder)));
     m_rowsLayout = new QVBoxLayout(m_rowsFrame);
     m_rowsLayout->setContentsMargins(0, 0, 0, 0);
     m_rowsLayout->setSpacing(0);
 
-    m_emptyHint = new QLabel(tr("No agents in roster. Click \"Add agent…\" to populate."),
+    m_emptyHint = new QLabel(tr("No agent selected yet — use \"Add agent…\" below."),
                              m_rowsFrame);
     m_emptyHint->setAlignment(Qt::AlignCenter);
     m_emptyHint->setContentsMargins(10, 12, 10, 12);
@@ -495,39 +496,71 @@ AgentRosterWidget::AgentRosterWidget(QWidget *parent)
     connect(m_addBtn, &QPushButton::clicked, this, &AgentRosterWidget::onAddClicked);
 }
 
-void AgentRosterWidget::setSlot(const QString &title, const QString &hint, const QColor &accent)
+void AgentRosterWidget::setSlot(
+    const QString &title,
+    const QString &hint,
+    const QStringList &presetTags)
 {
+    m_presetTags = presetTags;
     m_titleLabel->setText(title);
     m_hintLabel->setText(hint);
     m_hintLabel->setVisible(!hint.isEmpty());
-    if (accent.isValid()) {
-        m_accentDot->setStyleSheet(
-            QStringLiteral("background:%1; border:1px solid %2;")
-                .arg(accent.name(), accent.darker(140).name()));
-        m_accentDot->setVisible(true);
-    } else {
-        m_accentDot->setStyleSheet({});
-        m_accentDot->setVisible(false);
-    }
 }
 
 void AgentRosterWidget::setRoster(const QStringList &names, AgentFactory *factory)
 {
-    m_factory = factory;
+    if (m_factory != factory) {
+        QObject::disconnect(m_factoryConn);
+        QObject::disconnect(m_modelConn);
+        m_factory = factory;
+        if (m_factory) {
+            m_factoryConn = connect(m_factory, &AgentFactory::agentsChanged, this,
+                                    [this] { rebuildRows(); });
+            m_modelConn = connect(m_factory, &AgentFactory::agentModelChanged, this,
+                                  [this](const QString &name) {
+                                      if (m_names.contains(name))
+                                          rebuildRows();
+                                  });
+        }
+    }
     m_names = names;
     rebuildRows();
 }
 
-void AgentRosterWidget::setRoutingContext(const AgentRouter::Context &ctx)
+void AgentRosterWidget::setOrderable(bool orderable)
 {
-    m_routingCtx = ctx;
-    recomputeActive();
+    if (m_orderable == orderable)
+        return;
+    m_orderable = orderable;
     rebuildRows();
 }
 
+void AgentRosterWidget::setSingle(bool single)
+{
+    if (m_single == single)
+        return;
+    m_single = single;
+    if (single)
+        m_orderable = false;
+    rebuildRows();
+}
+
+void AgentRosterWidget::applyMode()
+{
+    const bool hasEntry = !m_names.isEmpty();
+    m_addBtn->setText((m_single && hasEntry) ? tr("Change agent…") : tr("+ Add agent…"));
+
+    QString footer;
+    if (!m_single)
+        footer = m_orderable ? tr("first matching agent is used")
+                             : tr("you pick the active agent in the chat panel");
+    m_footerHint->setText(footer);
+    m_footerHint->setVisible(!footer.isEmpty());
+}
+
 void AgentRosterWidget::recomputeActive()
 {
-    if (!m_factory || m_names.isEmpty()
+    if (!m_orderable || !m_factory || m_names.isEmpty()
         || (m_routingCtx.filePath.isEmpty() && m_routingCtx.projectName.isEmpty())) {
         m_activeIndex = -1;
         return;
@@ -550,6 +583,8 @@ void AgentRosterWidget::rebuildRows()
         delete it;
     }
 
+    applyMode();
+
     if (m_names.isEmpty()) {
         m_emptyHint->setVisible(true);
         m_rowsLayout->addWidget(m_emptyHint);
@@ -562,12 +597,15 @@ void AgentRosterWidget::rebuildRows()
     for (int i = 0; i < m_names.size(); ++i) {
         const QString &name = m_names.at(i);
         const AgentConfig *cfg = m_factory ? m_factory->configByName(name) : nullptr;
+        const QString model = cfg ? cfg->model : QString();
         auto *row = new AgentRosterRow(i,
                                        name,
                                        cfg,
+                                       model,
                                        i == m_activeIndex,
                                        /*first*/ i == 0,
                                        /*last*/ i == m_names.size() - 1,
+                                       m_orderable,
                                        t,
                                        m_rowsFrame);
         connect(row, &AgentRosterRow::moveUpRequested, this, &AgentRosterWidget::onRowMoveUp);
@@ -584,16 +622,25 @@ void AgentRosterWidget::onAddClicked()
         return;
 
     AgentSelectionDialog dialog(m_factory->configs(),
-                                /*currentName*/ QString{},
+                                /*currentName*/ m_single ? m_names.value(0) : QString{},
                                 m_factory.data(),
+                                m_presetTags,
                                 Core::ICore::dialogParent());
     if (dialog.exec() != QDialog::Accepted)
         return;
     const QString picked = dialog.selectedName();
-    if (picked.isEmpty() || m_names.contains(picked))
+    if (picked.isEmpty())
         return;
 
-    m_names.append(picked);
+    if (m_single) {
+        if (m_names.size() == 1 && m_names.first() == picked)
+            return;
+        m_names = {picked};
+    } else {
+        if (m_names.contains(picked))
+            return;
+        m_names.append(picked);
+    }
     rebuildRows();
     emit rosterChanged(m_names);
 }
diff --git a/sources/settings/AgentRosterWidget.hpp b/sources/settings/AgentRosterWidget.hpp
index 4a78db5..b7a1fe7 100644
--- a/sources/settings/AgentRosterWidget.hpp
+++ b/sources/settings/AgentRosterWidget.hpp
@@ -4,7 +4,6 @@
 
 #pragma once
 
-#include 
 #include 
 #include 
 #include 
@@ -32,12 +31,23 @@ class AgentRosterWidget : public QWidget
 public:
     explicit AgentRosterWidget(QWidget *parent = nullptr);
 
-    void setSlot(const QString &title, const QString &hint, const QColor &accent);
+    void setSlot(
+        const QString &title,
+        const QString &hint,
+        const QStringList &presetTags = {});
     void setRoster(const QStringList &names, AgentFactory *factory);
 
-    [[nodiscard]] QStringList roster() const { return m_names; }
+    // When false, the list is an unordered set: no move arrows, no position
+    // numbers, no "first matching" routing hint. Used by pipelines where the
+    // user — not a router — chooses the agent (e.g. the chat picker).
+    void setOrderable(bool orderable);
 
-    void setRoutingContext(const AgentRouter::Context &ctx);
+    // When true, the card holds at most one agent: "Add" becomes "Change",
+    // selecting replaces the current entry, and the routing footer is hidden.
+    // Implies a non-orderable list. Used by single-agent pipelines.
+    void setSingle(bool single);
+
+    [[nodiscard]] QStringList roster() const { return m_names; }
 
 signals:
     void rosterChanged(const QStringList &names);
@@ -46,6 +56,7 @@ signals:
 private:
     void rebuildRows();
     void recomputeActive();
+    void applyMode();
 
     void onAddClicked();
     void onRowMoveUp(int index);
@@ -54,11 +65,15 @@ private:
     void onRowEdit(int index);
 
     QStringList m_names;
+    QStringList m_presetTags;
     QPointer m_factory;
+    QMetaObject::Connection m_factoryConn;
+    QMetaObject::Connection m_modelConn;
     AgentRouter::Context m_routingCtx;
     int m_activeIndex = -1;
+    bool m_orderable = true;
+    bool m_single = false;
 
-    QLabel *m_accentDot = nullptr;
     QLabel *m_titleLabel = nullptr;
     QLabel *m_hintLabel = nullptr;
     QFrame *m_rowsFrame = nullptr;
diff --git a/sources/settings/AgentSelectionDialog.cpp b/sources/settings/AgentSelectionDialog.cpp
index 7b98069..825fced 100644
--- a/sources/settings/AgentSelectionDialog.cpp
+++ b/sources/settings/AgentSelectionDialog.cpp
@@ -4,9 +4,10 @@
 
 #include "AgentSelectionDialog.hpp"
 
-#include "AgentSlotWidget.hpp"
 #include "PipelinesConfig.hpp"
+#include "Pill.hpp"
 #include "SettingsTr.hpp"
+#include "TagFilterStrip.hpp"
 
 #include 
 
@@ -23,6 +24,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -56,6 +58,14 @@ void ListRowCard::setSelected(bool selected)
     applyTheme();
 }
 
+bool ListRowCard::hasAllTags(const QSet &activeTags) const
+{
+    for (const QString &tag : activeTags)
+        if (!m_itemTags.contains(tag))
+            return false;
+    return true;
+}
+
 void ListRowCard::buildSearchHaystack(const QStringList &parts)
 {
     m_searchHaystack = parts.join(QLatin1Char(' ')).toLower();
@@ -124,8 +134,9 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
     : ListRowCard(parent)
 {
     setItemName(cfg.name);
+    setItemTags(cfg.tags);
     QStringList haystack{cfg.name, cfg.providerInstance, cfg.model,
-                         cfg.description, cfg.role,
+                         cfg.description, cfg.systemPrompt,
                          cfg.endpoint};
     haystack += cfg.tags;
     buildSearchHaystack(haystack);
@@ -147,10 +158,7 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
 
     Pill *sourcePill = nullptr;
     if (cfg.isUserSource()) {
-        sourcePill = new Pill(
-            Pill::User,
-            cfg.overridesBundled ? Tr::tr("Override") : Tr::tr("User"),
-            this);
+        sourcePill = new Pill(Pill::User, Tr::tr("User"), this);
     }
 
     auto *description = new QLabel(this);
@@ -234,8 +242,8 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
         tooltip += cfg.description + QStringLiteral("\n\n");
     if (!cfg.providerInstance.isEmpty())
         tooltip += Tr::tr("Provider instance: %1\n").arg(cfg.providerInstance);
-    if (!cfg.role.isEmpty())
-        tooltip += Tr::tr("Role: %1\n").arg(cfg.role);
+    if (!cfg.systemPrompt.isEmpty())
+        tooltip += Tr::tr("System prompt: %1\n").arg(cfg.systemPrompt);
     if (!cfg.endpoint.isEmpty())
         tooltip += Tr::tr("Endpoint: %1\n").arg(cfg.endpoint);
     setToolTip(tooltip.trimmed());
@@ -257,6 +265,9 @@ ProviderSection::ProviderSection(const QString &name, QWidget *parent)
     ap.setColor(QPalette::WindowText, ap.color(QPalette::Mid));
     m_arrow->setPalette(ap);
 
+    m_count = new QLabel;
+    CardStyle::applySectionFont(m_count);
+
     m_header = new QFrame;
     m_header->setObjectName(QStringLiteral("ProviderHeader"));
     m_header->setCursor(Qt::PointingHandCursor);
@@ -267,6 +278,7 @@ ProviderSection::ProviderSection(const QString &name, QWidget *parent)
     headerLayout->addWidget(m_arrow);
     headerLayout->addWidget(m_label);
     headerLayout->addStretch(1);
+    headerLayout->addWidget(m_count);
     m_header->installEventFilter(this);
 
     m_content = new QWidget;
@@ -290,15 +302,17 @@ void ProviderSection::addCard(ListRowCard *card)
     m_cards.append(card);
 }
 
-int ProviderSection::applyFilter(const QString &needle)
+int ProviderSection::applyFilter(const QString &needle, const QSet &activeTags)
 {
     int visible = 0;
     for (auto *card : m_cards) {
-        const bool show = card->matches(needle);
+        const bool show = card->matches(needle) && card->hasAllTags(activeTags);
         card->setVisible(show);
         if (show)
             ++visible;
     }
+    if (m_count)
+        m_count->setText(QString::number(visible));
     return visible;
 }
 
@@ -329,12 +343,16 @@ AgentSelectionDialog::AgentSelectionDialog(
     const std::vector &configs,
     const QString ¤tName,
     AgentFactory *agentFactory,
+    const QStringList &presetTags,
     QWidget *parent)
     : QDialog(parent)
     , m_agentFactory(agentFactory)
+    , m_presetTags(presetTags)
 {
-    setWindowTitle(Tr::tr("Change Agent"));
+    const bool isChange = !currentName.isEmpty();
+    setWindowTitle(isChange ? Tr::tr("Change Agent") : Tr::tr("Add Agent"));
     resize(720, 600);
+    setMinimumSize(560, 420);
     setSizeGripEnabled(true);
 
     if (!m_agentFactory)
@@ -350,6 +368,31 @@ AgentSelectionDialog::AgentSelectionDialog(
     topRow->setSpacing(6);
     topRow->addWidget(m_filter, 1);
 
+    m_tagStrip = new TagFilterStrip(this);
+
+    const bool dark = CardStyle::isDark(palette());
+    const auto tone = CardStyle::toneFor(dark);
+
+    m_resultCount = new QLabel(this);
+    {
+        QPalette rp = m_resultCount->palette();
+        rp.setColor(QPalette::WindowText, QColor(tone.textMute));
+        m_resultCount->setPalette(rp);
+    }
+
+    auto *expandAll = new QLabel(
+        QStringLiteral("%1").arg(Tr::tr("Expand all")), this);
+    auto *collapseAll = new QLabel(
+        QStringLiteral("%1").arg(Tr::tr("Collapse all")), this);
+
+    auto *controlsRow = new QHBoxLayout;
+    controlsRow->setContentsMargins(2, 0, 2, 0);
+    controlsRow->setSpacing(8);
+    controlsRow->addWidget(m_resultCount);
+    controlsRow->addStretch(1);
+    controlsRow->addWidget(expandAll);
+    controlsRow->addWidget(collapseAll);
+
     m_scroll = new QScrollArea(this);
     m_scroll->setWidgetResizable(true);
     m_scroll->setFrameShape(QFrame::StyledPanel);
@@ -358,20 +401,54 @@ AgentSelectionDialog::AgentSelectionDialog(
     auto *buttons
         = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
     m_okButton = buttons->button(QDialogButtonBox::Ok);
-    m_okButton->setText(Tr::tr("Change"));
+    m_okButton->setText(isChange ? Tr::tr("Change") : Tr::tr("Add"));
     m_okButton->setEnabled(false);
 
     auto *layout = new QVBoxLayout(this);
     layout->addLayout(topRow);
+    layout->addWidget(m_tagStrip);
+    layout->addLayout(controlsRow);
     layout->addWidget(m_scroll);
     layout->addWidget(buttons);
 
+    QMap tagCounts;
+    for (const auto &cfg : (m_agentFactory ? m_agentFactory->configs() : m_localConfigs)) {
+        if (cfg.hidden)
+            continue;
+        for (const QString &tag : cfg.tags)
+            tagCounts[tag] += 1;
+    }
+
+    QSet preset;
+    for (const QString &tag : m_presetTags)
+        if (tagCounts.contains(tag))
+            preset.insert(tag);
+    m_tagStrip->setAvailableTags(tagCounts, preset);
+
+    connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this,
+            [this](const QSet &) { applyFilters(); });
+    connect(expandAll, &QLabel::linkActivated, this, [this](const QString &) {
+        setAllExpanded(true);
+    });
+    connect(collapseAll, &QLabel::linkActivated, this, [this](const QString &) {
+        setAllExpanded(false);
+    });
+
     rebuild(currentName);
 
     connect(m_filter, &QLineEdit::textChanged, this,
-            [this](const QString &text) { applyFilter(text); });
+            [this](const QString &) { applyFilters(); });
     connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
     connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+
+    m_filter->setFocus();
+}
+
+void AgentSelectionDialog::setAllExpanded(bool expanded)
+{
+    for (auto *section : m_sections)
+        if (!section->isHidden())
+            section->setExpanded(expanded);
 }
 
 void AgentSelectionDialog::selectCard(ListRowCard *card)
@@ -441,6 +518,20 @@ void AgentSelectionDialog::rebuild(const QString ¤tName)
         contentLayout->addWidget(section);
         m_sections.append(section);
     }
+
+    m_emptyLabel = new QLabel(tr("No agents match the current filter."), content);
+    m_emptyLabel->setAlignment(Qt::AlignCenter);
+    m_emptyLabel->setContentsMargins(10, 24, 10, 24);
+    {
+        QFont ef = m_emptyLabel->font();
+        ef.setItalic(true);
+        m_emptyLabel->setFont(ef);
+        QPalette ep = m_emptyLabel->palette();
+        ep.setColor(QPalette::WindowText, ep.color(QPalette::Mid));
+        m_emptyLabel->setPalette(ep);
+    }
+    m_emptyLabel->setVisible(false);
+    contentLayout->addWidget(m_emptyLabel);
     contentLayout->addStretch(1);
 
     m_scroll->setWidget(content);
@@ -455,17 +546,36 @@ void AgentSelectionDialog::rebuild(const QString ¤tName)
         });
     }
 
-    applyFilter(m_filter ? m_filter->text() : QString());
+    applyFilters();
 }
 
-void AgentSelectionDialog::applyFilter(const QString &needle)
+void AgentSelectionDialog::applyFilters()
 {
-    const QString trimmed = needle.trimmed();
+    const QString needle = m_filter ? m_filter->text().trimmed() : QString();
+    const QSet activeTags = m_tagStrip ? m_tagStrip->activeTags() : QSet();
+    const bool filtering = !needle.isEmpty() || !activeTags.isEmpty();
+    int total = 0;
     for (auto *section : m_sections) {
-        const int visible = section->applyFilter(trimmed);
+        const int visible = section->applyFilter(needle, activeTags);
         section->setVisible(visible > 0);
-        if (!trimmed.isEmpty())
+        if (filtering)
             section->setExpanded(visible > 0);
+        total += visible;
+    }
+    if (m_emptyLabel)
+        m_emptyLabel->setVisible(total == 0);
+    if (m_resultCount)
+        m_resultCount->setText(total == 0 ? tr("No matches")
+                                          : tr("%n agent(s)", nullptr, total));
+
+    if (m_tagStrip) {
+        QMap liveCounts;
+        for (auto *section : m_sections)
+            for (auto *card : section->cards())
+                if (card->matches(needle) && card->hasAllTags(activeTags))
+                    for (const QString &tag : card->itemTags())
+                        liveCounts[tag] += 1;
+        m_tagStrip->setVisibleCounts(liveCounts);
     }
 }
 
diff --git a/sources/settings/AgentSelectionDialog.hpp b/sources/settings/AgentSelectionDialog.hpp
index 2f879a6..f7f750a 100644
--- a/sources/settings/AgentSelectionDialog.hpp
+++ b/sources/settings/AgentSelectionDialog.hpp
@@ -9,6 +9,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -25,6 +26,8 @@ class AgentFactory;
 
 namespace QodeAssist::Settings {
 
+class TagFilterStrip;
+
 namespace CardStyle {
 
 struct Tone
@@ -87,9 +90,10 @@ class ListRowCard : public QFrame
     Q_OBJECT
 public:
     QString itemName() const { return m_itemName; }
+    QStringList itemTags() const { return m_itemTags; }
     bool matches(const QString &needle) const;
+    bool hasAllTags(const QSet &activeTags) const;
     void setSelected(bool selected);
-    bool isSelected() const { return m_selected; }
 
 signals:
     void clicked();
@@ -99,6 +103,7 @@ protected:
     explicit ListRowCard(QWidget *parent = nullptr);
 
     void setItemName(const QString &name) { m_itemName = name; }
+    void setItemTags(const QStringList &tags) { m_itemTags = tags; }
     void buildSearchHaystack(const QStringList &parts);
 
     void mousePressEvent(QMouseEvent *event) override;
@@ -111,6 +116,7 @@ private:
     void applyTheme();
 
     QString m_itemName;
+    QStringList m_itemTags;
     QString m_searchHaystack;
     bool m_selected = false;
     bool m_hover = false;
@@ -134,10 +140,10 @@ public:
 
     void addCard(ListRowCard *card);
     void setExpanded(bool expanded);
-    bool isExpanded() const { return m_expanded; }
     const QList &cards() const { return m_cards; }
 
-    int applyFilter(const QString &needle); // returns number of visible cards
+    // returns number of visible cards
+    int applyFilter(const QString &needle, const QSet &activeTags);
 
 protected:
     bool eventFilter(QObject *watched, QEvent *event) override;
@@ -146,6 +152,7 @@ private:
     QFrame *m_header = nullptr;
     QLabel *m_arrow = nullptr;
     QLabel *m_label = nullptr;
+    QLabel *m_count = nullptr;
     QWidget *m_content = nullptr;
     QVBoxLayout *m_contentLayout = nullptr;
     QList m_cards;
@@ -160,6 +167,7 @@ public:
         const std::vector &configs,
         const QString ¤tName,
         AgentFactory *agentFactory = nullptr,
+        const QStringList &presetTags = {},
         QWidget *parent = nullptr);
 
     QString selectedName() const { return m_selectedName; }
@@ -167,17 +175,22 @@ public:
 private:
     void rebuild(const QString ¤tName);
     void selectCard(ListRowCard *card);
-    void applyFilter(const QString &needle);
+    void applyFilters();
+    void setAllExpanded(bool expanded);
 
     QLineEdit *m_filter = nullptr;
+    TagFilterStrip *m_tagStrip = nullptr;
     QScrollArea *m_scroll = nullptr;
     QPushButton *m_okButton = nullptr;
+    QLabel *m_resultCount = nullptr;
+    QLabel *m_emptyLabel = nullptr;
     ListRowCard *m_currentCard = nullptr;
     QList m_sections;
     QString m_selectedName;
 
     AgentFactory *m_agentFactory = nullptr;
     std::vector m_localConfigs; // fallback when no factory
+    QStringList m_presetTags;
 };
 
 } // namespace QodeAssist::Settings
diff --git a/sources/settings/AgentSlotWidget.cpp b/sources/settings/AgentSlotWidget.cpp
deleted file mode 100644
index 566e65d..0000000
--- a/sources/settings/AgentSlotWidget.cpp
+++ /dev/null
@@ -1,362 +0,0 @@
-// Copyright (C) 2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#include "AgentSlotWidget.hpp"
-
-#include "SettingsTr.hpp"
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-namespace QodeAssist::Settings {
-
-namespace {
-
-bool isDarkPalette(const QPalette &p)
-{
-    return p.color(QPalette::Window).lightness() < 128;
-}
-
-QFont monoFont(int pixelSize)
-{
-    QFont f;
-    f.setFamilies({QStringLiteral("SF Mono"),
-                   QStringLiteral("Cascadia Code"),
-                   QStringLiteral("Consolas"),
-                   QStringLiteral("Liberation Mono"),
-                   QStringLiteral("Menlo"),
-                   QStringLiteral("Courier New")});
-    f.setStyleHint(QFont::Monospace);
-    f.setPixelSize(pixelSize);
-    return f;
-}
-
-struct Tone
-{
-    QString cardBg;
-    QString cardBd;
-    QString textSoft;
-    QString textMute;
-    QString textFaint;
-};
-
-Tone toneFor(bool dark)
-{
-    return dark ? Tone{"#333333", "#4a4a4a", "#c2c2c2", "#9a9a9a", "#7a7a7a"}
-                : Tone{"#f6f6f6", "#bdbdbd", "#3a3a3a", "#5a5a5a", "#8a8a8a"};
-}
-
-struct PillTone
-{
-    QString bg;
-    QString fg;
-    QString bd;
-};
-
-PillTone pillTone(Pill::Kind kind, bool dark)
-{
-    switch (kind) {
-    case Pill::Template:
-        return dark ? PillTone{"#2c3f5a", "#cfe1f7", "#4a6286"}
-                    : PillTone{"#dbe7f6", "#1f3f73", "#a8c1e0"};
-    case Pill::On:
-        return dark ? PillTone{"#2f4530", "#bce0bd", "#4a6c4b"}
-                    : PillTone{"#dbe9d3", "#2c5a1c", "#a3bc97"};
-    case Pill::Off:
-        return dark ? PillTone{"#3a3a3a", "#8a8a8a", "#4a4a4a"}
-                    : PillTone{"#ececec", "#7a7a7a", "#c8c8c8"};
-    case Pill::User:
-        return dark ? PillTone{"#4a3f24", "#e6cd92", "#6a5a30"}
-                    : PillTone{"#f0e4cf", "#75541a", "#cdb98a"};
-    case Pill::Tag:
-        return dark ? PillTone{"#2e2e3a", "#b9b9cf", "#46465a"}
-                    : PillTone{"#e7e7f2", "#46466e", "#c1c1d5"};
-    }
-    return {};
-}
-
-} // namespace
-
-// -- Pill --------------------------------------------------------------
-
-Pill::Pill(Kind kind, const QString &text, QWidget *parent)
-    : QLabel(text, parent)
-    , m_kind(kind)
-{
-    setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
-    setAlignment(Qt::AlignCenter);
-    QFont f = font();
-    f.setPixelSize(11);
-    setFont(f);
-    applyTheme();
-}
-
-void Pill::setKind(Kind kind)
-{
-    if (m_kind == kind)
-        return;
-    m_kind = kind;
-    applyTheme();
-}
-
-void Pill::changeEvent(QEvent *event)
-{
-    QLabel::changeEvent(event);
-    if (m_inApplyTheme)
-        return;
-    if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
-        applyTheme();
-}
-
-void Pill::applyTheme()
-{
-    if (m_inApplyTheme)
-        return;
-    QScopedValueRollback guard(m_inApplyTheme, true);
-
-    const auto t = pillTone(m_kind, isDarkPalette(palette()));
-    setStyleSheet(QStringLiteral(
-                      "QLabel { background-color: %1; color: %2;"
-                      " border: 1px solid %3; padding: 1px 7px; }")
-                      .arg(t.bg, t.fg, t.bd));
-}
-
-// -- AgentSlotWidget ---------------------------------------------------
-
-AgentSlotWidget::AgentSlotWidget(QWidget *parent)
-    : QWidget(parent)
-{
-    setAttribute(Qt::WA_StyledBackground, true);
-    setObjectName(QStringLiteral("AgentSlot"));
-
-    m_name = new QLabel(this);
-    QFont nameFont = m_name->font();
-    nameFont.setBold(true);
-    m_name->setFont(nameFont);
-    m_name->setTextInteractionFlags(Qt::TextSelectableByMouse);
-
-    m_sourcePill = new Pill(Pill::User, {}, this);
-    m_sourcePill->hide();
-
-    m_changeBtn = new QPushButton(Tr::tr("Change…"), this);
-    m_changeBtn->setCursor(Qt::PointingHandCursor);
-    connect(m_changeBtn, &QPushButton::clicked, this, &AgentSlotWidget::changeRequested);
-
-    m_editBtn = new QPushButton(Tr::tr("Edit"), this);
-    m_editBtn->setCursor(Qt::PointingHandCursor);
-    m_editBtn->setToolTip(Tr::tr("Open this agent in the Agents settings page."));
-    connect(m_editBtn, &QPushButton::clicked, this, &AgentSlotWidget::editRequested);
-
-    auto makeFieldLabel = [this]() {
-        auto *l = new QLabel(this);
-        l->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
-        return l;
-    };
-    auto makeMonoValue = [this]() {
-        auto *l = new QLabel(this);
-        l->setFont(monoFont(11));
-        l->setTextInteractionFlags(Qt::TextSelectableByMouse);
-        l->setMinimumWidth(0);
-        l->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
-        return l;
-    };
-    auto makePlainValue = [this]() {
-        auto *l = new QLabel(this);
-        l->setTextInteractionFlags(Qt::TextSelectableByMouse);
-        l->setMinimumWidth(0);
-        l->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
-        return l;
-    };
-
-    m_modelLabel = makeFieldLabel();
-    m_modelLabel->setText(Tr::tr("Model"));
-    m_modelValue = makeMonoValue();
-
-    m_urlLabel = makeFieldLabel();
-    m_urlLabel->setText(Tr::tr("Provider"));
-    m_urlValue = makeMonoValue();
-
-    m_endpointLabel = makeFieldLabel();
-    m_endpointLabel->setText(Tr::tr("Endpoint"));
-    m_endpointValue = makeMonoValue();
-
-    m_templateLabel = makeFieldLabel();
-    m_templateLabel->setText(Tr::tr("Template"));
-    m_templateValue = makePlainValue();
-
-    m_description = new QLabel(this);
-    m_description->setWordWrap(true);
-    m_description->setTextInteractionFlags(Qt::TextSelectableByMouse);
-    QFont descFont = m_description->font();
-    descFont.setItalic(true);
-    m_description->setFont(descFont);
-
-    m_thinkingPill = new Pill(Pill::Off, {}, this);
-    m_toolsPill = new Pill(Pill::Off, {}, this);
-
-    // Header row
-    auto *nameRow = new QHBoxLayout;
-    nameRow->setContentsMargins(0, 0, 0, 0);
-    nameRow->setSpacing(6);
-    nameRow->addWidget(m_name);
-    nameRow->addWidget(m_sourcePill);
-    nameRow->addStretch(1);
-
-    auto *buttonsBox = new QHBoxLayout;
-    buttonsBox->setContentsMargins(0, 0, 0, 0);
-    buttonsBox->setSpacing(4);
-    buttonsBox->addWidget(m_editBtn);
-    buttonsBox->addWidget(m_changeBtn);
-
-    auto *headerRow = new QHBoxLayout;
-    headerRow->setContentsMargins(0, 0, 0, 0);
-    headerRow->setSpacing(8);
-    headerRow->addLayout(nameRow, 1);
-    headerRow->addLayout(buttonsBox, 0);
-
-    // Two-column field grid
-    auto *grid = new QGridLayout;
-    grid->setContentsMargins(0, 0, 0, 0);
-    grid->setHorizontalSpacing(8);
-    grid->setVerticalSpacing(2);
-    grid->setColumnMinimumWidth(0, 62);
-    grid->setColumnStretch(0, 0);
-    grid->setColumnStretch(1, 1);
-
-    grid->addWidget(m_modelLabel, 0, 0);
-    grid->addWidget(m_modelValue, 0, 1);
-    grid->addWidget(m_urlLabel, 1, 0);
-    grid->addWidget(m_urlValue, 1, 1);
-    grid->addWidget(m_endpointLabel, 2, 0);
-    grid->addWidget(m_endpointValue, 2, 1);
-    grid->addWidget(m_templateLabel, 3, 0);
-    grid->addWidget(m_templateValue, 3, 1);
-
-    auto *pillsRow = new QHBoxLayout;
-    pillsRow->setContentsMargins(0, 0, 0, 0);
-    pillsRow->setSpacing(4);
-    pillsRow->addWidget(m_thinkingPill);
-    pillsRow->addWidget(m_toolsPill);
-    pillsRow->addStretch(1);
-
-    auto *outer = new QVBoxLayout(this);
-    outer->setContentsMargins(10, 10, 10, 10);
-    outer->setSpacing(6);
-    outer->addLayout(headerRow);
-    outer->addLayout(grid);
-    outer->addWidget(m_description);
-    outer->addLayout(pillsRow);
-
-    applyTheme();
-    clear();
-}
-
-void AgentSlotWidget::changeEvent(QEvent *event)
-{
-    QWidget::changeEvent(event);
-    if (m_inApplyTheme)
-        return;
-    if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
-        applyTheme();
-}
-
-void AgentSlotWidget::applyTheme()
-{
-    if (m_inApplyTheme)
-        return;
-    QScopedValueRollback guard(m_inApplyTheme, true);
-
-    const Tone t = toneFor(isDarkPalette(palette()));
-
-    setStyleSheet(QStringLiteral(
-                      "#AgentSlot { background-color: %1; border: 1px solid %2; }")
-                      .arg(t.cardBg, t.cardBd));
-
-    auto applyColor = [](QLabel *label, const QString &color) {
-        QPalette p = label->palette();
-        p.setColor(QPalette::WindowText, QColor(color));
-        label->setPalette(p);
-    };
-
-    for (QLabel *l : {m_modelLabel, m_urlLabel, m_endpointLabel, m_templateLabel})
-        applyColor(l, t.textMute);
-
-    applyColor(m_description, t.textSoft);
-}
-
-void AgentSlotWidget::setAgentConfig(const AgentConfig &cfg)
-{
-    m_name->setText(cfg.name);
-
-    if (cfg.isUserSource()) {
-        m_sourcePill->setText(cfg.overridesBundled
-                                  ? Tr::tr("User overrides bundled")
-                                  : Tr::tr("User"));
-        m_sourcePill->show();
-    } else {
-        m_sourcePill->hide();
-    }
-
-    m_modelValue->setText(cfg.model);
-    m_modelValue->setToolTip(cfg.model);
-    // The agent profile no longer carries a URL — the URL belongs to
-    // the referenced provider instance and is editable on the Providers
-    // settings page. Surface the instance name in this row instead.
-    m_urlValue->setText(cfg.providerInstance);
-    m_urlValue->setToolTip(cfg.providerInstance);
-    m_endpointValue->setText(cfg.endpoint);
-    m_endpointValue->setToolTip(cfg.endpoint);
-    // Templates are now inline in the agent profile — no separate name to
-    // surface. Keep the row but show '—' so the layout stays stable.
-    m_templateLabel->hide();
-    m_templateValue->hide();
-
-    m_description->setText(cfg.description.isEmpty()
-                               ? Tr::tr("No description provided.")
-                               : cfg.description);
-
-    const Tone t = toneFor(isDarkPalette(palette()));
-    QPalette descPal = m_description->palette();
-    descPal.setColor(QPalette::WindowText,
-                     QColor(cfg.description.isEmpty() ? t.textFaint : t.textSoft));
-    m_description->setPalette(descPal);
-
-    m_thinkingPill->setKind(cfg.enableThinking ? Pill::On : Pill::Off);
-    m_thinkingPill->setText(cfg.enableThinking ? Tr::tr("thinking on")
-                                               : Tr::tr("thinking off"));
-    m_toolsPill->setKind(cfg.enableTools ? Pill::On : Pill::Off);
-    m_toolsPill->setText(cfg.enableTools ? Tr::tr("tools on")
-                                         : Tr::tr("tools off"));
-    m_thinkingPill->show();
-    m_toolsPill->show();
-
-    m_editBtn->setEnabled(!cfg.name.isEmpty());
-}
-
-void AgentSlotWidget::clear()
-{
-    const Tone t = toneFor(isDarkPalette(palette()));
-
-    m_name->setText(QStringLiteral("%2")
-                        .arg(t.textFaint, Tr::tr("(no agent selected)")));
-    m_sourcePill->hide();
-
-    for (QLabel *l : {m_modelValue, m_urlValue, m_endpointValue, m_templateValue}) {
-        l->clear();
-        l->setToolTip({});
-    }
-
-    m_description->setText({});
-    m_thinkingPill->hide();
-    m_toolsPill->hide();
-    m_editBtn->setEnabled(false);
-}
-
-} // namespace QodeAssist::Settings
diff --git a/sources/settings/AgentSlotWidget.hpp b/sources/settings/AgentSlotWidget.hpp
deleted file mode 100644
index 285e88a..0000000
--- a/sources/settings/AgentSlotWidget.hpp
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-#include 
-
-#include 
-
-class QPushButton;
-
-namespace QodeAssist::Settings {
-
-class Pill : public QLabel
-{
-    Q_OBJECT
-public:
-    enum Kind { Template, On, Off, User, Tag };
-
-    Pill(Kind kind, const QString &text = {}, QWidget *parent = nullptr);
-    void setKind(Kind kind);
-
-protected:
-    void changeEvent(QEvent *event) override;
-
-private:
-    void applyTheme();
-
-    Kind m_kind;
-    bool m_inApplyTheme = false;
-};
-
-class AgentSlotWidget : public QWidget
-{
-    Q_OBJECT
-public:
-    explicit AgentSlotWidget(QWidget *parent = nullptr);
-
-    void setAgentConfig(const AgentConfig &cfg);
-    void clear();
-
-    QPushButton *changeButton() const { return m_changeBtn; }
-
-signals:
-    void changeRequested();
-    void editRequested();
-
-protected:
-    void changeEvent(QEvent *event) override;
-
-private:
-    void applyTheme();
-
-    QLabel *m_name = nullptr;
-    Pill *m_sourcePill = nullptr;
-    QPushButton *m_changeBtn = nullptr;
-    QPushButton *m_editBtn = nullptr;
-
-    QLabel *m_modelLabel = nullptr;
-    QLabel *m_modelValue = nullptr;
-    QLabel *m_urlLabel = nullptr;
-    QLabel *m_urlValue = nullptr;
-    QLabel *m_endpointLabel = nullptr;
-    QLabel *m_endpointValue = nullptr;
-    QLabel *m_templateLabel = nullptr;
-    QLabel *m_templateValue = nullptr;
-
-    QLabel *m_description = nullptr;
-
-    Pill *m_thinkingPill = nullptr;
-    Pill *m_toolsPill = nullptr;
-
-    bool m_inApplyTheme = false;
-};
-
-} // namespace QodeAssist::Settings
diff --git a/sources/settings/CMakeLists.txt b/sources/settings/CMakeLists.txt
index 95d8660..ae7f4c4 100644
--- a/sources/settings/CMakeLists.txt
+++ b/sources/settings/CMakeLists.txt
@@ -1,8 +1,7 @@
-add_library(QodeAssistAgentPipelines OBJECT
-    AgentPipelinesPage.hpp AgentPipelinesPage.cpp
+add_library(QodeAssistAgentPipelines STATIC
     PipelinesConfig.hpp PipelinesConfig.cpp
+    Pill.hpp Pill.cpp
     AgentRosterWidget.hpp AgentRosterWidget.cpp
-    AgentSlotWidget.hpp AgentSlotWidget.cpp
     AgentSelectionDialog.hpp AgentSelectionDialog.cpp
 )
 
@@ -29,6 +28,7 @@ target_link_libraries(QodeAssistAgentPipelines PRIVATE
     QodeAssistLogger
     TomlSerializer
     tomlplusplus::tomlplusplus
+    QodeAssistSettings
 )
 
 target_compile_definitions(QodeAssistAgentPipelines PRIVATE
diff --git a/sources/settings/Pill.cpp b/sources/settings/Pill.cpp
new file mode 100644
index 0000000..a223b02
--- /dev/null
+++ b/sources/settings/Pill.cpp
@@ -0,0 +1,96 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include "Pill.hpp"
+
+#include "SettingsTheme.hpp"
+
+#include 
+
+#include 
+#include 
+#include 
+
+namespace QodeAssist::Settings {
+
+namespace {
+
+struct PillTone
+{
+    QColor bg;
+    QColor fg;
+    QColor border;
+};
+
+PillTone toneFor(Pill::Kind kind)
+{
+    const QColor cardBg = Utils::creatorColor(Utils::Theme::BackgroundColorNormal);
+    const QColor text = Utils::creatorColor(Utils::Theme::TextColorNormal);
+    const QColor textMuted = Utils::creatorColor(Utils::Theme::PanelTextColorMid);
+    const QColor textFaint = Utils::creatorColor(Utils::Theme::TextColorDisabled);
+    const QColor accent = Utils::creatorColor(Utils::Theme::TextColorLink);
+    const QColor activeBg = Utils::creatorColor(Utils::Theme::BackgroundColorSelected);
+    const QColor border = Utils::creatorColor(Utils::Theme::SplitterColor);
+    const bool dark = cardBg.lightness() < 128;
+
+    const auto tint = [&](const QColor &role) -> PillTone {
+        return {mix(cardBg, role, 0.16), dark ? mix(text, role, 0.65) : role, mix(cardBg, role, 0.42)};
+    };
+
+    switch (kind) {
+    case Pill::On:
+        return tint(Utils::creatorColor(Utils::Theme::IconsRunColor));
+    case Pill::Off:
+        return {mix(cardBg, textFaint, 0.12), textFaint, mix(cardBg, textFaint, 0.30)};
+    case Pill::User:
+        return tint(Utils::creatorColor(Utils::Theme::IconsWarningColor));
+    case Pill::Accent:
+    case Pill::Match:
+        return tint(accent);
+    case Pill::Active:
+        return {activeBg, text, accent};
+    case Pill::Tag:
+    case Pill::Neutral:
+        return {mix(cardBg, textMuted, 0.14), textMuted, mix(cardBg, textMuted, 0.32)};
+    }
+    return {cardBg, text, border};
+}
+
+} // namespace
+
+Pill::Pill(Kind kind, const QString &text, QWidget *parent)
+    : QLabel(text, parent)
+    , m_kind(kind)
+{
+    setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
+    setAlignment(Qt::AlignCenter);
+    QFont f = font();
+    f.setPixelSize(11);
+    setFont(f);
+    applyTheme();
+}
+
+void Pill::changeEvent(QEvent *event)
+{
+    QLabel::changeEvent(event);
+    if (m_inApplyTheme)
+        return;
+    if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
+        applyTheme();
+}
+
+void Pill::applyTheme()
+{
+    if (m_inApplyTheme)
+        return;
+    QScopedValueRollback guard(m_inApplyTheme, true);
+
+    const PillTone t = toneFor(m_kind);
+    setStyleSheet(QStringLiteral("QLabel { background-color:%1; color:%2;"
+                                 " border:1px solid %3; border-radius:3px;"
+                                 " padding:1px 7px; font-size:11px; }")
+                      .arg(cssColor(t.bg), cssColor(t.fg), cssColor(t.border)));
+}
+
+} // namespace QodeAssist::Settings
diff --git a/sources/settings/Pill.hpp b/sources/settings/Pill.hpp
new file mode 100644
index 0000000..cd51b70
--- /dev/null
+++ b/sources/settings/Pill.hpp
@@ -0,0 +1,32 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#pragma once
+
+#include 
+#include 
+
+namespace QodeAssist::Settings {
+
+// Small rounded chip. Theme-aware: recolors itself on palette/style changes.
+// All colors derive from the active Qt Creator theme (Utils::Theme).
+class Pill : public QLabel
+{
+    Q_OBJECT
+public:
+    enum Kind { Neutral, Accent, On, Off, User, Tag, Active, Match };
+
+    explicit Pill(Kind kind, const QString &text = {}, QWidget *parent = nullptr);
+
+protected:
+    void changeEvent(QEvent *event) override;
+
+private:
+    void applyTheme();
+
+    Kind m_kind;
+    bool m_inApplyTheme = false;
+};
+
+} // namespace QodeAssist::Settings
diff --git a/sources/settings/PipelinesConfig.cpp b/sources/settings/PipelinesConfig.cpp
index 72c79c1..4c1c727 100644
--- a/sources/settings/PipelinesConfig.cpp
+++ b/sources/settings/PipelinesConfig.cpp
@@ -38,6 +38,30 @@ QString trimAndCap(const QString &raw)
     return s;
 }
 
+QString toSingleString(const toml::node *node, const QString &slotKey, bool *schemaOk)
+{
+    if (!node)
+        return {};
+    if (const auto *s = node->as_string())
+        return trimAndCap(QString::fromStdString(s->get()));
+    // Backward compatibility: older pipelines.toml stored these slots as an
+    // ordered array. Collapse to the first usable name.
+    if (const auto *arr = node->as_array()) {
+        for (size_t i = 0; i < arr->size(); ++i) {
+            if (const auto *s = (*arr)[i].as_string()) {
+                const QString name = trimAndCap(QString::fromStdString(s->get()));
+                if (!name.isEmpty())
+                    return name;
+            }
+        }
+        return {};
+    }
+    LOG_MESSAGE(QStringLiteral("[Pipelines] schema error: '%1' must be a string").arg(slotKey));
+    if (schemaOk)
+        *schemaOk = false;
+    return {};
+}
+
 QStringList toStringList(const toml::node *node, const QString &slotKey, bool *schemaOk)
 {
     QStringList out;
@@ -94,12 +118,7 @@ void fillMissingFromDefaults(PipelineRosters &r, const toml::table §ion)
 
 PipelineRosters PipelineRosters::defaults()
 {
-    PipelineRosters r;
-    r.codeCompletion = {QStringLiteral("Ollama Qwen2.5-Coder Completion")};
-    r.chatAssistant = {QStringLiteral("Ollama Chat")};
-    r.chatCompression = {QStringLiteral("Ollama Compression")};
-    r.quickRefactor = {QStringLiteral("Ollama Quick Refactor")};
-    return r;
+    return PipelineRosters{};
 }
 
 QString PipelinesConfig::filePath()
@@ -171,9 +190,9 @@ PipelinesLoadResult PipelinesConfig::load()
     result.rosters.chatAssistant
         = toStringList((*section)[kChatAssistant].node(), kChatAssistant, &schemaOk);
     result.rosters.chatCompression
-        = toStringList((*section)[kChatCompression].node(), kChatCompression, &schemaOk);
+        = toSingleString((*section)[kChatCompression].node(), kChatCompression, &schemaOk);
     result.rosters.quickRefactor
-        = toStringList((*section)[kQuickRefactor].node(), kQuickRefactor, &schemaOk);
+        = toSingleString((*section)[kQuickRefactor].node(), kQuickRefactor, &schemaOk);
 
     fillMissingFromDefaults(result.rosters, *section);
 
@@ -198,17 +217,22 @@ bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut)
     }
 
     TomlSerializer::TomlWriter w;
+    w.writeComment(QStringLiteral("QodeAssist pipelines — which agent each feature uses."));
     w.writeComment(QStringLiteral(
-        "QodeAssist pipelines — slot → ordered list of agent names."));
+        "code_completion: ordered list; the router walks it top-down and uses"));
     w.writeComment(QStringLiteral(
-        "The router walks each list top-down at request time and uses"));
-    w.writeComment(QStringLiteral("the first matching agent."));
+        "  the first agent whose match rules fit the current file/project."));
+    w.writeComment(QStringLiteral(
+        "chat_assistant: agents offered in the chat picker (order irrelevant —"));
+    w.writeComment(QStringLiteral("  you choose one in the UI)."));
+    w.writeComment(QStringLiteral(
+        "chat_compression / quick_refactor: a single agent name."));
     w.writeBlankLine();
     w.writeTableHeader(QString::fromUtf8(kSection));
     w.writeStringArray(QString::fromUtf8(kCodeCompletion), rosters.codeCompletion);
     w.writeStringArray(QString::fromUtf8(kChatAssistant), rosters.chatAssistant);
-    w.writeStringArray(QString::fromUtf8(kChatCompression), rosters.chatCompression);
-    w.writeStringArray(QString::fromUtf8(kQuickRefactor), rosters.quickRefactor);
+    w.writeString(QString::fromUtf8(kChatCompression), rosters.chatCompression);
+    w.writeString(QString::fromUtf8(kQuickRefactor), rosters.quickRefactor);
 
     QSaveFile out(path);
     if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
@@ -259,10 +283,19 @@ bool PipelinesConfig::validate(const QodeAssist::AgentFactory &factory, QString
         }
     };
 
+    auto fixOne = [&](QString ¤t) {
+        const QString name = trimAndCap(current);
+        const QString next = (!name.isEmpty() && factory.configByName(name)) ? name : QString();
+        if (next != current) {
+            current = next;
+            changed = true;
+        }
+    };
+
     fix(r.codeCompletion);
     fix(r.chatAssistant);
-    fix(r.chatCompression);
-    fix(r.quickRefactor);
+    fixOne(r.chatCompression);
+    fixOne(r.quickRefactor);
 
     if (!changed && lr.status == PipelinesLoadStatus::Ok)
         return true;
diff --git a/sources/settings/PipelinesConfig.hpp b/sources/settings/PipelinesConfig.hpp
index b5760ad..917e548 100644
--- a/sources/settings/PipelinesConfig.hpp
+++ b/sources/settings/PipelinesConfig.hpp
@@ -15,10 +15,15 @@ namespace QodeAssist::Settings {
 
 struct PipelineRosters
 {
+    // Code completion is auto-routed: the router walks this ordered list at
+    // request time and uses the first agent whose match rules fit the file.
     QStringList codeCompletion;
+    // Chat is user-driven: this is an unordered allow-list of the agents
+    // offered in the chat picker. The user picks; no routing happens.
     QStringList chatAssistant;
-    QStringList chatCompression;
-    QStringList quickRefactor;
+    // Compression and quick refactor each use a single fixed agent.
+    QString chatCompression;
+    QString quickRefactor;
 
     [[nodiscard]] static PipelineRosters defaults();
 };
diff --git a/sources/templates/JsonPromptTemplate.cpp b/sources/templates/JsonPromptTemplate.cpp
index f45543c..7d81f01 100644
--- a/sources/templates/JsonPromptTemplate.cpp
+++ b/sources/templates/JsonPromptTemplate.cpp
@@ -5,6 +5,10 @@
 #include "JsonPromptTemplate.hpp"
 
 #include 
+#include 
+#include 
+#include 
+#include 
 #include 
 #include 
 
@@ -42,6 +46,16 @@ nlohmann::json buildContextJson(const ContextData &context)
         ctx["files_metadata"] = std::move(files);
     }
 
+    // tool_result blocks only carry the tool_use_id; resolve the originating
+    // tool name so templates (e.g. Google's functionResponse.name) can emit it.
+    QHash toolNameById;
+    if (context.history) {
+        for (const auto &msg : context.history.value())
+            for (const auto &b : msg.blocks)
+                if (b.kind == ContentBlockEntry::Kind::ToolUse)
+                    toolNameById.insert(b.toolUseId, b.toolName);
+    }
+
     nlohmann::json history = nlohmann::json::array();
     if (context.history) {
         for (const auto &msg : context.history.value()) {
@@ -93,6 +107,7 @@ nlohmann::json buildContextJson(const ContextData &context)
                     bj["type"] = "tool_result";
                     bj["tool_use_id"] = b.toolUseId.toStdString();
                     bj["content"] = b.result.toStdString();
+                    bj["name"] = toolNameById.value(b.toolUseId).toStdString();
                     break;
                 case ContentBlockEntry::Kind::Image:
                     bj["type"] = "image";
@@ -126,19 +141,77 @@ nlohmann::json buildContextJson(const ContextData &context)
     return data;
 }
 
+// JSON-aware removal of trailing commas (a `,` immediately followed, after
+// optional whitespace, by `}` or `]`). Body partials emit an unconditional
+// comma after every array element / object member; this pass deletes the
+// dangling one before the closing bracket so the result parses as strict
+// JSON. String literals are skipped, so commas inside string values (e.g. a
+// tool result containing "],") are never touched.
+std::string stripTrailingCommas(const std::string &in)
+{
+    std::string out;
+    out.reserve(in.size());
+    bool inString = false;
+    bool escaped = false;
+    for (std::size_t i = 0; i < in.size(); ++i) {
+        const char c = in[i];
+        if (inString) {
+            out.push_back(c);
+            if (escaped)
+                escaped = false;
+            else if (c == '\\')
+                escaped = true;
+            else if (c == '"')
+                inString = false;
+            continue;
+        }
+        if (c == '"') {
+            inString = true;
+            out.push_back(c);
+            continue;
+        }
+        if (c == ',') {
+            std::size_t j = i + 1;
+            while (j < in.size()
+                   && (in[j] == ' ' || in[j] == '\t' || in[j] == '\n' || in[j] == '\r'))
+                ++j;
+            if (j < in.size() && (in[j] == '}' || in[j] == ']'))
+                continue; // drop this comma
+        }
+        out.push_back(c);
+    }
+    return out;
+}
+
+// Install a sandboxed `{% include %}` resolver. Includes resolve only against
+// the given roots (bundled qrc partials, then the user agent's own dir); names
+// containing ".." or starting with "/" are rejected. The included partial is
+// parsed in the same environment, so its own includes/callbacks resolve too.
+void setIncludeResolver(inja::Environment &env, std::vector roots)
+{
+    inja::Environment *envPtr = &env;
+    env.set_include_callback(
+        [envPtr, roots = std::move(roots)](
+            const std::filesystem::path &, const std::string &name) -> inja::Template {
+            const QString rel = QString::fromStdString(name);
+            if (rel.contains(QStringLiteral("..")) || rel.startsWith(QLatin1Char('/'))) {
+                throw inja::FileError("include rejected (path traversal): '" + name + "'");
+            }
+            for (const QString &root : roots) {
+                QFile f(root + QLatin1Char('/') + rel);
+                if (f.open(QIODevice::ReadOnly | QIODevice::Text))
+                    return envPtr->parse(QString::fromUtf8(f.readAll()).toStdString());
+            }
+            throw inja::FileError("include not found in partials roots: '" + name + "'");
+        });
+}
+
 void registerStandardCallbacks(inja::Environment &env)
 {
-    // Sandbox: disable filesystem reads from `{% include %}` and reject
-    // any include callback. User-authored templates run with full
-    // process privileges, so they must not slurp arbitrary files via
-    // include directives. File reads happen only through
-    // ContextManager-provided callbacks (e.g. read_file()).
+    // `{% include %}` resolution is wired per-instance in fromConfig() via a
+    // whitelisted callback; disable inja's own filesystem search so the only
+    // path is our sandboxed resolver.
     env.set_search_included_templates_in_files(false);
-    env.set_include_callback(
-        [](const std::filesystem::path &, const std::string &name) -> inja::Template {
-            throw inja::FileError(
-                "include is disabled in QodeAssist templates: '" + name + "'");
-        });
 
     // Disable inja's `##` line-statement shorthand — collides with
     // Markdown headings inside template bodies. Same rationale as in
@@ -149,6 +222,23 @@ void registerStandardCallbacks(inja::Environment &env)
         return args.at(0)->dump();
     });
 
+    // Returns the subset of a content_blocks array whose "type" equals the
+    // second argument. Lets templates build provider-specific structures (e.g.
+    // OpenAI message-level tool_calls / tool result messages) from a filtered
+    // list with clean loop.is_first/is_last comma handling.
+    env.add_callback("filter_by_type", 2, [](inja::Arguments &args) -> nlohmann::json {
+        const nlohmann::json &blocks = *args.at(0);
+        const std::string type = args.at(1)->get();
+        nlohmann::json result = nlohmann::json::array();
+        if (blocks.is_array()) {
+            for (const auto &b : blocks) {
+                if (b.is_object() && b.value("type", std::string{}) == type)
+                    result.push_back(b);
+            }
+        }
+        return result;
+    });
+
     env.add_callback("strip_signature_suffix", 1, [](inja::Arguments &args) -> nlohmann::json {
         std::string content = args.at(0)->get();
         const std::string marker = "\n[Signature: ";
@@ -203,6 +293,75 @@ void registerStandardCallbacks(inja::Environment &env)
         });
 }
 
+// A representative context for the load-time dry run: it populates every key a
+// body/partial might touch (system_prompt, prefix, suffix, and a history that
+// includes text, tool_use, tool_result and image blocks) so validation
+// exercises all branches without tripping on missing variables.
+ContextData makeValidationContext()
+{
+    ContextData ctx;
+    ctx.systemPrompt = QStringLiteral("validation");
+    ctx.prefix = QStringLiteral("prefix");
+    ctx.suffix = QStringLiteral("suffix");
+
+    QVector history;
+    history.append(Message::text(QStringLiteral("user"), QStringLiteral("hello")));
+
+    Message asst;
+    asst.role = QStringLiteral("assistant");
+    {
+        ContentBlockEntry th;
+        th.kind = ContentBlockEntry::Kind::Thinking;
+        th.thinking = QStringLiteral("reasoning");
+        th.signature = QStringLiteral("sig");
+        asst.blocks.append(th);
+        ContentBlockEntry rth;
+        rth.kind = ContentBlockEntry::Kind::RedactedThinking;
+        rth.signature = QStringLiteral("sig");
+        asst.blocks.append(rth);
+        ContentBlockEntry t;
+        t.kind = ContentBlockEntry::Kind::Text;
+        t.text = QStringLiteral("hi");
+        asst.blocks.append(t);
+        ContentBlockEntry tu;
+        tu.kind = ContentBlockEntry::Kind::ToolUse;
+        tu.toolUseId = QStringLiteral("call_1");
+        tu.toolName = QStringLiteral("read_file");
+        tu.toolInput = QJsonObject{{QStringLiteral("path"), QStringLiteral("x")}};
+        asst.blocks.append(tu);
+    }
+    history.append(asst);
+
+    Message toolMsg;
+    toolMsg.role = QStringLiteral("user");
+    {
+        ContentBlockEntry tr;
+        tr.kind = ContentBlockEntry::Kind::ToolResult;
+        tr.toolUseId = QStringLiteral("call_1");
+        tr.result = QStringLiteral("ok");
+        toolMsg.blocks.append(tr);
+    }
+    history.append(toolMsg);
+
+    Message imgMsg;
+    imgMsg.role = QStringLiteral("user");
+    {
+        ContentBlockEntry te;
+        te.kind = ContentBlockEntry::Kind::Text;
+        te.text = QStringLiteral("look");
+        imgMsg.blocks.append(te);
+        ContentBlockEntry im;
+        im.kind = ContentBlockEntry::Kind::Image;
+        im.imageData = QStringLiteral("AAAA");
+        im.mediaType = QStringLiteral("image/png");
+        imgMsg.blocks.append(im);
+    }
+    history.append(imgMsg);
+
+    ctx.history = history;
+    return ctx;
+}
+
 } // namespace
 
 std::unique_ptr JsonPromptTemplate::fromConfig(
@@ -212,126 +371,163 @@ std::unique_ptr JsonPromptTemplate::fromConfig(
         if (error) *error = msg;
     };
 
-    if (cfg.messageFormat.isEmpty()) {
-        setError(QStringLiteral("Agent '%1' has empty message_format").arg(cfg.name));
+    if (cfg.body.isEmpty()) {
+        setError(QStringLiteral("Agent '%1' has empty [body]").arg(cfg.name));
         return nullptr;
     }
 
     auto tpl = std::unique_ptr(new JsonPromptTemplate);
     tpl->m_name = cfg.name;
     tpl->m_description = cfg.description;
-    tpl->m_sampling = cfg.sampling;
-    tpl->m_thinking = cfg.thinking;
+    tpl->m_body = cfg.body;
+
+    tpl->m_partialRoots.push_back(QStringLiteral(":/agents"));
+    if (cfg.isUserSource()) {
+        const QString dir = QFileInfo(cfg.sourcePath).absolutePath();
+        if (!dir.isEmpty())
+            tpl->m_partialRoots.push_back(dir);
+    }
 
     registerStandardCallbacks(tpl->m_env);
-    try {
-        tpl->m_template = tpl->m_env.parse(cfg.messageFormat.toStdString());
-    } catch (const std::exception &e) {
-        setError(QStringLiteral("Failed to parse jinja for '%1': %2")
-                     .arg(cfg.name, QString::fromUtf8(e.what())));
+    setIncludeResolver(tpl->m_env, tpl->m_partialRoots);
+
+    // Dry-run against a representative context: catches jinja syntax errors,
+    // unknown callbacks and missing partials at load time instead of on first send.
+    if (!tpl->renderBody(makeValidationContext())) {
+        setError(QStringLiteral("Agent '%1' [body] failed to render to valid JSON "
+                                "(see log)").arg(cfg.name));
         return nullptr;
     }
     return tpl;
 }
 
-std::optional JsonPromptTemplate::renderBody(const ContextData &context) const
+namespace {
+
+// Render one body value. A string containing jinja is rendered and its output
+// spliced in as raw JSON; a plain string and any scalar pass through unchanged;
+// objects/arrays recurse. A jinja string that renders to nothing sets `omit`
+// so the caller drops the key. Returns false on render / JSON-parse failure.
+// The caller must hold the render lock (inja's env is not re-entrant).
+bool renderValue(
+    inja::Environment &env,
+    const QString &tplName,
+    const QJsonValue &in,
+    const nlohmann::json &data,
+    QJsonValue &out,
+    bool &omit)
 {
-    const nlohmann::json data = buildContextJson(context);
+    omit = false;
+
+    if (in.isObject()) {
+        QJsonObject obj;
+        const QJsonObject src = in.toObject();
+        for (auto it = src.constBegin(); it != src.constEnd(); ++it) {
+            QJsonValue v;
+            bool om = false;
+            if (!renderValue(env, tplName, it.value(), data, v, om))
+                return false;
+            if (!om)
+                obj.insert(it.key(), v);
+        }
+        out = obj;
+        return true;
+    }
+
+    if (in.isArray()) {
+        QJsonArray arr;
+        const QJsonArray src = in.toArray();
+        for (const QJsonValue &elem : src) {
+            QJsonValue v;
+            bool om = false;
+            if (!renderValue(env, tplName, elem, data, v, om))
+                return false;
+            if (!om)
+                arr.append(v);
+        }
+        out = arr;
+        return true;
+    }
+
+    if (!in.isString()) {
+        out = in;
+        return true;
+    }
+
+    const QString s = in.toString();
+    if (!s.contains(QStringLiteral("{{")) && !s.contains(QStringLiteral("{%"))) {
+        out = in;
+        return true;
+    }
 
     std::string rendered;
     try {
-        std::lock_guard lock(m_renderMutex);
-        rendered = m_env.render(m_template, data);
+        rendered = env.render(s.toStdString(), data);
     } catch (const std::exception &e) {
-        qWarning("[QodeAssist] Template '%s' render failed: %s",
-                 qUtf8Printable(m_name),
-                 e.what());
-        return std::nullopt;
+        qWarning("[QodeAssist] Template '%s' field render failed: %s",
+                 qUtf8Printable(tplName), e.what());
+        return false;
     }
 
-    QJsonParseError err;
-    const QJsonDocument doc
-        = QJsonDocument::fromJson(QByteArray::fromStdString(rendered), &err);
-    constexpr std::size_t kMaxRenderedLogChars = 500;
-    const std::string truncated = rendered.size() > kMaxRenderedLogChars
-        ? rendered.substr(0, kMaxRenderedLogChars) + "... [truncated]"
-        : rendered;
-    if (err.error != QJsonParseError::NoError) {
-        qWarning("[QodeAssist] Template '%s' produced invalid JSON at offset %d: %s\n"
-                 "--- raw output (truncated) ---\n%s",
-                 qUtf8Printable(m_name),
-                 err.offset,
-                 qUtf8Printable(err.errorString()),
-                 truncated.c_str());
-        return std::nullopt;
+    rendered = stripTrailingCommas(rendered);
+    if (QString::fromStdString(rendered).trimmed().isEmpty()) {
+        omit = true;
+        return true;
     }
-    if (!doc.isObject()) {
-        qWarning("[QodeAssist] Template '%s' rendered a non-object JSON value (truncated):\n%s",
-                 qUtf8Printable(m_name),
-                 truncated.c_str());
-        return std::nullopt;
+
+    // Wrap so ANY JSON value (array/object/string/number) parses via QJsonDocument.
+    const std::string wrapped = "{\"v\":" + rendered + "}";
+    QJsonParseError perr;
+    const QJsonDocument doc = QJsonDocument::fromJson(QByteArray::fromStdString(wrapped), &perr);
+    if (perr.error != QJsonParseError::NoError || !doc.isObject()) {
+        const QString snippet = QString::fromStdString(rendered).left(500);
+        qWarning("[QodeAssist] Template '%s' field produced invalid JSON: %s\n"
+                 "--- rendered (truncated) ---\n%s",
+                 qUtf8Printable(tplName),
+                 qUtf8Printable(perr.errorString()),
+                 qUtf8Printable(snippet));
+        return false;
     }
-    return doc.object();
+    out = doc.object().value(QStringLiteral("v"));
+    return true;
 }
 
-namespace {
-
 bool mergeRenderedBody(QJsonObject &request, const std::optional &body)
 {
     if (!body)
         return false;
-    for (auto it = body->constBegin(); it != body->constEnd(); ++it) {
+    for (auto it = body->constBegin(); it != body->constEnd(); ++it)
         request.insert(it.key(), it.value());
-    }
     return true;
 }
 
-void deepMergeInto(QJsonObject &base, const QJsonObject &overlay)
-{
-    for (auto it = overlay.constBegin(); it != overlay.constEnd(); ++it) {
-        const QJsonValue baseVal = base.value(it.key());
-        const QJsonValue overlayVal = it.value();
-        if (baseVal.isObject() && overlayVal.isObject()) {
-            QJsonObject merged = baseVal.toObject();
-            deepMergeInto(merged, overlayVal.toObject());
-            base[it.key()] = merged;
-        } else {
-            base[it.key()] = overlayVal;
-        }
-    }
-}
-
 } // namespace
 
+std::optional JsonPromptTemplate::renderBody(const ContextData &context) const
+{
+    const nlohmann::json data = buildContextJson(context);
+
+    std::lock_guard lock(m_renderMutex);
+    QJsonObject request;
+    for (auto it = m_body.constBegin(); it != m_body.constEnd(); ++it) {
+        QJsonValue v;
+        bool omit = false;
+        if (!renderValue(m_env, m_name, it.value(), data, v, omit))
+            return std::nullopt;
+        if (!omit)
+            request.insert(it.key(), v);
+    }
+    return request;
+}
+
 void JsonPromptTemplate::prepareRequest(QJsonObject &request, const ContextData &context) const
 {
     mergeRenderedBody(request, renderBody(context));
 }
 
 bool JsonPromptTemplate::buildFullRequest(
-    QJsonObject &request,
-    const ContextData &context,
-    bool thinkingEnabled) const
+    QJsonObject &request, const ContextData &context) const
 {
-    if (!mergeRenderedBody(request, renderBody(context)))
-        return false;
-    applySampling(request, thinkingEnabled);
-    return true;
-}
-
-void JsonPromptTemplate::applySampling(QJsonObject &request, bool thinkingEnabled) const
-{
-    // Merge order: sampling provides defaults → body wins for its own
-    // keys → thinking overrides win on top.
-    QJsonObject merged = m_sampling;
-    deepMergeInto(merged, request);
-
-    if (thinkingEnabled && !m_thinking.isEmpty()) {
-        deepMergeInto(merged, m_thinking.value("overrides").toObject());
-        deepMergeInto(merged, m_thinking.value("request_block").toObject());
-    }
-
-    request = std::move(merged);
+    return mergeRenderedBody(request, renderBody(context));
 }
 
 } // namespace QodeAssist::Templates
diff --git a/sources/templates/JsonPromptTemplate.hpp b/sources/templates/JsonPromptTemplate.hpp
index b38a262..e4435e3 100644
--- a/sources/templates/JsonPromptTemplate.hpp
+++ b/sources/templates/JsonPromptTemplate.hpp
@@ -7,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -40,37 +41,36 @@ public:
     // Standalone-template filters are gone — each template is built for
     // exactly one agent, so it always matches its owner's provider/model.
     bool isSupportProvider(Providers::ProviderID) const override { return true; }
-    bool isSupportModel(const QString &) const override { return true; }
-    PromptShape shape() const override { return PromptShape::Chat; }
 
     void prepareRequest(QJsonObject &request, const ContextData &context) const override;
 
     [[nodiscard]] bool buildFullRequest(
-        QJsonObject &request,
-        const ContextData &context,
-        bool thinkingEnabled = false) const override;
-
-    const QJsonObject &sampling() const { return m_sampling; }
+        QJsonObject &request, const ContextData &context) const override;
 
 private:
     JsonPromptTemplate() = default;
 
     std::optional renderBody(const ContextData &context) const;
-    void applySampling(QJsonObject &request, bool thinkingEnabled) const;
 
     QString m_name;
     QString m_description;
 
+    // The literal request body, as a deep-mergeable object. String values
+    // that contain jinja are rendered and spliced as JSON at request time;
+    // literal strings and scalars pass through unchanged.
+    QJsonObject m_body;
+
+    // Roots searched (in order) by the `{% include %}` resolver. The first
+    // is the bundled qrc partials prefix; an optional second is the user
+    // agent's own directory, so user profiles can ship their own partials.
+    std::vector m_partialRoots;
+
     // m_env is populated once in fromConfig() and never mutated again.
     // It is `mutable` only because inja::Environment::render() is not a
     // const member; m_renderMutex serialises those render() calls since
     // inja's render path is not internally re-entrant on one Environment.
     mutable inja::Environment m_env;
-    inja::Template m_template;
     mutable std::mutex m_renderMutex;
-
-    QJsonObject m_sampling;
-    QJsonObject m_thinking;
 };
 
 } // namespace QodeAssist::Templates
diff --git a/sources/templates/PromptTemplate.hpp b/sources/templates/PromptTemplate.hpp
index ad39ef0..7536b90 100644
--- a/sources/templates/PromptTemplate.hpp
+++ b/sources/templates/PromptTemplate.hpp
@@ -15,11 +15,6 @@ namespace QodeAssist::Templates {
 
 using Providers::ProviderID;
 
-enum class PromptShape {
-    Chat,
-    Fim,
-};
-
 class PromptTemplate
 {
 public:
@@ -35,14 +30,9 @@ public:
     virtual void prepareRequest(QJsonObject &request, const ContextData &context) const = 0;
     virtual QString description() const = 0;
     virtual bool isSupportProvider(ProviderID id) const = 0;
-    virtual PromptShape shape() const { return PromptShape::Chat; }
-
-    virtual bool isSupportModel(const QString & /*modelName*/) const { return true; }
 
     [[nodiscard]] virtual bool buildFullRequest(
-        QJsonObject &request,
-        const ContextData &context,
-        bool /*thinkingEnabled*/ = false) const
+        QJsonObject &request, const ContextData &context) const
     {
         prepareRequest(request, context);
         return true;
diff --git a/taplo.toml b/taplo.toml
new file mode 100644
index 0000000..323a848
--- /dev/null
+++ b/taplo.toml
@@ -0,0 +1,8 @@
+include = ["sources/agents/**/*.toml", "sources/providersConfig/**/*.toml"]
+
+[formatting]
+align_entries = true
+align_comments = true
+allowed_blank_lines = 1
+array_auto_collapse = false
+array_auto_expand = true
diff --git a/templates/Alpaca.hpp b/templates/Alpaca.hpp
deleted file mode 100644
index 6e96c92..0000000
--- a/templates/Alpaca.hpp
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include "pluginllmcore/PromptTemplate.hpp"
-#include 
-
-namespace QodeAssist::Templates {
-
-class Alpaca : public PluginLLMCore::PromptTemplate
-{
-public:
-    QString name() const override { return "Alpaca"; }
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
-    QStringList stopWords() const override
-    {
-        return QStringList() << "### Instruction:" << "### Response:";
-    }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        QJsonArray messages;
-
-        QString fullContent;
-
-        if (context.systemPrompt) {
-            fullContent += context.systemPrompt.value() + "\n\n";
-        }
-
-        if (context.history) {
-            for (const auto &msg : context.history.value()) {
-                if (msg.role == "user") {
-                    fullContent += QString("### Instruction:\n%1\n\n").arg(msg.content);
-                } else if (msg.role == "assistant") {
-                    fullContent += QString("### Response:\n%1\n\n").arg(msg.content);
-                }
-            }
-        }
-
-        messages.append(QJsonObject{{"role", "user"}, {"content", fullContent}});
-
-        request["messages"] = messages;
-    }
-    QString description() const override
-    {
-        return "Template for models using Alpaca instruction format:\n\n"
-               "{\n"
-               "  \"messages\": [\n"
-               "    {\n"
-               "      \"role\": \"user\",\n"
-               "      \"content\": \"\\n\\n"
-               "### Instruction:\\n\\n\\n"
-               "### Response:\\n\\n\\n\"\n"
-               "    }\n"
-               "  ]\n"
-               "}\n\n"
-               "Combines all messages into a single formatted prompt.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case PluginLLMCore::ProviderID::Ollama:
-        case PluginLLMCore::ProviderID::LMStudio:
-        case PluginLLMCore::ProviderID::OpenRouter:
-        case PluginLLMCore::ProviderID::OpenAICompatible:
-        case PluginLLMCore::ProviderID::LlamaCpp:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/ChatML.hpp b/templates/ChatML.hpp
deleted file mode 100644
index b4e0b7f..0000000
--- a/templates/ChatML.hpp
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-
-#include "pluginllmcore/PromptTemplate.hpp"
-
-namespace QodeAssist::Templates {
-
-class ChatML : public PluginLLMCore::PromptTemplate
-{
-public:
-    QString name() const override { return "ChatML"; }
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
-    QStringList stopWords() const override
-    {
-        return QStringList() << "<|im_start|>" << "<|im_end|>";
-    }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        QJsonArray messages;
-
-        if (context.systemPrompt) {
-            messages.append(QJsonObject{
-                {"role", "system"},
-                {"content",
-                 QString("<|im_start|>system\n%2\n<|im_end|>").arg(context.systemPrompt.value())}});
-        }
-
-        if (context.history) {
-            for (const auto &msg : context.history.value()) {
-                messages.append(QJsonObject{
-                    {"role", msg.role},
-                    {"content",
-                     QString("<|im_start|>%1\n%2\n<|im_end|>").arg(msg.role, msg.content)}});
-            }
-        }
-
-        request["messages"] = messages;
-    }
-    QString description() const override
-    {
-        return "Template for models supporting ChatML format:\n\n"
-               "{\n"
-               "  \"messages\": [\n"
-               "    {\n"
-               "      \"role\": \"system\",\n"
-               "      \"content\": \"<|im_start|>system\\n\\n<|im_end|>\"\n"
-               "    },\n"
-               "    {\n"
-               "      \"role\": \"user\",\n"
-               "      \"content\": \"<|im_start|>user\\n\\n<|im_end|>\"\n"
-               "    }\n"
-               "  ]\n"
-               "}\n\n"
-               "Compatible with multiple providers supporting the ChatML token format.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case PluginLLMCore::ProviderID::Ollama:
-        case PluginLLMCore::ProviderID::LMStudio:
-        case PluginLLMCore::ProviderID::OpenRouter:
-        case PluginLLMCore::ProviderID::OpenAICompatible:
-        case PluginLLMCore::ProviderID::LlamaCpp:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/Claude.hpp b/templates/Claude.hpp
deleted file mode 100644
index d549fd0..0000000
--- a/templates/Claude.hpp
+++ /dev/null
@@ -1,150 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-
-#include "pluginllmcore/PromptTemplate.hpp"
-
-namespace QodeAssist::Templates {
-
-class Claude : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
-    QString name() const override { return "Claude"; }
-    QStringList stopWords() const override { return QStringList(); }
-    bool supportsToolHistory() const override { return true; }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        QJsonArray messages;
-
-        if (context.systemPrompt) {
-            request["system"] = context.systemPrompt.value();
-        }
-
-        if (context.history) {
-            int toolResultUserIdx = -1;
-            for (const auto &msg : context.history.value()) {
-                if (msg.role == "system") continue;
-
-                if (!msg.toolCalls.isEmpty()) {
-                    toolResultUserIdx = -1;
-                    QJsonArray content;
-                    if (!msg.content.isEmpty()) {
-                        content.append(QJsonObject{{"type", "text"}, {"text", msg.content}});
-                    }
-                    for (const auto &call : msg.toolCalls) {
-                        content.append(QJsonObject{
-                            {"type", "tool_use"},
-                            {"id", call.id},
-                            {"name", call.name},
-                            {"input", call.arguments}});
-                    }
-                    messages.append(QJsonObject{{"role", "assistant"}, {"content", content}});
-                    continue;
-                }
-
-                if (msg.role == "tool") {
-                    QJsonObject resultBlock{
-                        {"type", "tool_result"},
-                        {"tool_use_id", msg.toolCallId},
-                        {"content", msg.content}};
-                    if (toolResultUserIdx >= 0) {
-                        QJsonObject userMsg = messages[toolResultUserIdx].toObject();
-                        QJsonArray content = userMsg["content"].toArray();
-                        content.append(resultBlock);
-                        userMsg["content"] = content;
-                        messages[toolResultUserIdx] = userMsg;
-                    } else {
-                        messages.append(QJsonObject{
-                            {"role", "user"}, {"content", QJsonArray{resultBlock}}});
-                        toolResultUserIdx = messages.size() - 1;
-                    }
-                    continue;
-                }
-
-                toolResultUserIdx = -1;
-
-                if (msg.isThinking) {
-                    // Claude API requires signature for thinking blocks
-                    if (msg.signature.isEmpty()) {
-                        continue;
-                    }
-                    
-                    QJsonArray content;
-                    QJsonObject thinkingBlock;
-                    thinkingBlock["type"] = msg.isRedacted ? "redacted_thinking" : "thinking";
-                    
-                    QString thinkingText = msg.content;
-                    int signaturePos = thinkingText.indexOf("\n[Signature: ");
-                    if (signaturePos != -1) {
-                        thinkingText = thinkingText.left(signaturePos);
-                    }
-                    
-                    if (!msg.isRedacted) {
-                        thinkingBlock["thinking"] = thinkingText;
-                    }
-                    thinkingBlock["signature"] = msg.signature;
-                    content.append(thinkingBlock);
-                    
-                    messages.append(QJsonObject{{"role", "assistant"}, {"content", content}});
-                } else if (msg.images && !msg.images->isEmpty()) {
-                    QJsonArray content;
-                    
-                    if (!msg.content.isEmpty()) {
-                        content.append(QJsonObject{{"type", "text"}, {"text", msg.content}});
-                    }
-                    
-                    for (const auto &image : msg.images.value()) {
-                        QJsonObject imageBlock;
-                        imageBlock["type"] = "image";
-                        
-                        QJsonObject source;
-                        if (image.isUrl) {
-                            source["type"] = "url";
-                            source["url"] = image.data;
-                        } else {
-                            source["type"] = "base64";
-                            source["media_type"] = image.mediaType;
-                            source["data"] = image.data;
-                        }
-                        imageBlock["source"] = source;
-                        content.append(imageBlock);
-                    }
-                    
-                    messages.append(QJsonObject{{"role", msg.role}, {"content", content}});
-                } else {
-                    messages.append(QJsonObject{{"role", msg.role}, {"content", msg.content}});
-                }
-            }
-        }
-
-        request["messages"] = messages;
-    }
-    QString description() const override
-    {
-        return "Template for Anthropic's Claude models:\n\n"
-               "{\n"
-               "  \"system\": \"\",\n"
-               "  \"messages\": [\n"
-               "    {\"role\": \"user\", \"content\": \"\"},\n"
-               "    {\"role\": \"assistant\", \"content\": \"\"}\n"
-               "  ]\n"
-               "}\n\n"
-               "Formats content according to Claude API specifications.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case QodeAssist::PluginLLMCore::ProviderID::Claude:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/CodeLlamaFim.hpp b/templates/CodeLlamaFim.hpp
deleted file mode 100644
index 8162df0..0000000
--- a/templates/CodeLlamaFim.hpp
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include "pluginllmcore/PromptTemplate.hpp"
-
-namespace QodeAssist::Templates {
-
-class CodeLlamaFim : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::FIM; }
-    QString name() const override { return "CodeLlama FIM"; }
-    QString endpoint() const override { return QStringLiteral("/api/generate"); }
-    QStringList stopWords() const override
-    {
-        return QStringList() << "" << "
" << "";
-    }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        request["prompt"] = QString("
 %1 %2 ")
-                                .arg(context.prefix.value_or(""), context.suffix.value_or(""));
-        request["system"] = context.systemPrompt.value_or("");
-    }
-    QString description() const override
-    {
-        return "Specialized template for CodeLlama FIM:\n\n"
-               "{\n"
-               "  \"prompt\": \"
   \",\n"
-               "  \"system\": \"\"\n"
-               "}\n\n"
-               "Optimized for code completion with CodeLlama models.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case QodeAssist::PluginLLMCore::ProviderID::Ollama:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/CodeLlamaQMLFim.hpp b/templates/CodeLlamaQMLFim.hpp
deleted file mode 100644
index 7732570..0000000
--- a/templates/CodeLlamaQMLFim.hpp
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include "pluginllmcore/PromptTemplate.hpp"
-
-namespace QodeAssist::Templates {
-
-class CodeLlamaQMLFim : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::FIM; }
-    QString name() const override { return "CodeLlama QML FIM"; }
-    QString endpoint() const override { return QStringLiteral("/api/generate"); }
-    QStringList stopWords() const override
-    {
-        return QStringList() << "" << "
" << "
" << "
" << "< EOT >" << "\\end" - << "" << "" << "##"; - } - void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override - { - request["prompt"] = QString("%1
%2")
-                                .arg(context.suffix.value_or(""), context.prefix.value_or(""));
-        request["system"] = context.systemPrompt.value_or("");
-    }
-    QString description() const override
-    {
-        return "Specialized template for QML code completion with CodeLlama:\n\n"
-               "{\n"
-               "  \"prompt\": \"
\",\n"
-               "  \"system\": \"\"\n"
-               "}\n\n"
-               "Specifically optimized for QML/JavaScript code completion.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case QodeAssist::PluginLLMCore::ProviderID::Ollama:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/DeepSeekCoderFim.hpp b/templates/DeepSeekCoderFim.hpp
deleted file mode 100644
index 2a8b64f..0000000
--- a/templates/DeepSeekCoderFim.hpp
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include "llmcore/PromptTemplate.hpp"
-
-namespace QodeAssist::Templates {
-
-class DeepSeekCoderFim : public LLMQore::PromptTemplate
-{
-public:
-    LLMQore::TemplateType type() const override { return LLMQore::TemplateType::Fim; }
-    QString name() const override { return "DeepSeekCoder FIM"; }
-    QString promptTemplate() const override
-    {
-        return "<|fim▁begin|>%1<|fim▁hole|>%2<|fim▁end|>";
-    }
-    QStringList stopWords() const override { return QStringList(); }
-    void prepareRequest(QJsonObject &request, const LLMQore::ContextData &context) const override
-    {
-        QString formattedPrompt = promptTemplate().arg(context.prefix, context.suffix);
-        request["prompt"] = formattedPrompt;
-    }
-    QString description() const override
-    {
-        return "The message will contain the following tokens: "
-               "<|fim▁begin|>%1<|fim▁hole|>%2<|fim▁end|>";
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/GoogleAI.hpp b/templates/GoogleAI.hpp
deleted file mode 100644
index 8e21622..0000000
--- a/templates/GoogleAI.hpp
+++ /dev/null
@@ -1,161 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-#include 
-
-#include "pluginllmcore/PromptTemplate.hpp"
-
-namespace QodeAssist::Templates {
-
-class GoogleAI : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
-    QString name() const override { return "Google AI"; }
-    QStringList stopWords() const override { return QStringList(); }
-    bool supportsToolHistory() const override { return true; }
-
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        QJsonArray contents;
-
-        if (context.systemPrompt && !context.systemPrompt->isEmpty()) {
-            request["system_instruction"] = QJsonObject{
-                {"parts", QJsonObject{{"text", context.systemPrompt.value()}}}};
-        }
-
-        int toolResultIdx = -1;
-        for (const auto &msg : context.history.value()) {
-            if (!msg.toolCalls.isEmpty()) {
-                toolResultIdx = -1;
-                QJsonArray callParts;
-                if (!msg.content.isEmpty()) {
-                    callParts.append(QJsonObject{{"text", msg.content}});
-                }
-                for (const auto &call : msg.toolCalls) {
-                    callParts.append(QJsonObject{
-                        {"functionCall",
-                         QJsonObject{{"name", call.name}, {"args", call.arguments}}}});
-                }
-                contents.append(QJsonObject{{"role", "model"}, {"parts", callParts}});
-                continue;
-            }
-
-            if (msg.role == "tool") {
-                QJsonObject responsePart{
-                    {"functionResponse",
-                     QJsonObject{
-                         {"name", msg.toolName},
-                         {"response", QJsonObject{{"result", msg.content}}}}}};
-                if (toolResultIdx >= 0) {
-                    QJsonObject fnMsg = contents[toolResultIdx].toObject();
-                    QJsonArray fnParts = fnMsg["parts"].toArray();
-                    fnParts.append(responsePart);
-                    fnMsg["parts"] = fnParts;
-                    contents[toolResultIdx] = fnMsg;
-                } else {
-                    contents.append(
-                        QJsonObject{{"role", "function"}, {"parts", QJsonArray{responsePart}}});
-                    toolResultIdx = contents.size() - 1;
-                }
-                continue;
-            }
-
-            toolResultIdx = -1;
-
-            QJsonObject content;
-            QJsonArray parts;
-
-            if (msg.isThinking) {
-                if (!msg.content.isEmpty()) {
-                    QJsonObject thinkingPart;
-                    thinkingPart["text"] = msg.content;
-                    thinkingPart["thought"] = true;
-                    parts.append(thinkingPart);
-                }
-                
-                if (!msg.signature.isEmpty()) {
-                    QJsonObject signaturePart;
-                    signaturePart["thoughtSignature"] = msg.signature;
-                    parts.append(signaturePart);
-                }
-                
-                if (parts.isEmpty()) {
-                    continue;
-                }
-                
-                content["role"] = "model";
-            } else {
-                if (!msg.content.isEmpty()) {
-                    parts.append(QJsonObject{{"text", msg.content}});
-                }
-
-                if (msg.images && !msg.images->isEmpty()) {
-                    for (const auto &image : msg.images.value()) {
-                        QJsonObject imagePart;
-                        
-                        if (image.isUrl) {
-                            QJsonObject fileData;
-                            fileData["mime_type"] = image.mediaType;
-                            fileData["file_uri"] = image.data;
-                            imagePart["file_data"] = fileData;
-                        } else {
-                            QJsonObject inlineData;
-                            inlineData["mime_type"] = image.mediaType;
-                            inlineData["data"] = image.data;
-                            imagePart["inline_data"] = inlineData;
-                        }
-                        
-                        parts.append(imagePart);
-                    }
-                }
-
-                QString role = msg.role;
-                if (role == "assistant") {
-                    role = "model";
-                }
-                
-                content["role"] = role;
-            }
-
-            content["parts"] = parts;
-            contents.append(content);
-        }
-
-        request["contents"] = contents;
-    }
-
-    QString description() const override
-    {
-        return "Template for Google AI models (Gemini):\n\n"
-               "{\n"
-               "  \"system_instruction\": {\"parts\": {\"text\": \"\"}},\n"
-               "  \"contents\": [\n"
-               "    {\n"
-               "      \"role\": \"user\",\n"
-               "      \"parts\": [{\"text\": \"\"}]\n"
-               "    },\n"
-               "    {\n"
-               "      \"role\": \"model\",\n"
-               "      \"parts\": [\n"
-               "        {\"text\": \"\", \"thought\": true},\n"
-               "        {\"thoughtSignature\": \"\"},\n"
-               "        {\"text\": \"\"}\n"
-               "      ]\n"
-               "    }\n"
-               "  ]\n"
-               "}\n\n"
-               "Supports proper role mapping (model/user roles), images, and thinking blocks.";
-    }
-
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        return id == QodeAssist::PluginLLMCore::ProviderID::GoogleAI;
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/Llama2.hpp b/templates/Llama2.hpp
deleted file mode 100644
index 39cfc11..0000000
--- a/templates/Llama2.hpp
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include "pluginllmcore/PromptTemplate.hpp"
-#include 
-
-namespace QodeAssist::Templates {
-
-class Llama2 : public PluginLLMCore::PromptTemplate
-{
-public:
-    QString name() const override { return "Llama 2"; }
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
-    QStringList stopWords() const override { return QStringList() << "[INST]"; }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        QJsonArray messages;
-
-        QString fullContent;
-
-        if (context.systemPrompt) {
-            fullContent
-                += QString("[INST]<>\n%1\n<>[/INST]\n").arg(context.systemPrompt.value());
-        }
-
-        if (context.history) {
-            for (const auto &msg : context.history.value()) {
-                if (msg.role == "user") {
-                    fullContent += QString("[INST]%1[/INST]\n").arg(msg.content);
-                } else if (msg.role == "assistant") {
-                    fullContent += msg.content + "\n";
-                }
-            }
-        }
-
-        messages.append(QJsonObject{{"role", "user"}, {"content", fullContent}});
-
-        request["messages"] = messages;
-    }
-    QString description() const override
-    {
-        return "Template for Llama 2 models:\n\n"
-               "{\n"
-               "  \"messages\": [\n"
-               "    {\n"
-               "      \"role\": \"user\",\n"
-               "      \"content\": \"[INST]<>\\n\\n<>[/INST]\\n"
-               "\\n"
-               "[INST][/INST]\\n\"\n"
-               "    }\n"
-               "  ]\n"
-               "}\n\n"
-               "Compatible with Ollama, LM Studio, and other services for Llama 2.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case PluginLLMCore::ProviderID::Ollama:
-        case PluginLLMCore::ProviderID::LMStudio:
-        case PluginLLMCore::ProviderID::OpenRouter:
-        case PluginLLMCore::ProviderID::OpenAICompatible:
-        case PluginLLMCore::ProviderID::LlamaCpp:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/Llama3.hpp b/templates/Llama3.hpp
deleted file mode 100644
index 9b591bd..0000000
--- a/templates/Llama3.hpp
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-
-#include "pluginllmcore/PromptTemplate.hpp"
-
-namespace QodeAssist::Templates {
-
-class Llama3 : public PluginLLMCore::PromptTemplate
-{
-public:
-    QString name() const override { return "Llama 3"; }
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
-    QStringList stopWords() const override
-    {
-        return QStringList() << "<|start_header_id|>" << "<|end_header_id|>" << "<|eot_id|>";
-    }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        QJsonArray messages;
-
-        if (context.systemPrompt) {
-            messages.append(QJsonObject{
-                {"role", "system"},
-                {"content",
-                 QString("<|start_header_id|>system<|end_header_id|>%2<|eot_id|>")
-                     .arg(context.systemPrompt.value())}});
-        }
-
-        if (context.history) {
-            for (const auto &msg : context.history.value()) {
-                messages.append(QJsonObject{
-                    {"role", msg.role},
-                    {"content",
-                     QString("<|start_header_id|>%1<|end_header_id|>%2<|eot_id|>")
-                         .arg(msg.role, msg.content)}});
-            }
-        }
-
-        request["messages"] = messages;
-    }
-    QString description() const override
-    {
-        return "Template for Llama 3 models:\n\n"
-               "{\n"
-               "  \"messages\": [\n"
-               "    {\n"
-               "      \"role\": \"system\",\n"
-               "      \"content\": \"<|start_header_id|>system<|end_header_id|><|eot_id|>\"\n"
-               "    },\n"
-               "    {\n"
-               "      \"role\": \"user\",\n"
-               "      \"content\": \"<|start_header_id|>user<|end_header_id|><|eot_id|>\"\n"
-               "    }\n"
-               "  ]\n"
-               "}\n\n"
-               "Compatible with Ollama, LM Studio, and OpenAI-compatible services for Llama 3.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case PluginLLMCore::ProviderID::Ollama:
-        case PluginLLMCore::ProviderID::LMStudio:
-        case PluginLLMCore::ProviderID::OpenRouter:
-        case PluginLLMCore::ProviderID::OpenAICompatible:
-        case PluginLLMCore::ProviderID::LlamaCpp:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/LlamaCppFim.hpp b/templates/LlamaCppFim.hpp
deleted file mode 100644
index 7f80d69..0000000
--- a/templates/LlamaCppFim.hpp
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-
-#include "pluginllmcore/PromptTemplate.hpp"
-
-namespace QodeAssist::Templates {
-
-class LlamaCppFim : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::FIM; }
-    QString name() const override { return "llama.cpp FIM"; }
-    QString endpoint() const override { return QStringLiteral("/infill"); }
-    QStringList stopWords() const override { return {}; }
-
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        request["input_prefix"] = context.prefix.value_or("");
-        request["input_suffix"] = context.suffix.value_or("");
-
-        if (context.filesMetadata && !context.filesMetadata->isEmpty()) {
-            QJsonArray filesArray;
-            for (const auto &file : *context.filesMetadata) {
-                QJsonObject fileObj;
-                fileObj["filename"] = file.filePath;
-                fileObj["text"] = file.content;
-                filesArray.append(fileObj);
-            }
-            request["input_extra"] = filesArray;
-        }
-    }
-
-    QString description() const override
-    {
-        return "Default llama.cpp FIM (Fill-in-Middle) /infill template with native format:\n\n"
-               "{\n"
-               "  \"input_prefix\": \"\",\n"
-               "  \"input_suffix\": \"\",\n"
-               "  \"input_extra\": \"\"\n"
-               "}\n\n"
-               "Recommended for models with FIM capability.";
-    }
-
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        return id == QodeAssist::PluginLLMCore::ProviderID::LlamaCpp;
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/MistralAI.hpp b/templates/MistralAI.hpp
deleted file mode 100644
index e396325..0000000
--- a/templates/MistralAI.hpp
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-
-#include "pluginllmcore/PromptTemplate.hpp"
-#include "templates/ToolMessages.hpp"
-
-namespace QodeAssist::Templates {
-
-class MistralAIFim : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::FIM; }
-    QString name() const override { return "Mistral AI FIM"; }
-    QString endpoint() const override { return QStringLiteral("/v1/fim/completions"); }
-    QStringList stopWords() const override { return QStringList(); }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        request["prompt"] = context.prefix.value_or("");
-        request["suffix"] = context.suffix.value_or("");
-    }
-    QString description() const override
-    {
-        return "Template for MistralAI models with FIM support:\n\n"
-               "{\n"
-               "  \"prompt\": \"\",\n"
-               "  \"suffix\": \"\"\n"
-               "}\n\n"
-               "Optimized for code completion with MistralAI models.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case QodeAssist::PluginLLMCore::ProviderID::MistralAI:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-class MistralAIChat : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
-    QString name() const override { return "Mistral AI Chat"; }
-    QStringList stopWords() const override { return QStringList(); }
-    bool supportsToolHistory() const override { return true; }
-
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        QJsonArray messages;
-
-        if (context.systemPrompt) {
-            messages.append(
-                QJsonObject{{"role", "system"}, {"content", context.systemPrompt.value()}});
-        }
-
-        if (context.history) {
-            for (const auto &msg : context.history.value()) {
-                if (appendOpenAIToolMessage(messages, msg)) {
-                    continue;
-                }
-                if (msg.images && !msg.images->isEmpty()) {
-                    QJsonArray content;
-                    
-                    if (!msg.content.isEmpty()) {
-                        content.append(QJsonObject{{"type", "text"}, {"text", msg.content}});
-                    }
-                    
-                    for (const auto &image : msg.images.value()) {
-                        QJsonObject imageBlock;
-                        imageBlock["type"] = "image_url";
-                        
-                        QJsonObject imageUrl;
-                        if (image.isUrl) {
-                            imageUrl["url"] = image.data;
-                        } else {
-                            imageUrl["url"] = QString("data:%1;base64,%2").arg(image.mediaType, image.data);
-                        }
-                        imageBlock["image_url"] = imageUrl;
-                        content.append(imageBlock);
-                    }
-                    
-                    messages.append(QJsonObject{{"role", msg.role}, {"content", content}});
-                } else {
-                    messages.append(QJsonObject{{"role", msg.role}, {"content", msg.content}});
-                }
-            }
-        }
-
-        request["messages"] = messages;
-    }
-    QString description() const override
-    {
-        return "Template for MistralAI chat-capable models:\n\n"
-               "{\n"
-               "  \"messages\": [\n"
-               "    {\"role\": \"system\", \"content\": \"\"},\n"
-               "    {\"role\": \"user\", \"content\": \"\"},\n"
-               "    {\"role\": \"assistant\", \"content\": \"\"}\n"
-               "  ]\n"
-               "}\n\n"
-               "Supports system messages, conversation history, and images.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case QodeAssist::PluginLLMCore::ProviderID::MistralAI:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/Ollama.hpp b/templates/Ollama.hpp
deleted file mode 100644
index a711c24..0000000
--- a/templates/Ollama.hpp
+++ /dev/null
@@ -1,128 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-
-#include "pluginllmcore/PromptTemplate.hpp"
-
-namespace QodeAssist::Templates {
-
-class OllamaFim : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::FIM; }
-    QString name() const override { return "Ollama FIM"; }
-    QString endpoint() const override { return QStringLiteral("/api/generate"); }
-    QStringList stopWords() const override { return QStringList() << ""; }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        request["prompt"] = context.prefix.value_or("");
-        request["suffix"] = context.suffix.value_or("");
-        request["system"] = context.systemPrompt.value_or("");
-    }
-    QString description() const override
-    {
-        return "Default Ollama FIM (Fill-in-Middle) template with native format:\n\n"
-               "{\n"
-               "  \"prompt\": \"\",\n"
-               "  \"suffix\": \"\",\n"
-               "  \"system\": \"\"\n"
-               "}\n\n"
-               "Recommended for Ollama models with FIM capability.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case QodeAssist::PluginLLMCore::ProviderID::Ollama:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-class OllamaChat : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
-    QString name() const override { return "Ollama Chat"; }
-    QStringList stopWords() const override { return QStringList(); }
-    bool supportsToolHistory() const override { return true; }
-
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        QJsonArray messages;
-
-        if (context.systemPrompt) {
-            messages.append(
-                QJsonObject{{"role", "system"}, {"content", context.systemPrompt.value()}});
-        }
-
-        if (context.history) {
-            for (const auto &msg : context.history.value()) {
-                QJsonObject messageObj;
-                messageObj["role"] = msg.role;
-
-                if (!msg.toolCalls.isEmpty()) {
-                    QJsonArray toolCalls;
-                    for (const auto &call : msg.toolCalls) {
-                        toolCalls.append(QJsonObject{
-                            {"type", "function"},
-                            {"function",
-                             QJsonObject{{"name", call.name}, {"arguments", call.arguments}}}});
-                    }
-                    messageObj["tool_calls"] = toolCalls;
-                    if (!msg.content.isEmpty()) {
-                        messageObj["content"] = msg.content;
-                    }
-                } else {
-                    messageObj["content"] = msg.content;
-                    // Ollama correlates a tool result to its originating
-                    // call by tool_name; omitting it breaks multi-tool turns.
-                    if (msg.role == QLatin1String("tool") && !msg.toolName.isEmpty()) {
-                        messageObj["tool_name"] = msg.toolName;
-                    }
-                }
-
-                if (msg.images && !msg.images->isEmpty()) {
-                    QJsonArray images;
-                    for (const auto &image : msg.images.value()) {
-                        images.append(image.data);
-                    }
-                    messageObj["images"] = images;
-                }
-                
-                messages.append(messageObj);
-            }
-        }
-
-        request["messages"] = messages;
-    }
-    QString description() const override
-    {
-        return "Template for Ollama Chat with message array format:\n\n"
-               "{\n"
-               "  \"messages\": [\n"
-               "    {\"role\": \"system\", \"content\": \"\"},\n"
-               "    {\"role\": \"user\", \"content\": \"\", \"images\": [\"\"]},\n"
-               "    {\"role\": \"assistant\", \"content\": \"\"}\n"
-               "  ]\n"
-               "}\n\n"
-               "Recommended for Ollama models with chat capability.\n"
-               "Supports images for multimodal models (e.g., llava).";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case QodeAssist::PluginLLMCore::ProviderID::Ollama:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/OpenAI.hpp b/templates/OpenAI.hpp
deleted file mode 100644
index 6762b3f..0000000
--- a/templates/OpenAI.hpp
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-
-#include "pluginllmcore/PromptTemplate.hpp"
-#include "templates/ToolMessages.hpp"
-
-namespace QodeAssist::Templates {
-
-class OpenAI : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
-    QString name() const override { return "OpenAI"; }
-    QStringList stopWords() const override { return QStringList(); }
-    bool supportsToolHistory() const override { return true; }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        QJsonArray messages;
-
-        if (context.systemPrompt) {
-            messages.append(
-                QJsonObject{{"role", "system"}, {"content", context.systemPrompt.value()}});
-        }
-
-        if (context.history) {
-            for (const auto &msg : context.history.value()) {
-                if (appendOpenAIToolMessage(messages, msg)) {
-                    continue;
-                }
-                if (msg.images && !msg.images->isEmpty()) {
-                    QJsonArray content;
-                    
-                    if (!msg.content.isEmpty()) {
-                        content.append(QJsonObject{{"type", "text"}, {"text", msg.content}});
-                    }
-                    
-                    for (const auto &image : msg.images.value()) {
-                        QJsonObject imageBlock;
-                        imageBlock["type"] = "image_url";
-                        
-                        QJsonObject imageUrl;
-                        if (image.isUrl) {
-                            imageUrl["url"] = image.data;
-                        } else {
-                            imageUrl["url"] = QString("data:%1;base64,%2").arg(image.mediaType, image.data);
-                        }
-                        imageBlock["image_url"] = imageUrl;
-                        content.append(imageBlock);
-                    }
-                    
-                    messages.append(QJsonObject{{"role", msg.role}, {"content", content}});
-                } else {
-                    messages.append(QJsonObject{{"role", msg.role}, {"content", msg.content}});
-                }
-            }
-        }
-
-        request["messages"] = messages;
-    }
-    QString description() const override
-    {
-        return "Template for OpenAI models (GPT series):\n\n"
-               "{\n"
-               "  \"messages\": [\n"
-               "    {\"role\": \"system\", \"content\": \"\"},\n"
-               "    {\"role\": \"user\", \"content\": \"\"},\n"
-               "    {\"role\": \"assistant\", \"content\": \"\"}\n"
-               "  ]\n"
-               "}\n\n"
-               "Standard Chat API format for OpenAI.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case QodeAssist::PluginLLMCore::ProviderID::OpenAI:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/OpenAICompatible.hpp b/templates/OpenAICompatible.hpp
deleted file mode 100644
index 7ef4b14..0000000
--- a/templates/OpenAICompatible.hpp
+++ /dev/null
@@ -1,94 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-
-#include "pluginllmcore/PromptTemplate.hpp"
-#include "templates/ToolMessages.hpp"
-
-namespace QodeAssist::Templates {
-
-class OpenAICompatible : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::Chat; }
-    QString name() const override { return "OpenAI Compatible"; }
-    QStringList stopWords() const override { return QStringList(); }
-    bool supportsToolHistory() const override { return true; }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        QJsonArray messages;
-
-        if (context.systemPrompt) {
-            messages.append(
-                QJsonObject{{"role", "system"}, {"content", context.systemPrompt.value()}});
-        }
-
-        if (context.history) {
-            for (const auto &msg : context.history.value()) {
-                if (appendOpenAIToolMessage(messages, msg)) {
-                    continue;
-                }
-                if (msg.images && !msg.images->isEmpty()) {
-                    QJsonArray content;
-                    
-                    if (!msg.content.isEmpty()) {
-                        content.append(QJsonObject{{"type", "text"}, {"text", msg.content}});
-                    }
-                    
-                    for (const auto &image : msg.images.value()) {
-                        QJsonObject imageBlock;
-                        imageBlock["type"] = "image_url";
-                        
-                        QJsonObject imageUrl;
-                        if (image.isUrl) {
-                            imageUrl["url"] = image.data;
-                        } else {
-                            imageUrl["url"] = QString("data:%1;base64,%2").arg(image.mediaType, image.data);
-                        }
-                        imageBlock["image_url"] = imageUrl;
-                        content.append(imageBlock);
-                    }
-                    
-                    messages.append(QJsonObject{{"role", msg.role}, {"content", content}});
-                } else {
-                    messages.append(QJsonObject{{"role", msg.role}, {"content", msg.content}});
-                }
-            }
-        }
-
-        request["messages"] = messages;
-    }
-    QString description() const override
-    {
-        return "Generic template for OpenAI API-compatible services:\n\n"
-               "{\n"
-               "  \"messages\": [\n"
-               "    {\"role\": \"system\", \"content\": \"\"},\n"
-               "    {\"role\": \"user\", \"content\": \"\"},\n"
-               "    {\"role\": \"assistant\", \"content\": \"\"}\n"
-               "  ]\n"
-               "}\n\n"
-               "Works with any service implementing the OpenAI Chat API specification.\n"
-               "Supports images.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case PluginLLMCore::ProviderID::OpenAICompatible:
-        case PluginLLMCore::ProviderID::OpenRouter:
-        case PluginLLMCore::ProviderID::LMStudio:
-        case PluginLLMCore::ProviderID::LlamaCpp:
-        case PluginLLMCore::ProviderID::Qwen:
-        case PluginLLMCore::ProviderID::DeepSeek:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/OpenAIResponses.hpp b/templates/OpenAIResponses.hpp
deleted file mode 100644
index e1b417c..0000000
--- a/templates/OpenAIResponses.hpp
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include "pluginllmcore/PromptTemplate.hpp"
-
-#include 
-#include 
-#include 
-
-namespace QodeAssist::Templates {
-
-class OpenAIResponses : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const noexcept override
-    {
-        return PluginLLMCore::TemplateType::Chat;
-    }
-
-    QString name() const override { return "OpenAI Responses"; }
-
-    QStringList stopWords() const override { return {}; }
-
-    bool supportsToolHistory() const override { return true; }
-
-    void prepareRequest(
-        QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        if (context.systemPrompt) {
-            request["instructions"] = context.systemPrompt.value();
-        }
-
-        if (!context.history || context.history->isEmpty()) {
-            return;
-        }
-
-        QJsonArray input;
-        for (const auto &msg : context.history.value()) {
-            if (msg.role == "system") {
-                continue;
-            }
-
-            if (!msg.toolCalls.isEmpty()) {
-                if (!msg.content.isEmpty()) {
-                    input.append(QJsonObject{{"role", "assistant"}, {"content", msg.content}});
-                }
-                for (const auto &call : msg.toolCalls) {
-                    input.append(QJsonObject{
-                        {"type", "function_call"},
-                        {"call_id", call.id},
-                        {"name", call.name},
-                        {"arguments",
-                         QString::fromUtf8(
-                             QJsonDocument(call.arguments).toJson(QJsonDocument::Compact))}});
-                }
-                continue;
-            }
-
-            if (msg.role == "tool") {
-                input.append(QJsonObject{
-                    {"type", "function_call_output"},
-                    {"call_id", msg.toolCallId},
-                    {"output", msg.content}});
-                continue;
-            }
-
-            QJsonObject message;
-            message["role"] = msg.role;
-
-            const bool hasImages = msg.images && !msg.images->isEmpty();
-
-            if (!hasImages) {
-                message["content"] = msg.content;
-            } else {
-                QJsonArray content;
-                if (!msg.content.isEmpty()) {
-                    content.append(
-                        QJsonObject{{"type", "input_text"}, {"text", msg.content}});
-                }
-
-                for (const auto &image : msg.images.value()) {
-                    QJsonObject imgObj{{"type", "input_image"}, {"detail", "auto"}};
-                    if (image.isUrl) {
-                        imgObj["image_url"] = image.data;
-                    } else {
-                        imgObj["image_url"]
-                            = QString("data:%1;base64,%2").arg(image.mediaType, image.data);
-                    }
-                    content.append(imgObj);
-                }
-
-                message["content"] = content;
-            }
-
-            input.append(message);
-        }
-
-        request["input"] = input;
-    }
-
-    QString description() const override
-    {
-        return "Template for OpenAI Responses API:\n\n"
-               "Simple request:\n"
-               "{\n"
-               "  \"input\": \"\"\n"
-               "}\n\n"
-               "Multi-turn conversation:\n"
-               "{\n"
-               "  \"instructions\": \"\",\n"
-               "  \"input\": [\n"
-               "    {\"role\": \"user\", \"content\": \"\"}\n"
-               "  ]\n"
-               "}";
-    }
-
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const noexcept override
-    {
-        return id == QodeAssist::PluginLLMCore::ProviderID::OpenAIResponses;
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/Qwen25CoderFIM.hpp b/templates/Qwen25CoderFIM.hpp
deleted file mode 100644
index 08e7e9a..0000000
--- a/templates/Qwen25CoderFIM.hpp
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include "pluginllmcore/PromptTemplate.hpp"
-#include 
-
-namespace QodeAssist::Templates {
-
-class Qwen25CoderFIM : public PluginLLMCore::PromptTemplate
-{
-public:
-    QString name() const override { return "Qwen2.5 Coder FIM"; }
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::FIM; }
-    QString endpoint() const override { return QStringLiteral("/api/generate"); }
-    QStringList stopWords() const override { return QStringList() << "<|endoftext|>" << "<|EOT|>"; }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        request["prompt"] = QString("<|fim_prefix|>%1<|fim_suffix|>%2<|fim_middle|>")
-                                .arg(context.prefix.value_or(""), context.suffix.value_or(""));
-        request["system"] = context.systemPrompt.value_or("");
-    }
-    QString description() const override
-    {
-        return "Template for Qwen models with FIM support:\n\n"
-               "{\n"
-               "  \"prompt\": \"<|fim_prefix|><|fim_suffix|><|fim_middle|>\",\n"
-               "  \"system\": \"\"\n"
-               "}\n\n"
-               "Ideal for code completion with Qwen models.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case QodeAssist::PluginLLMCore::ProviderID::Ollama:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/Qwen3CoderFIM.hpp b/templates/Qwen3CoderFIM.hpp
deleted file mode 100644
index d4dceee..0000000
--- a/templates/Qwen3CoderFIM.hpp
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-
-#include "pluginllmcore/PromptTemplate.hpp"
-
-namespace QodeAssist::Templates {
-
-class Qwen3CoderFIM : public PluginLLMCore::PromptTemplate
-{
-public:
-    QString name() const override { return "Qwen3 Coder FIM"; }
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::FIMOnChat; }
-    QStringList stopWords() const override { return QStringList() << "<|im_end|>"; }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        QJsonArray messages;
-
-        messages.append(
-            QJsonObject{{"role", "system"}, {"content", context.systemPrompt.value_or("")}});
-
-        messages.append(
-            QJsonObject{
-                {"role", "user"},
-                {"content",
-                 QString("<|fim_prefix|>%1<|fim_suffix|>%2<|fim_middle|>")
-                     .arg(context.prefix.value_or(""), context.suffix.value_or(""))}});
-        request["messages"] = messages;
-    }
-    QString description() const override
-    {
-        return "Template for supporting Qwen3 Coder FIM format via chat template:\n\n"
-               "{\n"
-               "  \"messages\": [\n"
-               "    {\n"
-               "      \"role\": \"system\",\n"
-               "      \"content\": \"You are a code completion assistant.\"\n"
-               "    },\n"
-               "    {\n"
-               "      \"role\": \"user\",\n"
-               "      \"content\": \"<|fim_prefix|>code<|fim_suffix|>code<|fim_middle|>\"\n"
-               "    }\n"
-               "  ]\n"
-               "}\n\n";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case PluginLLMCore::ProviderID::Ollama:
-        case PluginLLMCore::ProviderID::LMStudio:
-        case PluginLLMCore::ProviderID::OpenRouter:
-        case PluginLLMCore::ProviderID::OpenAICompatible:
-        case PluginLLMCore::ProviderID::LlamaCpp:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/StarCoder2Fim.hpp b/templates/StarCoder2Fim.hpp
deleted file mode 100644
index 8ebb02f..0000000
--- a/templates/StarCoder2Fim.hpp
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include "pluginllmcore/PromptTemplate.hpp"
-
-namespace QodeAssist::Templates {
-
-class StarCoder2Fim : public PluginLLMCore::PromptTemplate
-{
-public:
-    PluginLLMCore::TemplateType type() const override { return PluginLLMCore::TemplateType::FIM; }
-    QString name() const override { return "StarCoder2 FIM"; }
-    QString endpoint() const override { return QStringLiteral("/api/generate"); }
-    QStringList stopWords() const override
-    {
-        return QStringList() << "<|endoftext|>" << "" << "" << ""
-                             << "";
-    }
-    void prepareRequest(QJsonObject &request, const PluginLLMCore::ContextData &context) const override
-    {
-        request["prompt"] = QString("%1%2")
-                                .arg(context.prefix.value_or(""), context.suffix.value_or(""));
-        request["system"] = context.systemPrompt.value_or("");
-    }
-    QString description() const override
-    {
-        return "Template for StarCoder2 with FIM format:\n\n"
-               "{\n"
-               "  \"prompt\": \"\",\n"
-               "  \"system\": \"\"\n"
-               "}\n\n"
-               "Includes stop words to prevent token duplication.";
-    }
-    bool isSupportProvider(PluginLLMCore::ProviderID id) const override
-    {
-        switch (id) {
-        case QodeAssist::PluginLLMCore::ProviderID::Ollama:
-            return true;
-        default:
-            return false;
-        }
-    }
-};
-
-} // namespace QodeAssist::Templates
diff --git a/templates/Templates.hpp b/templates/Templates.hpp
deleted file mode 100644
index a35c9a1..0000000
--- a/templates/Templates.hpp
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include "pluginllmcore/PromptTemplateManager.hpp"
-#include "templates/Alpaca.hpp"
-#include "templates/ChatML.hpp"
-#include "templates/Claude.hpp"
-#include "templates/CodeLlamaFim.hpp"
-#include "templates/CodeLlamaQMLFim.hpp"
-#include "templates/MistralAI.hpp"
-#include "templates/Ollama.hpp"
-#include "templates/OpenAI.hpp"
-#include "templates/OpenAICompatible.hpp"
-#include "templates/OpenAIResponses.hpp"
-#include "templates/GoogleAI.hpp"
-#include "templates/Llama2.hpp"
-#include "templates/Llama3.hpp"
-#include "templates/LlamaCppFim.hpp"
-#include "templates/Qwen25CoderFIM.hpp"
-#include "templates/Qwen3CoderFIM.hpp"
-#include "templates/StarCoder2Fim.hpp"
-
-namespace QodeAssist::Templates {
-
-inline void registerTemplates()
-{
-    auto &templateManager = PluginLLMCore::PromptTemplateManager::instance();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-    templateManager.registerTemplate();
-}
-
-} // namespace QodeAssist::Templates
diff --git a/templates/ToolMessages.hpp b/templates/ToolMessages.hpp
deleted file mode 100644
index 9bc4f21..0000000
--- a/templates/ToolMessages.hpp
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-#include 
-#include 
-#include 
-
-#include "pluginllmcore/ContextData.hpp"
-
-namespace QodeAssist::Templates {
-
-inline bool appendOpenAIToolMessage(QJsonArray &messages, const PluginLLMCore::Message &msg)
-{
-    if (!msg.toolCalls.isEmpty()) {
-        QJsonArray toolCalls;
-        for (const auto &call : msg.toolCalls) {
-            toolCalls.append(QJsonObject{
-                {"id", call.id},
-                {"type", "function"},
-                {"function",
-                 QJsonObject{
-                     {"name", call.name},
-                     {"arguments",
-                      QString::fromUtf8(
-                          QJsonDocument(call.arguments).toJson(QJsonDocument::Compact))}}}});
-        }
-        QJsonObject toolMessage{{"role", "assistant"}, {"tool_calls", toolCalls}};
-        toolMessage["content"] = msg.content.isEmpty() ? QJsonValue() : QJsonValue(msg.content);
-        messages.append(toolMessage);
-        return true;
-    }
-
-    if (msg.role == QLatin1String("tool")) {
-        messages.append(QJsonObject{
-            {"role", "tool"}, {"tool_call_id", msg.toolCallId}, {"content", msg.content}});
-        return true;
-    }
-
-    return false;
-}
-
-} // namespace QodeAssist::Templates
diff --git a/test/AgentConfigTest.cpp b/test/AgentConfigTest.cpp
new file mode 100644
index 0000000..f040e3b
--- /dev/null
+++ b/test/AgentConfigTest.cpp
@@ -0,0 +1,115 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+
+#include 
+
+using QodeAssist::AgentConfig;
+
+namespace {
+
+AgentConfig makeValid()
+{
+    AgentConfig cfg;
+    cfg.name = QStringLiteral("Agent");
+    cfg.providerInstance = QStringLiteral("P");
+    cfg.model = QStringLiteral("m");
+    cfg.endpoint = QStringLiteral("/e");
+    cfg.body = QJsonObject{{"stream", true}};
+    return cfg;
+}
+
+} // namespace
+
+TEST(AgentConfigTest, ValidConfigReturnsNoError)
+{
+    EXPECT_TRUE(AgentConfig::validate(makeValid()).isEmpty());
+}
+
+TEST(AgentConfigTest, MissingNameRejected)
+{
+    AgentConfig cfg = makeValid();
+    cfg.name.clear();
+    EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("no name")));
+}
+
+TEST(AgentConfigTest, MissingProviderInstanceRejected)
+{
+    AgentConfig cfg = makeValid();
+    cfg.providerInstance.clear();
+    EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("provider_instance")));
+}
+
+TEST(AgentConfigTest, MissingModelRejected)
+{
+    AgentConfig cfg = makeValid();
+    cfg.model.clear();
+    EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("no model")));
+}
+
+TEST(AgentConfigTest, MissingEndpointRejected)
+{
+    AgentConfig cfg = makeValid();
+    cfg.endpoint.clear();
+    EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("no endpoint")));
+}
+
+TEST(AgentConfigTest, MissingBodyRejected)
+{
+    AgentConfig cfg = makeValid();
+    cfg.body = QJsonObject{};
+    EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("[body]")));
+}
+
+TEST(AgentConfigTest, FutureSchemaVersionRejected)
+{
+    AgentConfig cfg = makeValid();
+    cfg.schemaVersion = AgentConfig::kSupportedSchemaVersion + 1;
+    EXPECT_TRUE(AgentConfig::validate(cfg).contains(QStringLiteral("schema_version")));
+}
+
+TEST(AgentConfigTest, SupportedSchemaVersionAccepted)
+{
+    AgentConfig cfg = makeValid();
+    cfg.schemaVersion = AgentConfig::kSupportedSchemaVersion;
+    EXPECT_TRUE(AgentConfig::validate(cfg).isEmpty());
+}
+
+TEST(AgentConfigTest, MatchIsEmptyWhenAllDimensionsEmpty)
+{
+    AgentConfig::Match m;
+    EXPECT_TRUE(m.isEmpty());
+}
+
+TEST(AgentConfigTest, MatchIsNotEmptyWhenAnyDimensionSet)
+{
+    AgentConfig::Match files;
+    files.filePatterns = {QStringLiteral("*.cpp")};
+    EXPECT_FALSE(files.isEmpty());
+
+    AgentConfig::Match paths;
+    paths.pathPatterns = {QStringLiteral("*/src/*")};
+    EXPECT_FALSE(paths.isEmpty());
+
+    AgentConfig::Match projects;
+    projects.projectNames = {QStringLiteral("P")};
+    EXPECT_FALSE(projects.isEmpty());
+}
+
+TEST(AgentConfigTest, IsUserSourceFalseForBundledResourcePath)
+{
+    AgentConfig cfg = makeValid();
+    cfg.sourcePath = QStringLiteral(":/agents/claude_chat.toml");
+    EXPECT_FALSE(cfg.isUserSource());
+}
+
+TEST(AgentConfigTest, IsUserSourceTrueForFilesystemPath)
+{
+    AgentConfig cfg = makeValid();
+    cfg.sourcePath = QStringLiteral("/home/me/.config/agents/mine.toml");
+    EXPECT_TRUE(cfg.isUserSource());
+}
diff --git a/test/AgentLoaderTest.cpp b/test/AgentLoaderTest.cpp
new file mode 100644
index 0000000..59f4bb5
--- /dev/null
+++ b/test/AgentLoaderTest.cpp
@@ -0,0 +1,184 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+using QodeAssist::AgentConfig;
+using QodeAssist::Agents::AgentLoader;
+
+namespace {
+
+void writeFile(const QString &dir, const QString &name, const QByteArray &contents)
+{
+    QFile f(dir + QLatin1Char('/') + name);
+    ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text));
+    f.write(contents);
+}
+
+QByteArray minimalAgent(const QByteArray &name, const QByteArray &extra = {})
+{
+    return "name = \"" + name + "\"\n"
+           "provider_instance = \"P\"\n"
+           "model = \"m\"\n"
+           "endpoint = \"/e\"\n"
+           + extra +
+           "[body]\n"
+           "stream = true\n";
+}
+
+const AgentConfig *findConfig(const AgentLoader::LoadResult &result, const QString &name)
+{
+    for (const auto &cfg : result.configs) {
+        if (cfg.name == name)
+            return &cfg;
+    }
+    return nullptr;
+}
+
+bool anyContains(const QStringList &list, const QString &needle)
+{
+    for (const QString &s : list) {
+        if (s.contains(needle))
+            return true;
+    }
+    return false;
+}
+
+} // namespace
+
+TEST(AgentLoaderTest, WarnsOnUnknownTopLevelAndMatchKeys)
+{
+    QTemporaryDir dir;
+    ASSERT_TRUE(dir.isValid());
+    writeFile(dir.path(), "a.toml",
+              minimalAgent("A",
+                           "enable_thinkin = true\n"
+                           "[match]\n"
+                           "file_pattern = [\"*.cpp\"]\n"));
+
+    const auto result = AgentLoader::load(QString(), dir.path());
+    EXPECT_TRUE(result.errors.isEmpty()) << result.errors.join("; ").toStdString();
+    EXPECT_TRUE(anyContains(result.warnings, QStringLiteral("enable_thinkin")));
+    EXPECT_TRUE(anyContains(result.warnings, QStringLiteral("match.file_pattern")));
+}
+
+TEST(AgentLoaderTest, WarnsOnDuplicateNameInSameLayer)
+{
+    QTemporaryDir dir;
+    ASSERT_TRUE(dir.isValid());
+    writeFile(dir.path(), "first.toml", minimalAgent("Dup"));
+    writeFile(dir.path(), "second.toml", minimalAgent("Dup"));
+
+    const auto result = AgentLoader::load(QString(), dir.path());
+    EXPECT_TRUE(anyContains(result.warnings, QStringLiteral("defined in both")));
+    const AgentConfig *cfg = findConfig(result, QStringLiteral("Dup"));
+    ASSERT_NE(cfg, nullptr);
+    EXPECT_TRUE(cfg->sourcePath.endsWith(QStringLiteral("second.toml")));
+}
+
+TEST(AgentLoaderTest, UserAgentCollidingWithBundledNameIsRejected)
+{
+    QTemporaryDir bundled;
+    QTemporaryDir user;
+    ASSERT_TRUE(bundled.isValid());
+    ASSERT_TRUE(user.isValid());
+    writeFile(bundled.path(), "a.toml", minimalAgent("A", "description = \"base\"\n"));
+    writeFile(user.path(), "a.toml", minimalAgent("A", "description = \"mine\"\n"));
+
+    const auto result = AgentLoader::load(bundled.path(), user.path());
+    EXPECT_TRUE(anyContains(result.errors, QStringLiteral("cannot be replaced")));
+    const AgentConfig *cfg = findConfig(result, QStringLiteral("A"));
+    ASSERT_NE(cfg, nullptr);
+    EXPECT_EQ(cfg->description, QStringLiteral("base"));
+    EXPECT_FALSE(cfg->isUserSource());
+}
+
+TEST(AgentLoaderTest, HiddenIsNotInherited)
+{
+    QTemporaryDir dir;
+    ASSERT_TRUE(dir.isValid());
+    writeFile(dir.path(), "parent.toml", minimalAgent("Parent", "hidden = true\n"));
+    writeFile(dir.path(), "child.toml", minimalAgent("Child", "extends = \"Parent\"\n"));
+
+    const auto result = AgentLoader::load(QString(), dir.path());
+    EXPECT_TRUE(result.errors.isEmpty()) << result.errors.join("; ").toStdString();
+    const AgentConfig *parent = findConfig(result, QStringLiteral("Parent"));
+    const AgentConfig *child = findConfig(result, QStringLiteral("Child"));
+    ASSERT_NE(parent, nullptr);
+    ASSERT_NE(child, nullptr);
+    EXPECT_TRUE(parent->hidden);
+    EXPECT_FALSE(child->hidden);
+}
+
+TEST(AgentLoaderTest, UserAgentExtendsBundledProviderBase)
+{
+    QTemporaryDir bundled;
+    QTemporaryDir user;
+    ASSERT_TRUE(bundled.isValid());
+    ASSERT_TRUE(user.isValid());
+    writeFile(bundled.path(), "base.toml",
+              "name = \"Provider Base\"\n"
+              "abstract = true\n"
+              "provider_instance = \"P\"\n"
+              "endpoint = \"/e\"\n"
+              "[body]\n"
+              "stream = true\n");
+    writeFile(bundled.path(), "a.toml",
+              "name = \"A\"\n"
+              "extends = \"Provider Base\"\n"
+              "model = \"stock-model\"\n");
+    writeFile(user.path(), "mine.toml",
+              "name = \"My A\"\n"
+              "extends = \"Provider Base\"\n"
+              "model = \"my-model\"\n"
+              "[body]\n"
+              "temperature = 0.2\n");
+
+    const auto result = AgentLoader::load(bundled.path(), user.path());
+    EXPECT_TRUE(result.errors.isEmpty()) << result.errors.join("; ").toStdString();
+    const AgentConfig *stock = findConfig(result, QStringLiteral("A"));
+    ASSERT_NE(stock, nullptr);
+    EXPECT_EQ(stock->model, QStringLiteral("stock-model"));
+    const AgentConfig *mine = findConfig(result, QStringLiteral("My A"));
+    ASSERT_NE(mine, nullptr);
+    EXPECT_EQ(mine->model, QStringLiteral("my-model"));
+    EXPECT_EQ(mine->providerInstance, QStringLiteral("P"));
+    EXPECT_TRUE(mine->body.contains(QStringLiteral("stream")));
+    EXPECT_TRUE(mine->body.contains(QStringLiteral("temperature")));
+    EXPECT_TRUE(mine->isUserSource());
+}
+
+TEST(AgentLoaderTest, ExtendsUnknownParentErrorNamesChild)
+{
+    QTemporaryDir dir;
+    ASSERT_TRUE(dir.isValid());
+    writeFile(dir.path(), "child.toml", minimalAgent("Child", "extends = \"NoSuchBase\"\n"));
+
+    const auto result = AgentLoader::load(QString(), dir.path());
+    EXPECT_TRUE(anyContains(result.errors, QStringLiteral("'Child' extends unknown agent 'NoSuchBase'")));
+    EXPECT_EQ(findConfig(result, QStringLiteral("Child")), nullptr);
+}
+
+TEST(AgentLoaderTest, ParseFileReportsWarningsForOwnFileOnly)
+{
+    QTemporaryDir dir;
+    ASSERT_TRUE(dir.isValid());
+    writeFile(dir.path(), "other.toml", minimalAgent("Other", "bogus_key = 1\n"));
+    writeFile(dir.path(), "target.toml", minimalAgent("Target", "another_bogus = 2\n"));
+
+    QString error;
+    QStringList warnings;
+    const auto cfg = AgentLoader::parseFile(
+        dir.path() + QStringLiteral("/target.toml"), QString(), &error, &warnings);
+    ASSERT_TRUE(cfg.has_value()) << error.toStdString();
+    EXPECT_TRUE(anyContains(warnings, QStringLiteral("another_bogus")));
+    EXPECT_FALSE(anyContains(warnings, QStringLiteral("bogus_key")));
+}
diff --git a/test/AgentRouterTest.cpp b/test/AgentRouterTest.cpp
new file mode 100644
index 0000000..42b6945
--- /dev/null
+++ b/test/AgentRouterTest.cpp
@@ -0,0 +1,104 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+#include 
+
+using QodeAssist::AgentConfig;
+using QodeAssist::AgentRouter::Context;
+using QodeAssist::AgentRouter::matches;
+
+namespace {
+
+Context ctx(const QString &filePath, const QString &projectName = QString())
+{
+    return Context{filePath, projectName};
+}
+
+} // namespace
+
+TEST(AgentRouterTest, EmptyMatchIsCatchAll)
+{
+    AgentConfig::Match m;
+    EXPECT_TRUE(m.isEmpty());
+    EXPECT_TRUE(matches(m, ctx(QStringLiteral("/any/file.cpp"))));
+    EXPECT_TRUE(matches(m, ctx(QString())));
+}
+
+TEST(AgentRouterTest, FilePatternMatchesByFileName)
+{
+    AgentConfig::Match m;
+    m.filePatterns = {QStringLiteral("*.cpp")};
+
+    EXPECT_TRUE(matches(m, ctx(QStringLiteral("/proj/src/foo.cpp"))));
+    EXPECT_TRUE(matches(m, ctx(QStringLiteral("foo.cpp"))));
+    EXPECT_FALSE(matches(m, ctx(QStringLiteral("/proj/src/foo.h"))));
+}
+
+TEST(AgentRouterTest, FilePatternIsCaseInsensitive)
+{
+    AgentConfig::Match m;
+    m.filePatterns = {QStringLiteral("*.cpp")};
+
+    EXPECT_TRUE(matches(m, ctx(QStringLiteral("/proj/FOO.CPP"))));
+}
+
+TEST(AgentRouterTest, FilePatternWithEmptyPathDoesNotMatch)
+{
+    AgentConfig::Match m;
+    m.filePatterns = {QStringLiteral("*.cpp")};
+
+    EXPECT_FALSE(matches(m, ctx(QString())));
+}
+
+TEST(AgentRouterTest, MultipleFilePatternsAreOred)
+{
+    AgentConfig::Match m;
+    m.filePatterns = {QStringLiteral("*.cpp"), QStringLiteral("*.qml")};
+
+    EXPECT_TRUE(matches(m, ctx(QStringLiteral("main.qml"))));
+    EXPECT_TRUE(matches(m, ctx(QStringLiteral("main.cpp"))));
+    EXPECT_FALSE(matches(m, ctx(QStringLiteral("main.py"))));
+}
+
+TEST(AgentRouterTest, PathPatternMatchesAcrossSeparators)
+{
+    AgentConfig::Match m;
+    m.pathPatterns = {QStringLiteral("*/tests/*")};
+
+    EXPECT_TRUE(matches(m, ctx(QStringLiteral("/home/me/tests/x.cpp"))));
+    EXPECT_FALSE(matches(m, ctx(QStringLiteral("/home/me/src/x.cpp"))));
+}
+
+TEST(AgentRouterTest, ProjectNameMatchIsCaseSensitive)
+{
+    AgentConfig::Match m;
+    m.projectNames = {QStringLiteral("MyProj")};
+
+    EXPECT_TRUE(matches(m, ctx(QStringLiteral("a.cpp"), QStringLiteral("MyProj"))));
+    EXPECT_FALSE(matches(m, ctx(QStringLiteral("a.cpp"), QStringLiteral("myproj"))));
+    EXPECT_FALSE(matches(m, ctx(QStringLiteral("a.cpp"), QString())));
+}
+
+TEST(AgentRouterTest, DimensionsAreAndedTogether)
+{
+    AgentConfig::Match m;
+    m.filePatterns = {QStringLiteral("*.cpp")};
+    m.projectNames = {QStringLiteral("P")};
+
+    EXPECT_TRUE(matches(m, ctx(QStringLiteral("a.cpp"), QStringLiteral("P"))));
+    EXPECT_FALSE(matches(m, ctx(QStringLiteral("a.cpp"), QStringLiteral("Q"))));
+    EXPECT_FALSE(matches(m, ctx(QStringLiteral("a.h"), QStringLiteral("P"))));
+}
+
+TEST(AgentRouterTest, UnconstrainedDimensionDoesNotBlock)
+{
+    AgentConfig::Match m;
+    m.projectNames = {QStringLiteral("P")};
+
+    // file path is irrelevant because no file/path patterns are set
+    EXPECT_TRUE(matches(m, ctx(QString(), QStringLiteral("P"))));
+}
diff --git a/test/BundledAgentsTest.cpp b/test/BundledAgentsTest.cpp
new file mode 100644
index 0000000..b8e4818
--- /dev/null
+++ b/test/BundledAgentsTest.cpp
@@ -0,0 +1,77 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+using QodeAssist::Agents::AgentLoader;
+using QodeAssist::Templates::JsonPromptTemplate;
+using QodeAssist::Templates::ContextRenderer::Bindings;
+using QodeAssist::Templates::ContextRenderer::render;
+
+TEST(BundledAgentsTest, AllBundledAgentsLoadResolveAndRender)
+{
+    Q_INIT_RESOURCE(agents);
+
+    const AgentLoader::LoadResult result = AgentLoader::load(QStringLiteral(":/agents"), QString());
+
+    EXPECT_TRUE(result.errors.isEmpty())
+        << "bundled agent load errors: "
+        << result.errors.join(QStringLiteral("; ")).toStdString();
+
+    EXPECT_TRUE(result.warnings.isEmpty())
+        << "bundled agent load warnings: "
+        << result.warnings.join(QStringLiteral("; ")).toStdString();
+
+    ASSERT_FALSE(result.configs.empty()) << "no bundled agents were loaded from :/agents";
+
+    for (const auto &cfg : result.configs) {
+        QString error;
+        const auto tmpl = JsonPromptTemplate::fromConfig(cfg, &error);
+        EXPECT_NE(tmpl, nullptr) << "bundled agent '" << cfg.name.toStdString()
+                                 << "' body failed to render: " << error.toStdString();
+    }
+}
+
+TEST(BundledAgentsTest, AllBundledSystemPromptsResolveQrcResources)
+{
+    Q_INIT_RESOURCE(agents);
+
+    const AgentLoader::LoadResult result = AgentLoader::load(QStringLiteral(":/agents"), QString());
+    ASSERT_TRUE(result.errors.isEmpty())
+        << result.errors.join(QStringLiteral("; ")).toStdString();
+    ASSERT_FALSE(result.configs.empty());
+
+    const QStringList languages = {QString(), QStringLiteral("qml"), QStringLiteral("c-like")};
+
+    int withSystemPrompt = 0;
+    for (const auto &cfg : result.configs) {
+        if (cfg.systemPrompt.isEmpty())
+            continue;
+        ++withSystemPrompt;
+        for (const QString &lang : languages) {
+            Bindings bindings;
+            bindings.language = lang;
+
+            QString error;
+            const QString rendered = render(cfg.systemPrompt, bindings, &error);
+
+            EXPECT_TRUE(error.isEmpty())
+                << "agent '" << cfg.name.toStdString() << "' (language='"
+                << lang.toStdString() << "') system_prompt render error: " << error.toStdString();
+            EXPECT_FALSE(rendered.trimmed().isEmpty())
+                << "agent '" << cfg.name.toStdString() << "' (language='" << lang.toStdString()
+                << "') system_prompt rendered empty — a read_file(\":/...\") path is likely broken";
+        }
+    }
+
+    EXPECT_GT(withSystemPrompt, 0)
+        << "no bundled agent carried a system_prompt — this test would be vacuous";
+}
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 3659261..d1526cc 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -1,11 +1,23 @@
 add_executable(QodeAssistTest
     ../CodeHandler.cpp
-    ../LLMClientInterface.cpp
     ../LLMSuggestion.cpp
     CodeHandlerTest.cpp
-    ClaudeCacheControlTest.cpp
     DocumentContextReaderTest.cpp
+    EnvBlockFormatterTest.cpp
     LLMSuggestionTest.cpp
+    JsonPromptTemplateTest.cpp
+    ContextAssemblerTest.cpp
+    ResponseRouterTest.cpp
+    BundledAgentsTest.cpp
+    AgentLoaderTest.cpp
+    AgentConfigTest.cpp
+    AgentRouterTest.cpp
+    ClaudeCacheControlTest.cpp
+    ContextRendererTest.cpp
+    ErrorInfoTest.cpp
+    MessageSerializerTest.cpp
+    ResponseCleanerTest.cpp
+    SystemPromptBuilderTest.cpp
     # LLMClientInterfaceTests.cpp
     unittest_main.cpp
 )
@@ -18,8 +30,11 @@ target_link_libraries(QodeAssistTest PRIVATE
     GTest::Main
     QtCreator::LanguageClient
     Context
-    PluginLLMCore
+    Common
     LLMQore
+    Templates
+    Agents
+    Session
 )
 
 target_include_directories(QodeAssistTest PRIVATE ${CMAKE_SOURCE_DIR})
diff --git a/test/ClaudeCacheControlTest.cpp b/test/ClaudeCacheControlTest.cpp
index 86bf688..8b992ea 100644
--- a/test/ClaudeCacheControlTest.cpp
+++ b/test/ClaudeCacheControlTest.cpp
@@ -7,7 +7,7 @@
 #include 
 #include 
 
-#include "providers/ClaudeCacheControl.hpp"
+#include 
 
 using namespace QodeAssist::Providers::ClaudeCacheControl;
 
@@ -21,6 +21,11 @@ QJsonObject expectedEphemeral(bool extendedTtl)
     return obj;
 }
 
+void applyAll(QJsonObject &request, bool extendedTtl)
+{
+    apply(request, extendedTtl, QStringList());
+}
+
 } // namespace
 
 TEST(ClaudeCacheControlTest, BreakpointWithoutExtendedTTL)
@@ -42,7 +47,7 @@ TEST(ClaudeCacheControlTest, SystemAsStringWrappedIntoArray)
     QJsonObject request;
     request["system"] = "you are a helpful agent";
 
-    apply(request, false);
+    applyAll(request, false);
 
     ASSERT_TRUE(request.value("system").isArray());
     const QJsonArray sys = request.value("system").toArray();
@@ -59,7 +64,7 @@ TEST(ClaudeCacheControlTest, EmptySystemStringIsNotWrapped)
     QJsonObject request;
     request["system"] = "";
 
-    apply(request, false);
+    applyAll(request, false);
 
     EXPECT_TRUE(request.value("system").isString());
 }
@@ -71,7 +76,7 @@ TEST(ClaudeCacheControlTest, SystemAsArrayMarksLastBlock)
         QJsonObject{{"type", "text"}, {"text", "a"}},
         QJsonObject{{"type", "text"}, {"text", "b"}}};
 
-    apply(request, false);
+    applyAll(request, false);
 
     const QJsonArray sys = request.value("system").toArray();
     ASSERT_EQ(sys.size(), 2);
@@ -87,7 +92,7 @@ TEST(ClaudeCacheControlTest, ToolsLastEntryGetsCacheControl)
         QJsonObject{{"name", "edit_file"}},
         QJsonObject{{"name", "search"}}};
 
-    apply(request, true);
+    applyAll(request, true);
 
     const QJsonArray tools = request.value("tools").toArray();
     ASSERT_EQ(tools.size(), 3);
@@ -102,7 +107,7 @@ TEST(ClaudeCacheControlTest, SingleMessageHistorySkipped)
     request["messages"]
         = QJsonArray{QJsonObject{{"role", "user"}, {"content", "first message"}}};
 
-    apply(request, false);
+    applyAll(request, false);
 
     const QJsonArray msgs = request.value("messages").toArray();
     ASSERT_EQ(msgs.size(), 1);
@@ -117,7 +122,7 @@ TEST(ClaudeCacheControlTest, HistoryBreakpointOnSecondToLastMessage)
         QJsonObject{{"role", "assistant"}, {"content", "a1"}},
         QJsonObject{{"role", "user"}, {"content", "u2-current"}}};
 
-    apply(request, false);
+    applyAll(request, false);
 
     const QJsonArray msgs = request.value("messages").toArray();
     ASSERT_EQ(msgs.size(), 3);
@@ -146,7 +151,7 @@ TEST(ClaudeCacheControlTest, HistoryArrayContentMarksLastBlock)
                  QJsonObject{{"type", "image"}}}}},
         QJsonObject{{"role", "assistant"}, {"content", "ok"}}};
 
-    apply(request, false);
+    applyAll(request, false);
 
     const QJsonArray msgs = request.value("messages").toArray();
     const QJsonArray content = msgs[0].toObject().value("content").toArray();
@@ -161,7 +166,7 @@ TEST(ClaudeCacheControlTest, NoSystemNoToolsNoMessagesIsNoop)
     request["model"] = "claude-sonnet-4-5";
     request["max_tokens"] = 1024;
 
-    apply(request, false);
+    applyAll(request, false);
 
     EXPECT_EQ(request.value("model").toString(), "claude-sonnet-4-5");
     EXPECT_EQ(request.value("max_tokens").toInt(), 1024);
@@ -175,8 +180,54 @@ TEST(ClaudeCacheControlTest, EmptyToolsArrayIsNoop)
     QJsonObject request;
     request["tools"] = QJsonArray{};
 
-    apply(request, false);
+    applyAll(request, false);
 
     EXPECT_TRUE(request.value("tools").isArray());
     EXPECT_TRUE(request.value("tools").toArray().isEmpty());
 }
+
+TEST(ClaudeCacheControlTest, SelectiveBreakpointMarksOnlySystem)
+{
+    QJsonObject request;
+    request["system"] = "sys";
+    request["tools"] = QJsonArray{QJsonObject{{"name", "read_file"}}};
+
+    apply(request, false, QStringList{QStringLiteral("system")});
+
+    EXPECT_TRUE(request.value("system").isArray());
+    const QJsonArray tools = request.value("tools").toArray();
+    ASSERT_EQ(tools.size(), 1);
+    EXPECT_FALSE(tools[0].toObject().contains("cache_control"));
+}
+
+TEST(ClaudeCacheControlTest, SelectiveBreakpointMarksOnlyTools)
+{
+    QJsonObject request;
+    request["system"] = "sys";
+    request["tools"] = QJsonArray{QJsonObject{{"name", "read_file"}}};
+
+    apply(request, false, QStringList{QStringLiteral("tools")});
+
+    EXPECT_TRUE(request.value("system").isString());
+    const QJsonArray tools = request.value("tools").toArray();
+    ASSERT_EQ(tools.size(), 1);
+    EXPECT_EQ(tools[0].toObject().value("cache_control").toObject(), expectedEphemeral(false));
+}
+
+TEST(ClaudeCacheControlTest, EmptyBreakpointListMarksEveryDimension)
+{
+    QJsonObject request;
+    request["system"] = "sys";
+    request["tools"] = QJsonArray{QJsonObject{{"name", "read_file"}}};
+    request["messages"] = QJsonArray{
+        QJsonObject{{"role", "user"}, {"content", "u1"}},
+        QJsonObject{{"role", "assistant"}, {"content", "a1"}}};
+
+    apply(request, false, QStringList());
+
+    EXPECT_TRUE(request.value("system").isArray());
+    EXPECT_TRUE(
+        request.value("tools").toArray().last().toObject().contains("cache_control"));
+    const QJsonArray msgs = request.value("messages").toArray();
+    EXPECT_TRUE(msgs[0].toObject().value("content").isArray());
+}
diff --git a/test/ContextAssemblerTest.cpp b/test/ContextAssemblerTest.cpp
new file mode 100644
index 0000000..ab06e2a
--- /dev/null
+++ b/test/ContextAssemblerTest.cpp
@@ -0,0 +1,418 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+
+using namespace QodeAssist;
+using Templates::ContentBlockEntry;
+
+namespace {
+
+Message textMessage(Message::Role role, const QString &text)
+{
+    Message m(role);
+    m.appendBlock(std::make_unique(text));
+    return m;
+}
+
+ContextAssembler::ContentLoader base64Loader(const QString &content)
+{
+    return [content](const QString &) {
+        return QString::fromUtf8(content.toUtf8().toBase64());
+    };
+}
+
+ContextAssembler::ContentLoader emptyLoader()
+{
+    return [](const QString &) { return QString(); };
+}
+
+} // namespace
+
+TEST(ContextAssemblerTest, SystemPromptAndUserTextProduceWireContext)
+{
+    std::vector history;
+    history.push_back(textMessage(Message::Role::User, "hello"));
+
+    ContextAssembler::Manifest manifest;
+    const auto ctx = ContextAssembler::assemble(history, "be helpful", nullptr, {}, &manifest);
+
+    ASSERT_TRUE(ctx.systemPrompt.has_value());
+    EXPECT_EQ(*ctx.systemPrompt, "be helpful");
+    ASSERT_TRUE(ctx.history.has_value());
+    ASSERT_EQ(ctx.history->size(), 1);
+    EXPECT_EQ(ctx.history->first().role, "user");
+    ASSERT_EQ(ctx.history->first().blocks.size(), 1);
+    EXPECT_EQ(ctx.history->first().blocks.first().kind, ContentBlockEntry::Kind::Text);
+    EXPECT_EQ(ctx.history->first().blocks.first().text, "hello");
+    EXPECT_EQ(manifest.historyMessages, 1);
+    EXPECT_EQ(manifest.wireMessages, 1);
+    EXPECT_EQ(manifest.systemChars, 10);
+    EXPECT_EQ(manifest.textChars, 5);
+}
+
+TEST(ContextAssemblerTest, EmptySystemPromptIsOmitted)
+{
+    std::vector history;
+    history.push_back(textMessage(Message::Role::User, "hi"));
+
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr);
+
+    EXPECT_FALSE(ctx.systemPrompt.has_value());
+}
+
+TEST(ContextAssemblerTest, SystemRoleMessagesAreSkipped)
+{
+    std::vector history;
+    history.push_back(textMessage(Message::Role::System, "legacy system"));
+    history.push_back(textMessage(Message::Role::User, "hi"));
+
+    ContextAssembler::Manifest manifest;
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    EXPECT_EQ(ctx.history->size(), 1);
+    EXPECT_EQ(ctx.history->first().role, "user");
+    EXPECT_FALSE(manifest.elided.isEmpty());
+}
+
+TEST(ContextAssemblerTest, CompletionContentBecomesPrefixSuffix)
+{
+    std::vector history;
+    Message m(Message::Role::User);
+    m.appendBlock(std::make_unique("int ma", "()\n"));
+    history.push_back(std::move(m));
+
+    ContextAssembler::Manifest manifest;
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
+
+    ASSERT_TRUE(ctx.prefix.has_value());
+    EXPECT_EQ(*ctx.prefix, "int ma");
+    ASSERT_TRUE(ctx.suffix.has_value());
+    EXPECT_EQ(*ctx.suffix, "()\n");
+    EXPECT_FALSE(ctx.history.has_value());
+    EXPECT_TRUE(manifest.hasCompletionContext);
+}
+
+TEST(ContextAssemblerTest, UnsignedThinkingDroppedSignedKept)
+{
+    std::vector history;
+    Message m(Message::Role::Assistant);
+    m.appendBlock(std::make_unique("draft", QString()));
+    m.appendBlock(std::make_unique("signed", "sig"));
+    m.appendBlock(std::make_unique("answer"));
+    history.push_back(std::move(m));
+
+    ContextAssembler::Manifest manifest;
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    const auto &blocks = ctx.history->first().blocks;
+    ASSERT_EQ(blocks.size(), 2);
+    EXPECT_EQ(blocks[0].kind, ContentBlockEntry::Kind::Thinking);
+    EXPECT_EQ(blocks[0].signature, "sig");
+    EXPECT_EQ(blocks[1].kind, ContentBlockEntry::Kind::Text);
+    EXPECT_EQ(manifest.elided.size(), 1);
+}
+
+TEST(ContextAssemblerTest, ThinkingOnlyMessageIsDropped)
+{
+    std::vector history;
+    Message m(Message::Role::Assistant);
+    m.appendBlock(std::make_unique("signed", "sig"));
+    history.push_back(std::move(m));
+    history.push_back(textMessage(Message::Role::User, "hi"));
+
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    EXPECT_EQ(ctx.history->size(), 1);
+    EXPECT_EQ(ctx.history->first().role, "user");
+}
+
+TEST(ContextAssemblerTest, OrphanToolUseIsDropped)
+{
+    std::vector history;
+    Message m(Message::Role::Assistant);
+    m.appendBlock(std::make_unique("calling"));
+    m.appendBlock(
+        std::make_unique("tu1", "read_file", QJsonObject()));
+    history.push_back(std::move(m));
+
+    ContextAssembler::Manifest manifest;
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    ASSERT_EQ(ctx.history->first().blocks.size(), 1);
+    EXPECT_EQ(ctx.history->first().blocks.first().kind, ContentBlockEntry::Kind::Text);
+    EXPECT_EQ(manifest.toolUseBlocks, 0);
+    EXPECT_EQ(manifest.elided.size(), 1);
+}
+
+TEST(ContextAssemblerTest, OrphanToolResultIsDropped)
+{
+    std::vector history;
+    Message m(Message::Role::User);
+    m.appendBlock(std::make_unique("unknown", "data"));
+    history.push_back(std::move(m));
+    history.push_back(textMessage(Message::Role::User, "hi"));
+
+    ContextAssembler::Manifest manifest;
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    EXPECT_EQ(ctx.history->size(), 1);
+    EXPECT_EQ(manifest.toolResultBlocks, 0);
+}
+
+TEST(ContextAssemblerTest, PairedToolUseAndResultAreKept)
+{
+    std::vector history;
+    Message use(Message::Role::Assistant);
+    use.appendBlock(std::make_unique(
+        "tu1", "read_file", QJsonObject{{"path", "a.cpp"}}));
+    history.push_back(std::move(use));
+    Message result(Message::Role::User);
+    result.appendBlock(std::make_unique("tu1", "contents"));
+    history.push_back(std::move(result));
+
+    ContextAssembler::Manifest manifest;
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    ASSERT_EQ(ctx.history->size(), 2);
+    EXPECT_EQ(ctx.history->at(0).blocks.first().kind, ContentBlockEntry::Kind::ToolUse);
+    EXPECT_EQ(ctx.history->at(1).blocks.first().kind, ContentBlockEntry::Kind::ToolResult);
+    EXPECT_EQ(manifest.toolUseBlocks, 1);
+    EXPECT_EQ(manifest.toolResultBlocks, 1);
+    EXPECT_TRUE(manifest.elided.isEmpty());
+}
+
+TEST(ContextAssemblerTest, AttachmentMaterializedThroughLoader)
+{
+    std::vector history;
+    Message m(Message::Role::User);
+    m.appendBlock(std::make_unique("notes.txt", "stored/notes"));
+    history.push_back(std::move(m));
+
+    const auto ctx
+        = ContextAssembler::assemble(history, QString(), base64Loader("file body"));
+
+    ASSERT_TRUE(ctx.history.has_value());
+    const auto &block = ctx.history->first().blocks.first();
+    EXPECT_EQ(block.kind, ContentBlockEntry::Kind::Text);
+    EXPECT_EQ(block.text, "File: notes.txt\n```\nfile body\n```");
+}
+
+TEST(ContextAssemblerTest, MissingAttachmentGetsPlaceholder)
+{
+    std::vector history;
+    Message m(Message::Role::User);
+    m.appendBlock(std::make_unique("notes.txt", "stored/notes"));
+    history.push_back(std::move(m));
+
+    ContextAssembler::Manifest manifest;
+    const auto ctx = ContextAssembler::assemble(history, QString(), emptyLoader(), {}, &manifest);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    const auto &block = ctx.history->first().blocks.first();
+    EXPECT_EQ(block.kind, ContentBlockEntry::Kind::Text);
+    EXPECT_EQ(block.text, "[Attachment unavailable: notes.txt]");
+    EXPECT_EQ(manifest.elided.size(), 1);
+}
+
+TEST(ContextAssemblerTest, StoredImageMaterializedThroughLoader)
+{
+    std::vector history;
+    Message m(Message::Role::User);
+    m.appendBlock(
+        std::make_unique("shot.png", "stored/shot", "image/png"));
+    history.push_back(std::move(m));
+
+    ContextAssembler::Manifest manifest;
+    const auto ctx
+        = ContextAssembler::assemble(history, QString(), base64Loader("png"), {}, &manifest);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    const auto &block = ctx.history->first().blocks.first();
+    EXPECT_EQ(block.kind, ContentBlockEntry::Kind::Image);
+    EXPECT_EQ(block.mediaType, "image/png");
+    EXPECT_FALSE(block.isImageUrl);
+    EXPECT_EQ(manifest.imageBlocks, 1);
+}
+
+TEST(ContextAssemblerTest, MissingImageWithNullLoaderGetsPlaceholder)
+{
+    std::vector history;
+    Message m(Message::Role::User);
+    m.appendBlock(
+        std::make_unique("shot.png", "stored/shot", "image/png"));
+    history.push_back(std::move(m));
+
+    ContextAssembler::Manifest manifest;
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    const auto &block = ctx.history->first().blocks.first();
+    EXPECT_EQ(block.kind, ContentBlockEntry::Kind::Text);
+    EXPECT_EQ(block.text, "[Image unavailable: shot.png]");
+    EXPECT_EQ(manifest.imageBlocks, 0);
+    EXPECT_EQ(manifest.elided.size(), 1);
+}
+
+TEST(ContextAssemblerTest, PinnedBlocksPrependedToLastUserMessage)
+{
+    std::vector history;
+    history.push_back(textMessage(Message::Role::User, "first"));
+    history.push_back(textMessage(Message::Role::Assistant, "reply"));
+    history.push_back(textMessage(Message::Role::User, "second"));
+
+    ContextAssembler::Manifest manifest;
+    const QVector pinned{
+        {"chat.files", "Linked files for reference:\nFile: a.cpp"}};
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned, &manifest);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    ASSERT_EQ(ctx.history->size(), 3);
+    EXPECT_EQ(ctx.history->at(0).blocks.size(), 1);
+    const auto &last = ctx.history->at(2);
+    EXPECT_EQ(last.role, "user");
+    ASSERT_EQ(last.blocks.size(), 2);
+    EXPECT_EQ(last.blocks[0].text, "Linked files for reference:\nFile: a.cpp");
+    EXPECT_EQ(last.blocks[1].text, "second");
+    EXPECT_EQ(manifest.pinnedBlocks, 1);
+}
+
+TEST(ContextAssemblerTest, PinnedSkipsTrailingAssistantMessage)
+{
+    std::vector history;
+    history.push_back(textMessage(Message::Role::User, "ask"));
+    history.push_back(textMessage(Message::Role::Assistant, "answer"));
+
+    const QVector pinned{{"chat.files", "files"}};
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    ASSERT_EQ(ctx.history->size(), 2);
+    const auto &user = ctx.history->at(0);
+    ASSERT_EQ(user.blocks.size(), 2);
+    EXPECT_EQ(user.blocks[0].text, "files");
+    EXPECT_EQ(user.blocks[1].text, "ask");
+}
+
+TEST(ContextAssemblerTest, PinnedWithoutUserMessageCreatesSyntheticOne)
+{
+    std::vector history;
+
+    const QVector pinned{{"chat.files", "files"}};
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    ASSERT_EQ(ctx.history->size(), 1);
+    EXPECT_EQ(ctx.history->first().role, "user");
+    ASSERT_EQ(ctx.history->first().blocks.size(), 1);
+    EXPECT_EQ(ctx.history->first().blocks.first().text, "files");
+}
+
+TEST(ContextAssemblerTest, EmptyPinnedTextIsIgnored)
+{
+    std::vector history;
+    history.push_back(textMessage(Message::Role::User, "hi"));
+
+    ContextAssembler::Manifest manifest;
+    const QVector pinned{{"chat.files", QString()}};
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned, &manifest);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    EXPECT_EQ(ctx.history->first().blocks.size(), 1);
+    EXPECT_EQ(manifest.pinnedBlocks, 0);
+}
+
+TEST(ContextAssemblerTest, PinnedAnchorsToTypedMessageNotToolResults)
+{
+    std::vector history;
+    history.push_back(textMessage(Message::Role::User, "fix the bug"));
+    Message use(Message::Role::Assistant);
+    use.appendBlock(
+        std::make_unique("tu1", "edit_file", QJsonObject()));
+    history.push_back(std::move(use));
+    Message result(Message::Role::User);
+    result.appendBlock(std::make_unique("tu1", "edited"));
+    history.push_back(std::move(result));
+
+    const QVector pinned{{"chat.files", "files"}};
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    ASSERT_EQ(ctx.history->size(), 3);
+    const auto &typed = ctx.history->at(0);
+    ASSERT_EQ(typed.blocks.size(), 2);
+    EXPECT_EQ(typed.blocks[0].text, "files");
+    EXPECT_EQ(typed.blocks[1].text, "fix the bug");
+    EXPECT_EQ(ctx.history->at(2).blocks.size(), 1);
+}
+
+TEST(ContextAssemblerTest, PinnedInsertedAfterLeadingToolResults)
+{
+    std::vector history;
+    Message use(Message::Role::Assistant);
+    use.appendBlock(
+        std::make_unique("tu1", "edit_file", QJsonObject()));
+    history.push_back(std::move(use));
+    Message result(Message::Role::User);
+    result.appendBlock(std::make_unique("tu1", "edited"));
+    history.push_back(std::move(result));
+
+    const QVector pinned{{"chat.files", "files"}};
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, pinned);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    const auto &last = ctx.history->at(1);
+    EXPECT_EQ(last.role, "user");
+    ASSERT_EQ(last.blocks.size(), 2);
+    EXPECT_EQ(last.blocks[0].kind, ContentBlockEntry::Kind::ToolResult);
+    EXPECT_EQ(last.blocks[1].text, "files");
+}
+
+TEST(ContextAssemblerTest, SkillInvocationBecomesTextEntry)
+{
+    std::vector history;
+    Message m(Message::Role::User);
+    m.appendBlock(std::make_unique("/review this"));
+    m.appendBlock(std::make_unique("review", "Review the code."));
+    history.push_back(std::move(m));
+
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    const auto &blocks = ctx.history->first().blocks;
+    ASSERT_EQ(blocks.size(), 2);
+    EXPECT_EQ(blocks[1].kind, ContentBlockEntry::Kind::Text);
+    EXPECT_EQ(blocks[1].text, "# Invoked Skill: review\n\nReview the code.");
+}
+
+TEST(ContextAssemblerTest, UnsupportedBlocksAreCounted)
+{
+    std::vector history;
+    Message m(Message::Role::Assistant);
+    m.appendBlock(std::make_unique("done"));
+    m.appendBlock(std::make_unique("e1", "a.cpp", "old", "new"));
+    history.push_back(std::move(m));
+
+    ContextAssembler::Manifest manifest;
+    const auto ctx = ContextAssembler::assemble(history, QString(), nullptr, {}, &manifest);
+
+    ASSERT_TRUE(ctx.history.has_value());
+    EXPECT_EQ(ctx.history->first().blocks.size(), 1);
+    EXPECT_EQ(manifest.unsupportedBlocks, 1);
+}
diff --git a/test/ContextRendererTest.cpp b/test/ContextRendererTest.cpp
new file mode 100644
index 0000000..bd347c7
--- /dev/null
+++ b/test/ContextRendererTest.cpp
@@ -0,0 +1,206 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+
+using QodeAssist::Templates::ContextRenderer::Bindings;
+using QodeAssist::Templates::ContextRenderer::render;
+
+namespace {
+
+void writeFile(const QString &path, const QByteArray &contents)
+{
+    QFile f(path);
+    ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text));
+    f.write(contents);
+}
+
+} // namespace
+
+TEST(ContextRendererTest, EmptyTemplateRendersEmpty)
+{
+    EXPECT_TRUE(render(QString(), Bindings{}).isEmpty());
+}
+
+TEST(ContextRendererTest, SubstitutesProjectDirAndConfigVariables)
+{
+    const QString out = render(
+        QStringLiteral("P=${PROJECT_DIR};C=${CONFIG_DIR}"),
+        Bindings{QStringLiteral("/proj"), QStringLiteral("/cfg")});
+    EXPECT_EQ(out, QStringLiteral("P=/proj;C=/cfg"));
+}
+
+TEST(ContextRendererTest, ReadsFileWithinProjectDir)
+{
+    QTemporaryDir proj;
+    ASSERT_TRUE(proj.isValid());
+    writeFile(proj.filePath(QStringLiteral("notes.txt")), "hello body");
+
+    const QString out = render(
+        QStringLiteral("{{ read_file(\"${PROJECT_DIR}/notes.txt\") }}"),
+        Bindings{proj.path(), QString()});
+    EXPECT_EQ(out, QStringLiteral("hello body"));
+}
+
+TEST(ContextRendererTest, ReadsFileUnderConfigDir)
+{
+    QTemporaryDir config;
+    ASSERT_TRUE(config.isValid());
+    writeFile(config.filePath(QStringLiteral("persona.md")), "be terse");
+
+    const QString out = render(
+        QStringLiteral("{{ read_file(\"${CONFIG_DIR}/persona.md\") }}"),
+        Bindings{QStringLiteral("/unrelated/project"), config.path()});
+    EXPECT_EQ(out, QStringLiteral("be terse"));
+}
+
+TEST(ContextRendererTest, ReadFileOutsideAllowedRootsThrowsLoudly)
+{
+    QTemporaryDir proj;
+    QTemporaryDir outside;
+    ASSERT_TRUE(proj.isValid());
+    ASSERT_TRUE(outside.isValid());
+    writeFile(outside.filePath(QStringLiteral("secret.txt")), "TOP SECRET");
+
+    QString error;
+    const QString out = render(
+        QStringLiteral("{{ read_file(\"%1/secret.txt\") }}").arg(outside.path()),
+        Bindings{proj.path(), QString()},
+        &error);
+
+    EXPECT_TRUE(out.isEmpty());
+    EXPECT_FALSE(error.isEmpty());
+    EXPECT_TRUE(error.contains(QStringLiteral("read_file")));
+    EXPECT_TRUE(error.contains(QStringLiteral("outside the allowed read roots")));
+}
+
+TEST(ContextRendererTest, ReadFileMissingButAllowedThrowsLoudly)
+{
+    QTemporaryDir proj;
+    ASSERT_TRUE(proj.isValid());
+
+    QString error;
+    const QString out = render(
+        QStringLiteral("{{ read_file(\"${PROJECT_DIR}/nope.txt\") }}"),
+        Bindings{proj.path(), QString()},
+        &error);
+
+    EXPECT_TRUE(out.isEmpty());
+    EXPECT_FALSE(error.isEmpty());
+    EXPECT_TRUE(error.contains(QStringLiteral("cannot open")));
+}
+
+TEST(ContextRendererTest, FileExistsTrueForPresentAllowedFileAndFalseWhenAbsent)
+{
+    QTemporaryDir proj;
+    ASSERT_TRUE(proj.isValid());
+    writeFile(proj.filePath(QStringLiteral("present.txt")), "x");
+
+    EXPECT_EQ(
+        render(
+            QStringLiteral("{{ file_exists(\"${PROJECT_DIR}/present.txt\") }}"),
+            Bindings{proj.path(), QString()}),
+        QStringLiteral("true"));
+
+    QString error;
+    EXPECT_EQ(
+        render(
+            QStringLiteral("{{ file_exists(\"${PROJECT_DIR}/missing.txt\") }}"),
+            Bindings{proj.path(), QString()},
+            &error),
+        QStringLiteral("false"));
+    EXPECT_TRUE(error.isEmpty());
+}
+
+TEST(ContextRendererTest, FileExistsOutsideAllowedRootsThrowsLoudly)
+{
+    QTemporaryDir proj;
+    QTemporaryDir outside;
+    ASSERT_TRUE(proj.isValid());
+    ASSERT_TRUE(outside.isValid());
+    writeFile(outside.filePath(QStringLiteral("present.txt")), "x");
+
+    QString error;
+    const QString out = render(
+        QStringLiteral("{{ file_exists(\"%1/present.txt\") }}").arg(outside.path()),
+        Bindings{proj.path(), QString()},
+        &error);
+
+    EXPECT_TRUE(out.isEmpty());
+    EXPECT_FALSE(error.isEmpty());
+    EXPECT_TRUE(error.contains(QStringLiteral("file_exists")));
+}
+
+TEST(ContextRendererTest, HeadLinesTakesLeadingLines)
+{
+    QTemporaryDir proj;
+    ASSERT_TRUE(proj.isValid());
+    writeFile(proj.filePath(QStringLiteral("multi.txt")), "l1\nl2\nl3\n");
+
+    const QString out = render(
+        QStringLiteral("{{ head_lines(read_file(\"${PROJECT_DIR}/multi.txt\"), 2) }}"),
+        Bindings{proj.path(), QString()});
+    EXPECT_EQ(out, QStringLiteral("l1\nl2"));
+}
+
+TEST(ContextRendererTest, StringHelpers)
+{
+    const Bindings none{};
+    EXPECT_EQ(
+        render(QStringLiteral("{{ basename(\"/a/b/c.txt\") }}"), none), QStringLiteral("c.txt"));
+    EXPECT_EQ(render(QStringLiteral("{{ ext(\"/a/b/c.txt\") }}"), none), QStringLiteral("txt"));
+    EXPECT_EQ(render(QStringLiteral("{{ dirname(\"/a/b/c.txt\") }}"), none), QStringLiteral("/a/b"));
+    EXPECT_EQ(render(QStringLiteral("{{ lower(\"ABC\") }}"), none), QStringLiteral("abc"));
+    EXPECT_EQ(render(QStringLiteral("{{ upper(\"abc\") }}"), none), QStringLiteral("ABC"));
+}
+
+TEST(ContextRendererTest, ParseErrorReturnsEmptyAndReportsError)
+{
+    QString error;
+    const QString out = render(QStringLiteral("{{ "), Bindings{}, &error);
+    EXPECT_TRUE(out.isEmpty());
+    EXPECT_FALSE(error.isEmpty());
+}
+
+TEST(ContextRendererTest, ReadsBundledQrcResource)
+{
+    Q_INIT_RESOURCE(agents);
+
+    QString error;
+    const QString out = render(
+        QStringLiteral("{{ read_file(\":/roles/qt-cpp-developer.md\") }}"), Bindings{}, &error);
+
+    EXPECT_TRUE(error.isEmpty()) << error.toStdString();
+    EXPECT_FALSE(out.trimmed().isEmpty())
+        << "read_file(\":/roles/qt-cpp-developer.md\") returned empty — qrc alias broken?";
+    EXPECT_TRUE(out.contains(QStringLiteral("Qt/C++ developer")));
+}
+
+TEST(ContextRendererTest, SelectsCompletionRoleByLanguageFromQrc)
+{
+    Q_INIT_RESOURCE(agents);
+
+    const QString tpl = QStringLiteral(
+        "{%- if language == \"qml\" %}{{ read_file(\":/roles/code-completion-qml.md\") }}"
+        "{%- else if language == \"c-like\" %}{{ read_file(\":/roles/code-completion-c-like.md\") }}"
+        "{%- else %}{{ read_file(\":/roles/code-completion.md\") }}"
+        "{%- endif %}");
+
+    Bindings qml;
+    qml.language = QStringLiteral("qml");
+    Bindings clike;
+    clike.language = QStringLiteral("c-like");
+    Bindings other;
+    other.language = QStringLiteral("python");
+
+    EXPECT_TRUE(render(tpl, qml).contains(QStringLiteral("QML and Qt Quick")));
+    EXPECT_TRUE(render(tpl, clike).contains(QStringLiteral("C++, Qt, and QML")));
+    EXPECT_TRUE(render(tpl, other).contains(QStringLiteral("expert code completion assistant")));
+}
diff --git a/test/DocumentContextReaderTest.cpp b/test/DocumentContextReaderTest.cpp
index 390e258..7d53cff 100644
--- a/test/DocumentContextReaderTest.cpp
+++ b/test/DocumentContextReaderTest.cpp
@@ -9,7 +9,7 @@
 #include 
 #include 
 
-namespace QodeAssist::PluginLLMCore {
+namespace QodeAssist::Templates {
 
 void PrintTo(const ContextData &data, std::ostream *os)
 {
@@ -20,10 +20,10 @@ void PrintTo(const ContextData &data, std::ostream *os)
         << "}";
 }
 
-} // namespace QodeAssist::PluginLLMCore
+} // namespace QodeAssist::Templates
 
 using namespace QodeAssist::Context;
-using namespace QodeAssist::PluginLLMCore;
+using namespace QodeAssist::Templates;
 using namespace QodeAssist::Settings;
 
 class DocumentContextReaderTest : public QObject, public testing::Test
@@ -367,7 +367,7 @@ TEST_F(DocumentContextReaderTest, testPrepareContext)
 
     EXPECT_EQ(
         reader.prepareContext(2, 3, *createSettingsForWholeFile()),
-        (QodeAssist::PluginLLMCore::ContextData{
+        (QodeAssist::Templates::ContextData{
             .prefix = "Line 0\nLine 1\nLin",
             .suffix = "e 2\nLine 3\nLine 4",
             .fileContext = "\n Language:  (MIME: text/python) filepath: /path/to/file()\n\n"
@@ -375,7 +375,7 @@ TEST_F(DocumentContextReaderTest, testPrepareContext)
 
     EXPECT_EQ(
         reader.prepareContext(2, 3, *createSettingsForLines(1, 1)),
-        (QodeAssist::PluginLLMCore::ContextData{
+        (QodeAssist::Templates::ContextData{
             .prefix = "Line 1\nLin",
             .suffix = "e 2\nLine 3",
             .fileContext = "\n Language:  (MIME: text/python) filepath: /path/to/file()\n\n"
@@ -383,7 +383,7 @@ TEST_F(DocumentContextReaderTest, testPrepareContext)
 
     EXPECT_EQ(
         reader.prepareContext(2, 3, *createSettingsForLines(2, 2)),
-        (QodeAssist::PluginLLMCore::ContextData{
+        (QodeAssist::Templates::ContextData{
             .prefix = "Line 0\nLine 1\nLin",
             .suffix = "e 2\nLine 3\nLine 4",
             .fileContext = "\n Language:  (MIME: text/python) filepath: /path/to/file()\n\n"
diff --git a/test/EnvBlockFormatterTest.cpp b/test/EnvBlockFormatterTest.cpp
new file mode 100644
index 0000000..f2bd8fc
--- /dev/null
+++ b/test/EnvBlockFormatterTest.cpp
@@ -0,0 +1,53 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+
+using namespace QodeAssist::Context;
+
+TEST(EnvBlockFormatterTest, FormatProjectWithBuildDir)
+{
+    const QString out = EnvBlockFormatter::formatProject(
+        {"MyApp", "/home/dev/myapp", "/home/dev/build-myapp"});
+
+    EXPECT_TRUE(out.startsWith("# Active project: MyApp"));
+    EXPECT_TRUE(out.contains("# Project source root: /home/dev/myapp"));
+    EXPECT_TRUE(out.contains("# Build output directory"));
+    EXPECT_TRUE(out.contains("/home/dev/build-myapp"));
+}
+
+TEST(EnvBlockFormatterTest, FormatProjectWithoutBuildDir)
+{
+    const QString out = EnvBlockFormatter::formatProject({"MyApp", "/home/dev/myapp", {}});
+
+    EXPECT_TRUE(out.contains("# Project source root: /home/dev/myapp"));
+    EXPECT_FALSE(out.contains("# Build output directory"));
+}
+
+TEST(EnvBlockFormatterTest, FormatProjectEmptyEnv)
+{
+    EXPECT_EQ(EnvBlockFormatter::formatProject({}), "# No active project in IDE");
+}
+
+TEST(EnvBlockFormatterTest, FormatFileWithKnownMime)
+{
+    const QString out
+        = EnvBlockFormatter::formatFile({"/home/dev/myapp/main.cpp", "text/x-c++src"});
+
+    EXPECT_TRUE(out.startsWith("File information:"));
+    EXPECT_TRUE(out.contains("Language:"));
+    EXPECT_TRUE(out.contains("text/x-c++src"));
+    EXPECT_TRUE(out.contains("File path: /home/dev/myapp/main.cpp"));
+}
+
+TEST(EnvBlockFormatterTest, FormatFileWithoutMime)
+{
+    const QString out = EnvBlockFormatter::formatFile({"/home/dev/myapp/data.bin", {}});
+
+    EXPECT_FALSE(out.contains("Language"));
+    EXPECT_FALSE(out.contains("MIME"));
+    EXPECT_TRUE(out.contains("File path: /home/dev/myapp/data.bin"));
+}
diff --git a/test/ErrorInfoTest.cpp b/test/ErrorInfoTest.cpp
new file mode 100644
index 0000000..53003ba
--- /dev/null
+++ b/test/ErrorInfoTest.cpp
@@ -0,0 +1,84 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+
+using namespace QodeAssist;
+
+TEST(ErrorInfoTest, MakeErrorPopulatesFields)
+{
+    const ErrorInfo e = makeError(ErrorCategory::Tool, QStringLiteral("boom"), QStringLiteral("detail"));
+    EXPECT_EQ(e.category, ErrorCategory::Tool);
+    EXPECT_EQ(e.message, QStringLiteral("boom"));
+    EXPECT_EQ(e.providerDetail, QStringLiteral("detail"));
+    EXPECT_FALSE(e.isEmpty());
+}
+
+TEST(ErrorInfoTest, DefaultIsEmpty)
+{
+    ErrorInfo e;
+    EXPECT_TRUE(e.isEmpty());
+    EXPECT_EQ(e.category, ErrorCategory::Provider);
+}
+
+TEST(ErrorInfoTest, EmptyMessageIsEmptyRegardlessOfCategory)
+{
+    const ErrorInfo e = makeError(ErrorCategory::Auth, QString());
+    EXPECT_TRUE(e.isEmpty());
+}
+
+TEST(ErrorInfoTest, CategorizesHttp401AsAuth)
+{
+    EXPECT_EQ(categorizeProviderError(QStringLiteral("HTTP 401 Unauthorized")), ErrorCategory::Auth);
+}
+
+TEST(ErrorInfoTest, CategorizesForbiddenAsAuth)
+{
+    EXPECT_EQ(categorizeProviderError(QStringLiteral("403 Forbidden")), ErrorCategory::Auth);
+}
+
+TEST(ErrorInfoTest, CategorizesApiKeyAsAuth)
+{
+    EXPECT_EQ(categorizeProviderError(QStringLiteral("invalid api key supplied")), ErrorCategory::Auth);
+}
+
+TEST(ErrorInfoTest, CategorizesAuthenticationAsAuth)
+{
+    EXPECT_EQ(categorizeProviderError(QStringLiteral("Authentication failed")), ErrorCategory::Auth);
+}
+
+TEST(ErrorInfoTest, AuthMatchIsCaseInsensitive)
+{
+    EXPECT_EQ(categorizeProviderError(QStringLiteral("UNAUTHORIZED")), ErrorCategory::Auth);
+}
+
+TEST(ErrorInfoTest, CategorizesTimeoutAsNetwork)
+{
+    EXPECT_EQ(categorizeProviderError(QStringLiteral("request timed out")), ErrorCategory::Network);
+}
+
+TEST(ErrorInfoTest, CategorizesConnectionRefusedAsNetwork)
+{
+    EXPECT_EQ(categorizeProviderError(QStringLiteral("Connection refused")), ErrorCategory::Network);
+}
+
+TEST(ErrorInfoTest, CategorizesDnsFailureAsNetwork)
+{
+    EXPECT_EQ(
+        categorizeProviderError(QStringLiteral("could not resolve host")), ErrorCategory::Network);
+}
+
+TEST(ErrorInfoTest, CategorizesSslAsNetwork)
+{
+    EXPECT_EQ(categorizeProviderError(QStringLiteral("SSL handshake error")), ErrorCategory::Network);
+}
+
+TEST(ErrorInfoTest, UnrecognizedErrorFallsBackToProvider)
+{
+    EXPECT_EQ(
+        categorizeProviderError(QStringLiteral("model produced an internal error")),
+        ErrorCategory::Provider);
+}
diff --git a/test/JsonPromptTemplateTest.cpp b/test/JsonPromptTemplateTest.cpp
new file mode 100644
index 0000000..14d22bb
--- /dev/null
+++ b/test/JsonPromptTemplateTest.cpp
@@ -0,0 +1,129 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+using QodeAssist::AgentConfig;
+using QodeAssist::Templates::ContextData;
+using QodeAssist::Templates::JsonPromptTemplate;
+
+namespace {
+
+AgentConfig makeConfig(const QJsonObject &body)
+{
+    AgentConfig cfg;
+    cfg.name = QStringLiteral("test-agent");
+    cfg.body = body;
+    return cfg;
+}
+
+const QString kUserMessages
+    = QStringLiteral("[ { \"role\": \"user\", \"content\": {{ tojson(ctx.prefix) }} } ]");
+
+const QString kSystemField = QStringLiteral(
+    "{% if existsIn(ctx, \"system_prompt\") %}{{ tojson(ctx.system_prompt) }}{% endif %}");
+
+} // namespace
+
+TEST(JsonPromptTemplateTest, RendersJinjaSplicesAndKeepsLiterals)
+{
+    auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
+        {"max_tokens", 128},
+        {"temperature", 0.5},
+        {"stream", true},
+        {"messages", kUserMessages},
+    }));
+    ASSERT_NE(tmpl, nullptr);
+
+    ContextData ctx;
+    ctx.prefix = QStringLiteral("hello world");
+
+    QJsonObject request;
+    ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
+
+    EXPECT_EQ(request.value("max_tokens").toInt(), 128);
+    EXPECT_DOUBLE_EQ(request.value("temperature").toDouble(), 0.5);
+    EXPECT_TRUE(request.value("stream").toBool());
+
+    const QJsonArray messages = request.value("messages").toArray();
+    ASSERT_EQ(messages.size(), 1);
+    EXPECT_EQ(
+        messages.at(0).toObject().value("content").toString(), QStringLiteral("hello world"));
+}
+
+TEST(JsonPromptTemplateTest, DropsKeyWhenJinjaRendersEmpty)
+{
+    auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
+        {"system", kSystemField},
+        {"messages", kUserMessages},
+    }));
+    ASSERT_NE(tmpl, nullptr);
+
+    ContextData ctx;
+    ctx.prefix = QStringLiteral("hi");
+
+    QJsonObject request;
+    ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
+
+    EXPECT_FALSE(request.contains(QStringLiteral("system")));
+    EXPECT_TRUE(request.contains(QStringLiteral("messages")));
+}
+
+TEST(JsonPromptTemplateTest, RendersSystemPromptWhenPresent)
+{
+    auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
+        {"system", kSystemField},
+        {"messages", kUserMessages},
+    }));
+    ASSERT_NE(tmpl, nullptr);
+
+    ContextData ctx;
+    ctx.prefix = QStringLiteral("hi");
+    ctx.systemPrompt = QStringLiteral("You are a helpful assistant.");
+
+    QJsonObject request;
+    ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
+
+    EXPECT_EQ(
+        request.value("system").toString(), QStringLiteral("You are a helpful assistant."));
+}
+
+TEST(JsonPromptTemplateTest, PreservesNestedLiteralObjects)
+{
+    auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{
+        {"thinking", QJsonObject{{"type", "adaptive"}, {"budget", 8192}}},
+        {"messages", kUserMessages},
+    }));
+    ASSERT_NE(tmpl, nullptr);
+
+    ContextData ctx;
+    ctx.prefix = QStringLiteral("x");
+
+    QJsonObject request;
+    ASSERT_TRUE(tmpl->buildFullRequest(request, ctx));
+
+    const QJsonObject thinking = request.value("thinking").toObject();
+    EXPECT_EQ(thinking.value("type").toString(), QStringLiteral("adaptive"));
+    EXPECT_EQ(thinking.value("budget").toInt(), 8192);
+}
+
+TEST(JsonPromptTemplateTest, RejectsBodyThatRendersInvalidJsonAtLoad)
+{
+    QString error;
+    auto tmpl = JsonPromptTemplate::fromConfig(
+        makeConfig(QJsonObject{
+            {"messages", QStringLiteral("[ {{ tojson(ctx.prefix) }}")},
+        }),
+        &error);
+
+    EXPECT_EQ(tmpl, nullptr);
+    EXPECT_FALSE(error.isEmpty());
+}
diff --git a/test/MessageSerializerTest.cpp b/test/MessageSerializerTest.cpp
new file mode 100644
index 0000000..8955297
--- /dev/null
+++ b/test/MessageSerializerTest.cpp
@@ -0,0 +1,306 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+
+using namespace QodeAssist;
+
+namespace {
+
+// Round-trips a message through JSON and back, returning the re-serialized
+// form so it can be compared against the original serialization. Any field
+// dropped or mangled by fromJson/toJson surfaces as a JSON mismatch.
+QJsonObject reserialize(const Message &message)
+{
+    bool ok = false;
+    const QJsonObject json = MessageSerializer::toJson(message);
+    Message restored = MessageSerializer::fromJson(json, &ok);
+    EXPECT_TRUE(ok);
+    return MessageSerializer::toJson(restored);
+}
+
+} // namespace
+
+TEST(MessageSerializerTest, RoleAndIdRoundtrip)
+{
+    Message m(Message::Role::Assistant, QStringLiteral("msg-7"));
+    m.appendBlock(std::make_unique(QStringLiteral("hi")));
+
+    const QJsonObject json = MessageSerializer::toJson(m);
+    EXPECT_EQ(json.value("role").toString(), QStringLiteral("assistant"));
+    EXPECT_EQ(json.value("id").toString(), QStringLiteral("msg-7"));
+
+    EXPECT_EQ(reserialize(m), json);
+}
+
+TEST(MessageSerializerTest, EmptyIdIsOmitted)
+{
+    Message m(Message::Role::User);
+    m.appendBlock(std::make_unique(QStringLiteral("x")));
+
+    const QJsonObject json = MessageSerializer::toJson(m);
+    EXPECT_FALSE(json.contains(QStringLiteral("id")));
+    EXPECT_EQ(json.value("role").toString(), QStringLiteral("user"));
+}
+
+TEST(MessageSerializerTest, SystemRoleRoundtrip)
+{
+    Message m(Message::Role::System);
+    m.appendBlock(std::make_unique(QStringLiteral("rules")));
+    EXPECT_EQ(MessageSerializer::toJson(m).value("role").toString(), QStringLiteral("system"));
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, ThinkingBlockPreservesSignature)
+{
+    Message m(Message::Role::Assistant);
+    m.appendBlock(
+        std::make_unique(QStringLiteral("draft"), QStringLiteral("sig")));
+
+    const QJsonObject block
+        = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
+    EXPECT_EQ(block.value("type").toString(), QStringLiteral("thinking"));
+    EXPECT_EQ(block.value("thinking").toString(), QStringLiteral("draft"));
+    EXPECT_EQ(block.value("signature").toString(), QStringLiteral("sig"));
+
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, RedactedThinkingRoundtrip)
+{
+    Message m(Message::Role::Assistant);
+    m.appendBlock(std::make_unique(QStringLiteral("blob")));
+
+    const QJsonObject block
+        = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
+    EXPECT_EQ(block.value("type").toString(), QStringLiteral("redacted_thinking"));
+    EXPECT_EQ(block.value("signature").toString(), QStringLiteral("blob"));
+
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, ImageBase64Roundtrip)
+{
+    Message m(Message::Role::User);
+    m.appendBlock(std::make_unique(
+        QStringLiteral("ZGF0YQ=="),
+        QStringLiteral("image/png"),
+        LLMQore::ImageContent::ImageSourceType::Base64));
+
+    const QJsonObject block
+        = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
+    EXPECT_EQ(block.value("type").toString(), QStringLiteral("image"));
+    EXPECT_EQ(block.value("sourceType").toString(), QStringLiteral("base64"));
+    EXPECT_EQ(block.value("mediaType").toString(), QStringLiteral("image/png"));
+
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, ImageUrlSourceTypeRoundtrip)
+{
+    Message m(Message::Role::User);
+    m.appendBlock(std::make_unique(
+        QStringLiteral("https://example.com/a.png"),
+        QStringLiteral("image/png"),
+        LLMQore::ImageContent::ImageSourceType::Url));
+
+    const QJsonObject block
+        = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
+    EXPECT_EQ(block.value("sourceType").toString(), QStringLiteral("url"));
+
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, ToolUseRoundtrip)
+{
+    Message m(Message::Role::Assistant);
+    m.appendBlock(std::make_unique(
+        QStringLiteral("tu1"),
+        QStringLiteral("read_file"),
+        QJsonObject{{"path", "a.cpp"}}));
+
+    const QJsonObject block
+        = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
+    EXPECT_EQ(block.value("type").toString(), QStringLiteral("tool_use"));
+    EXPECT_EQ(block.value("id").toString(), QStringLiteral("tu1"));
+    EXPECT_EQ(block.value("name").toString(), QStringLiteral("read_file"));
+    EXPECT_EQ(block.value("input").toObject().value("path").toString(), QStringLiteral("a.cpp"));
+
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, ToolResultRoundtrip)
+{
+    Message m(Message::Role::User);
+    m.appendBlock(
+        std::make_unique(QStringLiteral("tu1"), QStringLiteral("body")));
+
+    const QJsonObject block
+        = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
+    EXPECT_EQ(block.value("type").toString(), QStringLiteral("tool_result"));
+    EXPECT_EQ(block.value("toolUseId").toString(), QStringLiteral("tu1"));
+    EXPECT_EQ(block.value("result").toString(), QStringLiteral("body"));
+
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, StoredImageRoundtrip)
+{
+    Message m(Message::Role::User);
+    m.appendBlock(std::make_unique(
+        QStringLiteral("shot.png"), QStringLiteral("stored/shot"), QStringLiteral("image/png")));
+
+    const QJsonObject block
+        = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
+    EXPECT_EQ(block.value("type").toString(), QStringLiteral("stored_image"));
+    EXPECT_EQ(block.value("fileName").toString(), QStringLiteral("shot.png"));
+    EXPECT_EQ(block.value("storedPath").toString(), QStringLiteral("stored/shot"));
+    EXPECT_EQ(block.value("mediaType").toString(), QStringLiteral("image/png"));
+
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, StoredAttachmentRoundtrip)
+{
+    Message m(Message::Role::User);
+    m.appendBlock(std::make_unique(
+        QStringLiteral("notes.txt"), QStringLiteral("stored/notes")));
+
+    const QJsonObject block
+        = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
+    EXPECT_EQ(block.value("type").toString(), QStringLiteral("stored_attachment"));
+    EXPECT_EQ(block.value("fileName").toString(), QStringLiteral("notes.txt"));
+    EXPECT_EQ(block.value("storedPath").toString(), QStringLiteral("stored/notes"));
+
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, SkillInvocationRoundtrip)
+{
+    Message m(Message::Role::User);
+    m.appendBlock(std::make_unique(
+        QStringLiteral("review"), QStringLiteral("Review the code.")));
+
+    const QJsonObject block
+        = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
+    EXPECT_EQ(block.value("type").toString(), QStringLiteral("skill_invocation"));
+    EXPECT_EQ(block.value("skillName").toString(), QStringLiteral("review"));
+    EXPECT_EQ(block.value("body").toString(), QStringLiteral("Review the code."));
+
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, FileEditRoundtripWithStatusAndMessage)
+{
+    Message m(Message::Role::Assistant);
+    m.appendBlock(std::make_unique(
+        QStringLiteral("e1"),
+        QStringLiteral("a.cpp"),
+        QStringLiteral("old"),
+        QStringLiteral("new"),
+        FileEditContent::Status::Applied,
+        QStringLiteral("done")));
+
+    const QJsonObject block
+        = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
+    EXPECT_EQ(block.value("type").toString(), QStringLiteral("file_edit"));
+    EXPECT_EQ(block.value("editId").toString(), QStringLiteral("e1"));
+    EXPECT_EQ(block.value("filePath").toString(), QStringLiteral("a.cpp"));
+    EXPECT_EQ(block.value("oldContent").toString(), QStringLiteral("old"));
+    EXPECT_EQ(block.value("newContent").toString(), QStringLiteral("new"));
+    EXPECT_EQ(block.value("status").toString(), QStringLiteral("applied"));
+    EXPECT_EQ(block.value("statusMessage").toString(), QStringLiteral("done"));
+
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, FileEditOmitsEmptyStatusMessageAndDefaultsToPending)
+{
+    Message m(Message::Role::Assistant);
+    m.appendBlock(std::make_unique(
+        QStringLiteral("e1"),
+        QStringLiteral("a.cpp"),
+        QStringLiteral("old"),
+        QStringLiteral("new")));
+
+    const QJsonObject block
+        = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject();
+    EXPECT_EQ(block.value("status").toString(), QStringLiteral("pending"));
+    EXPECT_FALSE(block.contains(QStringLiteral("statusMessage")));
+}
+
+TEST(MessageSerializerTest, MultipleBlocksPreserveOrder)
+{
+    Message m(Message::Role::Assistant);
+    m.appendBlock(std::make_unique(QStringLiteral("calling")));
+    m.appendBlock(std::make_unique(
+        QStringLiteral("tu1"), QStringLiteral("read_file"), QJsonObject()));
+
+    const QJsonArray blocks = MessageSerializer::toJson(m).value("blocks").toArray();
+    ASSERT_EQ(blocks.size(), 2);
+    EXPECT_EQ(blocks[0].toObject().value("type").toString(), QStringLiteral("text"));
+    EXPECT_EQ(blocks[1].toObject().value("type").toString(), QStringLiteral("tool_use"));
+
+    EXPECT_EQ(reserialize(m), MessageSerializer::toJson(m));
+}
+
+TEST(MessageSerializerTest, UnknownRoleFailsDeserialization)
+{
+    QJsonObject json;
+    json["role"] = QStringLiteral("operator");
+    json["blocks"] = QJsonArray{};
+
+    bool ok = true;
+    const Message m = MessageSerializer::fromJson(json, &ok);
+    EXPECT_FALSE(ok);
+    EXPECT_TRUE(m.blocks().empty());
+}
+
+TEST(MessageSerializerTest, EmptyBlocksDeserializeOk)
+{
+    QJsonObject json;
+    json["role"] = QStringLiteral("user");
+    json["blocks"] = QJsonArray{};
+
+    bool ok = false;
+    const Message m = MessageSerializer::fromJson(json, &ok);
+    EXPECT_TRUE(ok);
+    EXPECT_TRUE(m.blocks().empty());
+}
+
+TEST(MessageSerializerTest, AllUnknownBlocksFailDeserialization)
+{
+    QJsonObject json;
+    json["role"] = QStringLiteral("assistant");
+    json["blocks"] = QJsonArray{QJsonObject{{"type", "future_block"}}};
+
+    bool ok = true;
+    const Message m = MessageSerializer::fromJson(json, &ok);
+    EXPECT_FALSE(ok);
+    EXPECT_TRUE(m.blocks().empty());
+}
+
+TEST(MessageSerializerTest, UnknownBlocksSkippedButKnownKept)
+{
+    QJsonObject json;
+    json["role"] = QStringLiteral("assistant");
+    json["blocks"] = QJsonArray{
+        QJsonObject{{"type", "future_block"}},
+        QJsonObject{{"type", "text"}, {"text", "kept"}}};
+
+    bool ok = false;
+    const Message m = MessageSerializer::fromJson(json, &ok);
+    EXPECT_TRUE(ok);
+    ASSERT_EQ(m.blocks().size(), 1u);
+    EXPECT_EQ(m.text(), QStringLiteral("kept"));
+}
diff --git a/test/ResponseCleanerTest.cpp b/test/ResponseCleanerTest.cpp
new file mode 100644
index 0000000..828e979
--- /dev/null
+++ b/test/ResponseCleanerTest.cpp
@@ -0,0 +1,64 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+
+using QodeAssist::ResponseCleaner;
+
+TEST(ResponseCleanerTest, EmptyInputStaysEmpty)
+{
+    EXPECT_EQ(ResponseCleaner::clean(QString()), QString());
+}
+
+TEST(ResponseCleanerTest, PlainCodeIsUnchanged)
+{
+    const QString code = QStringLiteral("int main() {\n    return 0;\n}");
+    EXPECT_EQ(ResponseCleaner::clean(code), code);
+}
+
+TEST(ResponseCleanerTest, ExtractsFencedCodeWithLanguage)
+{
+    const QString input = QStringLiteral("```cpp\nint main() {}\n```");
+    EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("int main() {}"));
+}
+
+TEST(ResponseCleanerTest, ExtractsFencedCodeWithoutLanguage)
+{
+    const QString input = QStringLiteral("```\nfoo\nbar\n```");
+    EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("foo\nbar"));
+}
+
+TEST(ResponseCleanerTest, StripsHeresTheExplanationPrefix)
+{
+    const QString input = QStringLiteral("Here's the refactored code:\nint x = 1;");
+    EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("int x = 1;"));
+}
+
+TEST(ResponseCleanerTest, StripsHereIsTheExplanationPrefix)
+{
+    const QString input = QStringLiteral("Here is the code:\nint y = 2;");
+    EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("int y = 2;"));
+}
+
+TEST(ResponseCleanerTest, StripsBareCodeColonPrefix)
+{
+    const QString input = QStringLiteral("code:\nfoo();");
+    EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("foo();"));
+}
+
+TEST(ResponseCleanerTest, TrimsLeadingAndTrailingNewlines)
+{
+    const QString input = QStringLiteral("\n\nhello\n\n");
+    EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("hello"));
+}
+
+TEST(ResponseCleanerTest, FencedCodeWithExplanationLineInsideIsExtractedVerbatim)
+{
+    // The fence body is returned verbatim; explanation stripping only inspects
+    // the first lines of the *extracted* code, which here is real code.
+    const QString input = QStringLiteral("```python\nx = 1\ny = 2\n```");
+    EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("x = 1\ny = 2"));
+}
diff --git a/test/ResponseRouterTest.cpp b/test/ResponseRouterTest.cpp
new file mode 100644
index 0000000..133aaab
--- /dev/null
+++ b/test/ResponseRouterTest.cpp
@@ -0,0 +1,159 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+using namespace QodeAssist;
+
+namespace {
+
+class FakeClient : public LLMQore::BaseClient
+{
+public:
+    using LLMQore::BaseClient::BaseClient;
+
+    void fireChunk(const QString &id, const QString &chunk) { emit chunkReceived(id, chunk); }
+    void fireThinking(const QString &id, const QString &thinking, const QString &signature)
+    {
+        emit thinkingBlockReceived(id, thinking, signature);
+    }
+    void fireToolStarted(
+        const QString &id, const QString &toolId, const QString &name, const QJsonObject &args)
+    {
+        emit toolStarted(id, toolId, name, args);
+    }
+    void fireToolResult(
+        const QString &id, const QString &toolId, const QString &name, const QString &result)
+    {
+        emit toolResultReady(id, toolId, name, result);
+    }
+    void fireFinalized(const QString &id, const LLMQore::CompletionInfo &info)
+    {
+        emit requestFinalized(id, info);
+    }
+    void fireFailed(const QString &id, const QString &error) { emit requestFailed(id, error); }
+
+protected:
+    LLMQore::RequestID sendMessage(
+        const QJsonObject &, const QString &, LLMQore::RequestMode) override
+    {
+        return {};
+    }
+    LLMQore::RequestID ask(const QString &, LLMQore::RequestMode) override { return {}; }
+    QFuture> listModels() override { return {}; }
+    LLMQore::ToolSchemaFormat toolSchemaFormat() const override
+    {
+        return LLMQore::ToolSchemaFormat::Claude;
+    }
+    void processData(const LLMQore::RequestID &, const QByteArray &) override {}
+    void processBufferedResponse(const LLMQore::RequestID &, const QByteArray &) override {}
+    QNetworkRequest prepareNetworkRequest(const QUrl &) const override { return {}; }
+    LLMQore::BaseMessage *messageForRequest(const LLMQore::RequestID &) const override
+    {
+        return nullptr;
+    }
+    void cleanupDerivedData(const LLMQore::RequestID &) override {}
+    QJsonObject buildContinuationPayload(
+        const QJsonObject &,
+        LLMQore::BaseMessage *,
+        const QHash &) override
+    {
+        return {};
+    }
+};
+
+} // namespace
+
+TEST(ResponseRouterTest, BuildsAssistantTurnAndEmitsEvents)
+{
+    FakeClient client;
+    ConversationHistory history;
+    ResponseRouter router(&client, &history);
+
+    QVector kinds;
+    QObject::connect(&router, &ResponseRouter::event, &router, [&kinds](const ResponseEvent &ev) {
+        kinds.append(ev.kind());
+    });
+
+    const QString id = QStringLiteral("req-1");
+    router.beginRequest(id);
+    client.fireThinking(id, QStringLiteral("pondering"), QStringLiteral("sig"));
+    client.fireChunk(id, QStringLiteral("Hello"));
+    client.fireChunk(id, QStringLiteral(" world"));
+    client.fireToolStarted(
+        id, QStringLiteral("t1"), QStringLiteral("read_file"), QJsonObject{{"path", "a.txt"}});
+    client.fireToolResult(
+        id, QStringLiteral("t1"), QStringLiteral("read_file"), QStringLiteral("contents"));
+
+    LLMQore::CompletionInfo info;
+    info.stopReason = QStringLiteral("end_turn");
+    info.usage = LLMQore::TokenUsage{12, 34, 0, 0};
+    client.fireFinalized(id, info);
+
+    ASSERT_EQ(history.size(), 2);
+
+    const Message &assistant = history.messages()[0];
+    EXPECT_EQ(assistant.role(), Message::Role::Assistant);
+    EXPECT_EQ(assistant.id(), id);
+    EXPECT_EQ(assistant.text(), QStringLiteral("Hello world"));
+    EXPECT_TRUE(assistant.hasToolUse());
+
+    const Message &toolResult = history.messages()[1];
+    EXPECT_EQ(toolResult.role(), Message::Role::User);
+
+    EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::ThinkingDelta));
+    EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::TextDelta));
+    EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::ToolResult));
+    EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::Usage));
+    EXPECT_TRUE(kinds.contains(ResponseEvent::Kind::MessageStop));
+}
+
+TEST(ResponseRouterTest, CategorizesAuthError)
+{
+    FakeClient client;
+    ConversationHistory history;
+    ResponseRouter router(&client, &history);
+
+    std::optional captured;
+    QObject::connect(&router, &ResponseRouter::event, &router, [&captured](const ResponseEvent &ev) {
+        if (ev.kind() == ResponseEvent::Kind::Error)
+            captured = *ev.as();
+    });
+
+    router.beginRequest(QStringLiteral("req-2"));
+    client.fireFailed(
+        QStringLiteral("req-2"), QStringLiteral("HTTP 401 Unauthorized: invalid api key"));
+
+    ASSERT_TRUE(captured.has_value());
+    EXPECT_EQ(captured->category, ErrorCategory::Auth);
+}
+
+TEST(ResponseRouterTest, IgnoresEventsForInactiveRequest)
+{
+    FakeClient client;
+    ConversationHistory history;
+    ResponseRouter router(&client, &history);
+
+    router.beginRequest(QStringLiteral("req-3"));
+    client.fireChunk(QStringLiteral("OTHER"), QStringLiteral("ignored"));
+
+    EXPECT_TRUE(history.isEmpty());
+}
diff --git a/test/SystemPromptBuilderTest.cpp b/test/SystemPromptBuilderTest.cpp
new file mode 100644
index 0000000..1177f14
--- /dev/null
+++ b/test/SystemPromptBuilderTest.cpp
@@ -0,0 +1,154 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include 
+
+#include 
+
+#include 
+
+using QodeAssist::SystemPromptBuilder;
+
+TEST(SystemPromptBuilderTest, StartsEmpty)
+{
+    SystemPromptBuilder builder;
+    EXPECT_TRUE(builder.isEmpty());
+    EXPECT_TRUE(builder.compose().isEmpty());
+    EXPECT_TRUE(builder.layerNames().isEmpty());
+}
+
+TEST(SystemPromptBuilderTest, SetLayerStoresTextAndName)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("agent"), QStringLiteral("you are helpful"));
+
+    EXPECT_FALSE(builder.isEmpty());
+    EXPECT_EQ(builder.layer(QStringLiteral("agent")), QStringLiteral("you are helpful"));
+    EXPECT_EQ(builder.layerNames(), QStringList{QStringLiteral("agent")});
+}
+
+TEST(SystemPromptBuilderTest, ComposeOrdersByPriorityAscending)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("b"), QStringLiteral("B"), 100);
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 50);
+
+    EXPECT_EQ(builder.compose(), QStringLiteral("A\n\nB"));
+}
+
+TEST(SystemPromptBuilderTest, EqualPriorityKeepsInsertionOrder)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("first"), QStringLiteral("F"), 100);
+    builder.setLayer(QStringLiteral("second"), QStringLiteral("S"), 100);
+
+    EXPECT_EQ(builder.compose(), QStringLiteral("F\n\nS"));
+}
+
+TEST(SystemPromptBuilderTest, AgentPriorityComposesBeforeDefault)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("env"), QStringLiteral("ENV"), SystemPromptBuilder::kDefaultPriority);
+    builder.setLayer(QStringLiteral("agent"), QStringLiteral("SYS"), SystemPromptBuilder::kAgentPriority);
+
+    EXPECT_EQ(builder.compose(), QStringLiteral("SYS\n\nENV"));
+}
+
+TEST(SystemPromptBuilderTest, ComposeUsesCustomSeparator)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 1);
+    builder.setLayer(QStringLiteral("b"), QStringLiteral("B"), 2);
+
+    EXPECT_EQ(builder.compose(QStringLiteral(" | ")), QStringLiteral("A | B"));
+}
+
+TEST(SystemPromptBuilderTest, ComposeSkipsEmptyTextButLayerStaysNamed)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("A"));
+    builder.setLayer(QStringLiteral("blank"), QString());
+
+    EXPECT_EQ(builder.compose(), QStringLiteral("A"));
+    EXPECT_TRUE(builder.layerNames().contains(QStringLiteral("blank")));
+}
+
+TEST(SystemPromptBuilderTest, SetLayerUpdatesExistingInPlace)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("old"));
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("new"));
+
+    EXPECT_EQ(builder.layerNames().size(), 1);
+    EXPECT_EQ(builder.layer(QStringLiteral("a")), QStringLiteral("new"));
+}
+
+TEST(SystemPromptBuilderTest, IdenticalSetLayerEmitsNoSignal)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 10);
+
+    QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 10);
+
+    EXPECT_EQ(spy.count(), 0);
+}
+
+TEST(SystemPromptBuilderTest, ChangingSetLayerEmitsSignal)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 10);
+
+    QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("A"), 20);
+
+    EXPECT_EQ(spy.count(), 1);
+}
+
+TEST(SystemPromptBuilderTest, ClearLayerRemovesAndSignals)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("A"));
+
+    QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
+    builder.clearLayer(QStringLiteral("a"));
+
+    EXPECT_TRUE(builder.isEmpty());
+    EXPECT_EQ(spy.count(), 1);
+}
+
+TEST(SystemPromptBuilderTest, ClearMissingLayerEmitsNoSignal)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("A"));
+
+    QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
+    builder.clearLayer(QStringLiteral("nope"));
+
+    EXPECT_FALSE(builder.isEmpty());
+    EXPECT_EQ(spy.count(), 0);
+}
+
+TEST(SystemPromptBuilderTest, ClearEmptiesAndSignals)
+{
+    SystemPromptBuilder builder;
+    builder.setLayer(QStringLiteral("a"), QStringLiteral("A"));
+    builder.setLayer(QStringLiteral("b"), QStringLiteral("B"));
+
+    QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
+    builder.clear();
+
+    EXPECT_TRUE(builder.isEmpty());
+    EXPECT_EQ(spy.count(), 1);
+}
+
+TEST(SystemPromptBuilderTest, ClearWhenAlreadyEmptyEmitsNoSignal)
+{
+    SystemPromptBuilder builder;
+
+    QSignalSpy spy(&builder, &SystemPromptBuilder::layersChanged);
+    builder.clear();
+
+    EXPECT_EQ(spy.count(), 0);
+}
diff --git a/test/TestUtils.hpp b/test/TestUtils.hpp
index 1fbb61c..78ec61e 100644
--- a/test/TestUtils.hpp
+++ b/test/TestUtils.hpp
@@ -3,7 +3,7 @@
 // Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
 
 #include 
-#include 
+#include 
 #include 
 
 QT_BEGIN_NAMESPACE
@@ -44,12 +44,11 @@ std::ostream &operator<<(std::ostream &out, const std::optional &value)
     return out;
 }
 
-namespace QodeAssist::PluginLLMCore {
+namespace QodeAssist::Templates {
 
 inline std::ostream &operator<<(std::ostream &out, const Message &value)
 {
-    out << "Message{"
-        << "role=" << value.role << "content=" << value.content << "}";
+    out << "Message{role=" << value.role << ", blocks=" << value.blocks.size() << "}";
     return out;
 }
 
@@ -62,4 +61,4 @@ inline std::ostream &operator<<(std::ostream &out, const ContextData &value)
     return out;
 }
 
-} // namespace QodeAssist::PluginLLMCore
+} // namespace QodeAssist::Templates
diff --git a/tools/ListProjectFilesTool.cpp b/tools/ListProjectFilesTool.cpp
index dc5a7be..9c4bb0c 100644
--- a/tools/ListProjectFilesTool.cpp
+++ b/tools/ListProjectFilesTool.cpp
@@ -115,15 +115,4 @@ QFuture ListProjectFilesTool::executeAsync(const QJsonObjec
     });
 }
 
-QString ListProjectFilesTool::formatFileList(const QStringList &files) const
-{
-    QString result = QString("Project files (%1 total):\n\n").arg(files.size());
-
-    for (const QString &file : files) {
-        result += QString("- %1\n").arg(file);
-    }
-
-    return result;
-}
-
 } // namespace QodeAssist::Tools
diff --git a/tools/ListProjectFilesTool.hpp b/tools/ListProjectFilesTool.hpp
index dbcbd46..abb0506 100644
--- a/tools/ListProjectFilesTool.hpp
+++ b/tools/ListProjectFilesTool.hpp
@@ -24,7 +24,6 @@ public:
     QFuture executeAsync(const QJsonObject &input = QJsonObject()) override;
 
 private:
-    QString formatFileList(const QStringList &files) const;
     Context::IgnoreManager *m_ignoreManager;
 };
 
diff --git a/widgets/CompletionErrorHandler.hpp b/widgets/CompletionErrorHandler.hpp
index 6cbf46b..3c992b2 100644
--- a/widgets/CompletionErrorHandler.hpp
+++ b/widgets/CompletionErrorHandler.hpp
@@ -21,8 +21,6 @@ public:
 
     void hideError();
 
-    bool isErrorVisible() const { return !m_errorWidget.isNull(); }
-
 protected:
     void identifyMatch(
         TextEditor::TextEditorWidget *editorWidget, int pos, ReportPriority report) override;
diff --git a/widgets/CompletionHintHandler.cpp b/widgets/CompletionHintHandler.cpp
deleted file mode 100644
index 0a3203c..0000000
--- a/widgets/CompletionHintHandler.cpp
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2025-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#include "CompletionHintHandler.hpp"
-#include "CompletionHintWidget.hpp"
-
-#include 
-
-namespace QodeAssist {
-
-CompletionHintHandler::CompletionHintHandler() = default;
-
-CompletionHintHandler::~CompletionHintHandler()
-{
-    hideHint();
-}
-
-void CompletionHintHandler::showHint(TextEditor::TextEditorWidget *widget, QPoint position, int fontSize)
-{
-    if (!widget) {
-        return;
-    }
-
-    if (!m_hintWidget) {
-        m_hintWidget = new CompletionHintWidget(widget, fontSize);
-    }
-
-    m_hintWidget->move(position);
-    m_hintWidget->show();
-    m_hintWidget->raise();
-}
-
-void CompletionHintHandler::hideHint()
-{
-    if (m_hintWidget) {
-        m_hintWidget->deleteLater();
-        m_hintWidget = nullptr;
-    }
-}
-
-bool CompletionHintHandler::isHintVisible() const
-{
-    return !m_hintWidget.isNull() && m_hintWidget->isVisible();
-}
-
-void CompletionHintHandler::updateHintPosition(TextEditor::TextEditorWidget *widget, QPoint position)
-{
-    if (!widget || !m_hintWidget) {
-        return;
-    }
-
-    m_hintWidget->move(position);
-}
-
-} // namespace QodeAssist
-
diff --git a/widgets/CompletionHintHandler.hpp b/widgets/CompletionHintHandler.hpp
deleted file mode 100644
index eb72781..0000000
--- a/widgets/CompletionHintHandler.hpp
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2025-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-#include 
-
-namespace TextEditor {
-class TextEditorWidget;
-}
-
-namespace QodeAssist {
-
-class CompletionHintWidget;
-
-class CompletionHintHandler
-{
-public:
-    CompletionHintHandler();
-    ~CompletionHintHandler();
-
-    void showHint(TextEditor::TextEditorWidget *widget, QPoint position, int fontSize);
-    void hideHint();
-    bool isHintVisible() const;
-    void updateHintPosition(TextEditor::TextEditorWidget *widget, QPoint position);
-
-private:
-    QPointer m_hintWidget;
-};
-
-} // namespace QodeAssist
-
diff --git a/widgets/CompletionHintWidget.cpp b/widgets/CompletionHintWidget.cpp
deleted file mode 100644
index dbe97c0..0000000
--- a/widgets/CompletionHintWidget.cpp
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2025-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#include "CompletionHintWidget.hpp"
-
-#include 
-
-#include 
-
-namespace QodeAssist {
-
-CompletionHintWidget::CompletionHintWidget(QWidget *parent, int fontSize)
-    : QWidget(parent)
-    , m_isHovered(false)
-{
-    m_accentColor = Utils::creatorTheme()->color(Utils::Theme::TextColorNormal);
-
-    setMouseTracking(true);
-    setFocusPolicy(Qt::NoFocus);
-    setAttribute(Qt::WA_TranslucentBackground);
-    
-    int triangleSize = qMax(6, fontSize / 2);
-    setFixedSize(triangleSize, triangleSize);
-}
-
-void CompletionHintWidget::paintEvent(QPaintEvent *event)
-{
-    Q_UNUSED(event);
-    
-    QPainter painter(this);
-    painter.setRenderHint(QPainter::Antialiasing);
-
-    QColor triangleColor = m_accentColor;
-    triangleColor.setAlpha(m_isHovered ? 255 : 200);
-    
-    painter.setPen(Qt::NoPen);
-    painter.setBrush(triangleColor);
-    
-    QPolygonF triangle;
-    int w = width();
-    int h = height();
-    
-    triangle << QPointF(0, 0)
-             << QPointF(0, h)
-             << QPointF(w, h / 2.0);
-    
-    painter.drawPolygon(triangle);
-}
-
-void CompletionHintWidget::enterEvent(QEnterEvent *event)
-{
-    Q_UNUSED(event);
-    m_isHovered = true;
-    setCursor(Qt::PointingHandCursor);
-    update();
-}
-
-void CompletionHintWidget::leaveEvent(QEvent *event)
-{
-    Q_UNUSED(event);
-    m_isHovered = false;
-    setCursor(Qt::ArrowCursor);
-    update();
-}
-
-} // namespace QodeAssist
-
diff --git a/widgets/CompletionHintWidget.hpp b/widgets/CompletionHintWidget.hpp
deleted file mode 100644
index 1df948c..0000000
--- a/widgets/CompletionHintWidget.hpp
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2025-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-
-namespace QodeAssist {
-
-class CompletionHintWidget : public QWidget
-{
-    Q_OBJECT
-public:
-    explicit CompletionHintWidget(QWidget *parent = nullptr, int fontSize = 12);
-    ~CompletionHintWidget() override = default;
-
-protected:
-    void paintEvent(QPaintEvent *event) override;
-    void enterEvent(QEnterEvent *event) override;
-    void leaveEvent(QEvent *event) override;
-
-private:
-    QColor m_accentColor;
-    bool m_isHovered;
-};
-
-} // namespace QodeAssist
-
diff --git a/widgets/CompletionProgressHandler.hpp b/widgets/CompletionProgressHandler.hpp
index 56b7da7..56dcfff 100644
--- a/widgets/CompletionProgressHandler.hpp
+++ b/widgets/CompletionProgressHandler.hpp
@@ -18,7 +18,6 @@ public:
     void showProgress(TextEditor::TextEditorWidget *widget);
     void hideProgress();
     void setCancelCallback(std::function callback);
-    bool isProgressVisible() const { return !m_progressWidget.isNull(); }
 
 protected:
     void identifyMatch(
diff --git a/widgets/ContextExtractor.hpp b/widgets/ContextExtractor.hpp
index e25fb06..e841328 100644
--- a/widgets/ContextExtractor.hpp
+++ b/widgets/ContextExtractor.hpp
@@ -63,47 +63,6 @@ public:
 
         return contextLines.join('\n');
     }
-
-    static QString extractLineContext(QTextDocument *doc, int position, bool before)
-    {
-        QTextCursor cursor(doc);
-        cursor.setPosition(position);
-
-        if (before) {
-            int posInBlock = cursor.positionInBlock();
-            cursor.movePosition(QTextCursor::StartOfBlock);
-            cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, posInBlock);
-            return cursor.selectedText();
-        } else {
-            cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
-            return cursor.selectedText();
-        }
-    }
-
-    static QStringList extractSurroundingLines(QTextDocument *doc, int position, int linesBefore, int linesAfter)
-    {
-        QTextCursor cursor(doc);
-        cursor.setPosition(position);
-        QTextBlock currentBlock = cursor.block();
-
-        QStringList result;
-
-        QTextBlock blockBefore = currentBlock.previous();
-        QStringList beforeLines;
-        for (int i = 0; i < linesBefore && blockBefore.isValid(); ++i) {
-            beforeLines.prepend(blockBefore.text());
-            blockBefore = blockBefore.previous();
-        }
-        result.append(beforeLines);
-
-        QTextBlock blockAfter = currentBlock.next();
-        for (int i = 0; i < linesAfter && blockAfter.isValid(); ++i) {
-            result.append(blockAfter.text());
-            blockAfter = blockAfter.next();
-        }
-
-        return result;
-    }
 };
 
 } // namespace QodeAssist
diff --git a/widgets/EditorChatButton.cpp b/widgets/EditorChatButton.cpp
deleted file mode 100644
index 2ebc788..0000000
--- a/widgets/EditorChatButton.cpp
+++ /dev/null
@@ -1,125 +0,0 @@
-// Copyright (C) 2025-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#include "EditorChatButton.hpp"
-
-#include 
-#include 
-#include 
-
-namespace QodeAssist {
-
-EditorChatButton::EditorChatButton(QWidget *parent)
-    : QWidget(parent)
-{
-    m_textColor = Utils::creatorTheme()->color(Utils::Theme::TextColorNormal);
-    m_backgroundColor = Utils::creatorTheme()->color(Utils::Theme::BackgroundColorNormal);
-
-    m_logoPixmap = QPixmap(":/resources/images/qoderassist-icon.png");
-
-    if (!m_logoPixmap.isNull()) {
-        QImage image = m_logoPixmap.toImage();
-        image = image.convertToFormat(QImage::Format_ARGB32);
-
-        for (int y = 0; y < image.height(); ++y) {
-            for (int x = 0; x < image.width(); ++x) {
-                QColor pixelColor = QColor::fromRgba(image.pixel(x, y));
-
-                int brightness = (pixelColor.red() + pixelColor.green() + pixelColor.blue()) / 3;
-
-                if (brightness > 200) {
-                    pixelColor.setAlpha(0);
-                    image.setPixelColor(x, y, pixelColor);
-                } else if (pixelColor.alpha() > 0) {
-                    int alpha = pixelColor.alpha();
-                    pixelColor = m_textColor;
-                    pixelColor.setAlpha(alpha);
-                    image.setPixelColor(x, y, pixelColor);
-                }
-            }
-        }
-
-        m_logoPixmap = QPixmap::fromImage(image);
-        m_logoPixmap = m_logoPixmap.scaled(24, 24, Qt::KeepAspectRatio, Qt::SmoothTransformation);
-    }
-
-    setFixedSize(40, 40);
-    setCursor(Qt::PointingHandCursor);
-    setToolTip(tr("Open QodeAssist Chat"));
-}
-
-EditorChatButton::~EditorChatButton() = default;
-
-void EditorChatButton::paintEvent(QPaintEvent *)
-{
-    QPainter painter(this);
-    painter.setRenderHint(QPainter::Antialiasing);
-
-    QColor bgColor = m_backgroundColor;
-    if (m_isPressed) {
-        bgColor = bgColor.darker(120);
-    } else if (m_isHovered) {
-        bgColor = bgColor.lighter(110);
-    }
-    painter.fillRect(rect(), bgColor);
-
-    QRect buttonRect = rect().adjusted(4, 4, -4, -4);
-    painter.setPen(Qt::NoPen);
-    QColor buttonBgColor
-        = m_isPressed ? Utils::creatorTheme()->color(Utils::Theme::BackgroundColorHover).darker(110)
-                      : Utils::creatorTheme()->color(Utils::Theme::BackgroundColorHover);
-
-    if (m_isHovered) {
-        buttonBgColor = buttonBgColor.lighter(110);
-    }
-    painter.setBrush(buttonBgColor);
-    painter.drawEllipse(buttonRect);
-
-    if (!m_logoPixmap.isNull()) {
-        QRect logoRect(
-            (width() - m_logoPixmap.width()) / 2,
-            (height() - m_logoPixmap.height()) / 2,
-            m_logoPixmap.width(),
-            m_logoPixmap.height());
-        painter.drawPixmap(logoRect, m_logoPixmap);
-    }
-}
-
-void EditorChatButton::mousePressEvent(QMouseEvent *event)
-{
-    if (event->button() == Qt::LeftButton) {
-        m_isPressed = true;
-        update();
-    }
-    QWidget::mousePressEvent(event);
-}
-
-void EditorChatButton::mouseReleaseEvent(QMouseEvent *event)
-{
-    if (event->button() == Qt::LeftButton && m_isPressed) {
-        m_isPressed = false;
-        update();
-        if (rect().contains(event->pos())) {
-            emit clicked();
-        }
-    }
-    QWidget::mouseReleaseEvent(event);
-}
-
-void EditorChatButton::enterEvent(QEnterEvent *event)
-{
-    m_isHovered = true;
-    update();
-    QWidget::enterEvent(event);
-}
-
-void EditorChatButton::leaveEvent(QEvent *event)
-{
-    m_isHovered = false;
-    m_isPressed = false;
-    update();
-    QWidget::leaveEvent(event);
-}
-
-} // namespace QodeAssist
diff --git a/widgets/EditorChatButton.hpp b/widgets/EditorChatButton.hpp
deleted file mode 100644
index 6f7a671..0000000
--- a/widgets/EditorChatButton.hpp
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2025-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include 
-#include 
-#include 
-
-namespace QodeAssist {
-
-class EditorChatButton : public QWidget
-{
-    Q_OBJECT
-public:
-    explicit EditorChatButton(QWidget *parent = nullptr);
-    ~EditorChatButton() override;
-
-signals:
-    void clicked();
-
-protected:
-    void paintEvent(QPaintEvent *event) override;
-    void mousePressEvent(QMouseEvent *event) override;
-    void mouseReleaseEvent(QMouseEvent *event) override;
-    void enterEvent(QEnterEvent *event) override;
-    void leaveEvent(QEvent *event) override;
-
-private:
-    QPixmap m_logoPixmap;
-    QColor m_textColor;
-    QColor m_backgroundColor;
-    bool m_isPressed = false;
-    bool m_isHovered = false;
-};
-
-} // namespace QodeAssist
diff --git a/widgets/EditorChatButtonHandler.cpp b/widgets/EditorChatButtonHandler.cpp
deleted file mode 100644
index cb54d76..0000000
--- a/widgets/EditorChatButtonHandler.cpp
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2025-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#include "EditorChatButtonHandler.hpp"
-#include "EditorChatButton.hpp"
-
-#include 
-#include 
-#include 
-
-namespace QodeAssist {
-
-EditorChatButtonHandler::~EditorChatButtonHandler()
-{
-    delete m_chatButton;
-}
-
-void EditorChatButtonHandler::showButton(TextEditor::TextEditorWidget *widget)
-{
-    if (!widget)
-        return;
-
-    m_widget = widget;
-
-    identifyMatch(widget, widget->textCursor().position(), [this](auto priority) {
-        if (priority != Priority_None && m_widget) {
-            const QTextCursor cursor = m_widget->textCursor();
-            const QRect selectionRect = m_widget->cursorRect(cursor);
-            m_cursorPosition = m_widget->viewport()->mapToGlobal(selectionRect.topLeft())
-                               - Utils::ToolTip::offsetFromPosition();
-            operateTooltip(m_widget, m_cursorPosition);
-        }
-    });
-}
-
-void EditorChatButtonHandler::hideButton()
-{
-    Utils::ToolTip::hide();
-}
-
-void EditorChatButtonHandler::identifyMatch(
-    TextEditor::TextEditorWidget *editorWidget, int pos, ReportPriority report)
-{
-    if (!editorWidget) {
-        report(Priority_None);
-        return;
-    }
-
-    report(Priority_Tooltip);
-}
-
-void EditorChatButtonHandler::operateTooltip(
-    TextEditor::TextEditorWidget *editorWidget, const QPoint &point)
-{
-    if (!editorWidget)
-        return;
-
-    if (!Utils::ToolTip::isVisible()) {
-        m_chatButton = new EditorChatButton(editorWidget);
-        m_buttonHeight = m_chatButton->height();
-
-        QPoint showPoint = point;
-        showPoint.ry() -= m_buttonHeight;
-
-        Utils::ToolTip::show(showPoint, m_chatButton, editorWidget);
-    } else {
-        QPoint showPoint = point;
-        showPoint.ry() -= m_buttonHeight;
-        Utils::ToolTip::move(showPoint);
-    }
-}
-
-} // namespace QodeAssist
diff --git a/widgets/EditorChatButtonHandler.hpp b/widgets/EditorChatButtonHandler.hpp
deleted file mode 100644
index d8645d2..0000000
--- a/widgets/EditorChatButtonHandler.hpp
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2025-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include "widgets/EditorChatButton.hpp"
-#include 
-#include 
-
-namespace QodeAssist {
-
-class EditorChatButtonHandler : public TextEditor::BaseHoverHandler
-{
-public:
-    explicit EditorChatButtonHandler() = default;
-    ~EditorChatButtonHandler() override;
-
-    void showButton(TextEditor::TextEditorWidget *widget);
-    void hideButton();
-
-signals:
-    void chatButtonClicked(TextEditor::TextEditorWidget *widget);
-
-protected:
-    void identifyMatch(
-        TextEditor::TextEditorWidget *editorWidget, int pos, ReportPriority report) override;
-    void operateTooltip(TextEditor::TextEditorWidget *editorWidget, const QPoint &point) override;
-
-private:
-    QPointer m_widget;
-    QPoint m_cursorPosition;
-    EditorChatButton *m_chatButton = nullptr;
-    int m_buttonHeight = 0;
-};
-
-} // namespace QodeAssist
diff --git a/widgets/ErrorWidget.cpp b/widgets/ErrorWidget.cpp
index bdd5bef..d83683c 100644
--- a/widgets/ErrorWidget.cpp
+++ b/widgets/ErrorWidget.cpp
@@ -47,16 +47,6 @@ ErrorWidget::~ErrorWidget()
     }
 }
 
-void ErrorWidget::setErrorMessage(const QString &message)
-{
-    m_errorMessage = message;
-    QFont smallFont = font();
-    smallFont.setPointSize(qMax(8, smallFont.pointSize() - 2));
-    setFont(smallFont);
-    setFixedSize(calculateSize());
-    update();
-}
-
 void ErrorWidget::setupColors()
 {
     m_textColor = Utils::creatorTheme()->color(Utils::Theme::TextColorNormal);
diff --git a/widgets/ErrorWidget.hpp b/widgets/ErrorWidget.hpp
index 18d5a3f..222e1cd 100644
--- a/widgets/ErrorWidget.hpp
+++ b/widgets/ErrorWidget.hpp
@@ -19,10 +19,6 @@ public:
     explicit ErrorWidget(const QString &errorMessage, QWidget *parent = nullptr, int autoHideMs = 5000);
     ~ErrorWidget();
 
-    void setErrorMessage(const QString &message);
-
-    QString errorMessage() const { return m_errorMessage; }
-
 signals:
     void dismissed();
 
diff --git a/widgets/QuickRefactorDialog.cpp b/widgets/QuickRefactorDialog.cpp
index 50f5306..671f12b 100644
--- a/widgets/QuickRefactorDialog.cpp
+++ b/widgets/QuickRefactorDialog.cpp
@@ -7,9 +7,7 @@
 #include "CustomInstructionsManager.hpp"
 #include "QodeAssisttr.h"
 
-#include "settings/ConfigurationManager.hpp"
 #include "settings/GeneralSettings.hpp"
-#include "settings/QuickRefactorSettings.hpp"
 #include "settings/SettingsConstants.hpp"
 
 #include 
@@ -42,46 +40,11 @@
 
 namespace QodeAssist {
 
-static QIcon createThemedIcon(const QString &svgPath, const QColor &color)
-{
-    QSvgRenderer renderer(svgPath);
-    if (!renderer.isValid()) {
-        return QIcon();
-    }
-
-    QSize iconSize(16, 16);
-    QPixmap pixmap(iconSize);
-    pixmap.fill(Qt::transparent);
-
-    QPainter painter(&pixmap);
-    renderer.render(&painter);
-    painter.end();
-
-    QImage image = pixmap.toImage().convertToFormat(QImage::Format_ARGB32);
-
-    uchar *bits = image.bits();
-    const int bytesPerPixel = 4;
-    const int totalBytes = image.width() * image.height() * bytesPerPixel;
-
-    const int newR = color.red();
-    const int newG = color.green();
-    const int newB = color.blue();
-
-    for (int i = 0; i < totalBytes; i += bytesPerPixel) {
-        int alpha = bits[i + 3];
-        if (alpha > 0) {
-            bits[i] = newB;
-            bits[i + 1] = newG;
-            bits[i + 2] = newR;
-        }
-    }
-
-    return QIcon(QPixmap::fromImage(image));
-}
-
-QuickRefactorDialog::QuickRefactorDialog(QWidget *parent, const QString &lastInstructions)
+QuickRefactorDialog::QuickRefactorDialog(
+    QWidget *parent, const QString &lastInstructions, bool refactorAgentAvailable)
     : QDialog(parent)
     , m_lastInstructions(lastInstructions)
+    , m_refactorAgentAvailable(refactorAgentAvailable)
 {
     setWindowTitle(Tr::tr("Quick Refactor"));
     setupUi();
@@ -113,48 +76,6 @@ void QuickRefactorDialog::setupUi()
     actionsLayout->addWidget(m_alternativeButton);
     actionsLayout->addStretch();
 
-    m_configComboBox = new QComboBox(this);
-    m_configComboBox->setMinimumWidth(200);
-    m_configComboBox->setToolTip(Tr::tr("Switch AI configuration"));
-    actionsLayout->addWidget(m_configComboBox);
-
-    Utils::Theme *theme = Utils::creatorTheme();
-    QColor iconColor = theme ? theme->color(Utils::Theme::TextColorNormal) : QColor(Qt::white);
-
-    m_toolsIconOn = createThemedIcon(":/qt/qml/ChatView/icons/tools-icon-on.svg", iconColor);
-    m_toolsIconOff = createThemedIcon(":/qt/qml/ChatView/icons/tools-icon-off.svg", iconColor);
-
-    m_toolsButton = new QToolButton(this);
-    m_toolsButton->setCheckable(true);
-    m_toolsButton->setChecked(Settings::quickRefactorSettings().useTools());
-    m_toolsButton->setIcon(m_toolsButton->isChecked() ? m_toolsIconOn : m_toolsIconOff);
-    m_toolsButton->setToolTip(Tr::tr("Enable/Disable AI Tools"));
-    m_toolsButton->setIconSize(QSize(16, 16));
-    actionsLayout->addWidget(m_toolsButton);
-
-    connect(m_toolsButton, &QToolButton::toggled, this, [this](bool checked) {
-        m_toolsButton->setIcon(checked ? m_toolsIconOn : m_toolsIconOff);
-        Settings::quickRefactorSettings().useTools.setValue(checked);
-        Settings::quickRefactorSettings().writeSettings();
-    });
-
-    m_thinkingIconOn = createThemedIcon(":/qt/qml/ChatView/icons/thinking-icon-on.svg", iconColor);
-    m_thinkingIconOff = createThemedIcon(":/qt/qml/ChatView/icons/thinking-icon-off.svg", iconColor);
-
-    m_thinkingButton = new QToolButton(this);
-    m_thinkingButton->setCheckable(true);
-    m_thinkingButton->setChecked(Settings::quickRefactorSettings().useThinking());
-    m_thinkingButton->setIcon(m_thinkingButton->isChecked() ? m_thinkingIconOn : m_thinkingIconOff);
-    m_thinkingButton->setToolTip(Tr::tr("Enable/Disable Thinking Mode"));
-    m_thinkingButton->setIconSize(QSize(16, 16));
-    actionsLayout->addWidget(m_thinkingButton);
-
-    connect(m_thinkingButton, &QToolButton::toggled, this, [this](bool checked) {
-        m_thinkingButton->setIcon(checked ? m_thinkingIconOn : m_thinkingIconOff);
-        Settings::quickRefactorSettings().useThinking.setValue(checked);
-        Settings::quickRefactorSettings().writeSettings();
-    });
-
     m_settingsButton = new QToolButton(this);
     m_settingsButton->setIcon(Utils::Icons::SETTINGS_TOOLBAR.icon());
     m_settingsButton->setToolTip(Tr::tr("Open Quick Refactor Settings"));
@@ -244,23 +165,36 @@ void QuickRefactorDialog::setupUi()
         &QuickRefactorDialog::onOpenInstructionsFolder);
 
     loadCustomCommands();
-    loadAvailableConfigurations();
-
-    connect(
-        m_configComboBox,
-        QOverload::of(&QComboBox::currentIndexChanged),
-        this,
-        &QuickRefactorDialog::onConfigurationChanged);
 
     QDialogButtonBox *buttonBox
         = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
     connect(buttonBox, &QDialogButtonBox::accepted, this, &QuickRefactorDialog::validateAndAccept);
     connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
-    mainLayout->addWidget(buttonBox);
 
     QPushButton *okButton = buttonBox->button(QDialogButtonBox::Ok);
     QPushButton *cancelButton = buttonBox->button(QDialogButtonBox::Cancel);
-    
+
+    if (!m_refactorAgentAvailable) {
+        if (okButton) {
+            okButton->setEnabled(false);
+            okButton->setToolTip(Tr::tr("Assign a Quick Refactor agent in the Pipelines settings"));
+        }
+
+        QLabel *agentHint = new QLabel(
+            Tr::tr("No Quick Refactor agent is set. "
+                   "Assign one in the Pipelines settings."),
+            this);
+        agentHint->setWordWrap(true);
+        agentHint->setTextInteractionFlags(
+            Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
+        connect(agentHint, &QLabel::linkActivated, this, [] {
+            Settings::showSettings(Constants::QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID);
+        });
+        mainLayout->addWidget(agentHint);
+    }
+
+    mainLayout->addWidget(buttonBox);
+
     if (okButton) {
         okButton->installEventFilter(this);
     }
@@ -308,16 +242,6 @@ QString QuickRefactorDialog::instructions() const
     return m_instructionEdit->toPlainText().trimmed();
 }
 
-void QuickRefactorDialog::setInstructions(const QString &instructions)
-{
-    m_instructionEdit->setPlainText(instructions);
-}
-
-QuickRefactorDialog::Action QuickRefactorDialog::selectedAction() const
-{
-    return m_selectedAction;
-}
-
 void QuickRefactorDialog::keyPressEvent(QKeyEvent *event)
 {
     QDialog::keyPressEvent(event);
@@ -355,7 +279,6 @@ void QuickRefactorDialog::useLastInstructions()
     if (!m_lastInstructions.isEmpty()) {
         m_commandsComboBox->setCurrentIndex(0);
         m_instructionEdit->setPlainText(m_lastInstructions);
-        m_selectedAction = Action::RepeatLast;
     }
     accept();
 }
@@ -367,7 +290,6 @@ void QuickRefactorDialog::useImproveCodeTemplate()
         Tr::tr(
             "Improve the selected code by enhancing readability, efficiency, and maintainability. "
             "Follow best practices for C++/Qt and fix any potential issues."));
-    m_selectedAction = Action::ImproveCode;
     accept();
 }
 
@@ -379,7 +301,6 @@ void QuickRefactorDialog::useAlternativeSolutionTemplate()
             "Suggest an alternative implementation approach for the selected code. "
             "Provide a different solution that might be cleaner, more efficient, "
             "or uses different Qt/C++ patterns or idioms."));
-    m_selectedAction = Action::AlternativeSolution;
     accept();
 }
 
@@ -577,60 +498,6 @@ void QuickRefactorDialog::onOpenSettings()
     Settings::showSettings(Constants::QODE_ASSIST_QUICK_REFACTOR_SETTINGS_PAGE_ID);
 }
 
-QString QuickRefactorDialog::selectedConfiguration() const
-{
-    return m_selectedConfiguration;
-}
-
-void QuickRefactorDialog::loadAvailableConfigurations()
-{
-    auto &manager = Settings::ConfigurationManager::instance();
-    manager.loadConfigurations(Settings::ConfigurationType::QuickRefactor);
-
-    QVector configs = manager.configurations(
-        Settings::ConfigurationType::QuickRefactor);
-
-    m_configComboBox->clear();
-    m_configComboBox->addItem(Tr::tr("Current"), QString());
-
-    for (const Settings::AIConfiguration &config : configs) {
-        m_configComboBox->addItem(config.name, config.id);
-    }
-
-    auto &settings = Settings::generalSettings();
-    QString currentProvider = settings.qrProvider.value();
-    QString currentModel = settings.qrModel.value();
-    QString currentConfigText = QString("%1/%2").arg(currentProvider, currentModel);
-    m_configComboBox->setItemText(0, Tr::tr("Current (%1)").arg(currentConfigText));
-}
-
-void QuickRefactorDialog::onConfigurationChanged(int index)
-{
-    if (index == 0) {
-        m_selectedConfiguration.clear();
-        return;
-    }
-
-    QString configId = m_configComboBox->itemData(index).toString();
-    m_selectedConfiguration = m_configComboBox->itemText(index);
-
-    auto &manager = Settings::ConfigurationManager::instance();
-    Settings::AIConfiguration config
-        = manager.getConfigurationById(configId, Settings::ConfigurationType::QuickRefactor);
-
-    if (!config.id.isEmpty()) {
-        auto &settings = Settings::generalSettings();
-
-        settings.qrProvider.setValue(config.provider);
-        settings.qrModel.setValue(config.model);
-        settings.qrTemplate.setValue(config.templateName);
-        settings.qrUrl.setValue(config.url);
-        settings.qrCustomEndpoint.setValue(config.customEndpoint);
-
-        settings.writeSettings();
-    }
-}
-
 void QuickRefactorDialog::validateAndAccept()
 {
     QString instruction = m_instructionEdit->toPlainText().trimmed();
diff --git a/widgets/QuickRefactorDialog.hpp b/widgets/QuickRefactorDialog.hpp
index 96999d6..1d027e2 100644
--- a/widgets/QuickRefactorDialog.hpp
+++ b/widgets/QuickRefactorDialog.hpp
@@ -22,18 +22,13 @@ class QuickRefactorDialog : public QDialog
     Q_OBJECT
 
 public:
-    enum class Action { Custom, RepeatLast, ImproveCode, AlternativeSolution };
-
     explicit QuickRefactorDialog(
-        QWidget *parent = nullptr, const QString &lastInstructions = QString());
+        QWidget *parent = nullptr,
+        const QString &lastInstructions = QString(),
+        bool refactorAgentAvailable = true);
     ~QuickRefactorDialog() override = default;
 
     QString instructions() const;
-    void setInstructions(const QString &instructions);
-
-    Action selectedAction() const;
-
-    QString selectedConfiguration() const;
 
     bool eventFilter(QObject *watched, QEvent *event) override;
     void keyPressEvent(QKeyEvent *event) override;
@@ -50,8 +45,6 @@ private slots:
     void onOpenInstructionsFolder();
     void onOpenSettings();
     void loadCustomCommands();
-    void loadAvailableConfigurations();
-    void onConfigurationChanged(int index);
     void validateAndAccept();
 
 private:
@@ -68,19 +61,10 @@ private:
     QToolButton *m_deleteCommandButton;
     QToolButton *m_openFolderButton;
     QToolButton *m_settingsButton;
-    QToolButton *m_toolsButton;
-    QToolButton *m_thinkingButton;
     QComboBox *m_commandsComboBox;
-    QComboBox *m_configComboBox;
 
-    Action m_selectedAction = Action::Custom;
     QString m_lastInstructions;
-    QString m_selectedConfiguration;
-    
-    QIcon m_toolsIconOn;
-    QIcon m_toolsIconOff;
-    QIcon m_thinkingIconOn;
-    QIcon m_thinkingIconOff;
+    bool m_refactorAgentAvailable = true;
 };
 
 } // namespace QodeAssist
diff --git a/widgets/RefactorWidget.cpp b/widgets/RefactorWidget.cpp
index 6215cee..8bf1cdd 100644
--- a/widgets/RefactorWidget.cpp
+++ b/widgets/RefactorWidget.cpp
@@ -382,11 +382,6 @@ void RefactorWidget::dimContextLines(const QString &contextBefore, const QString
     }
 }
 
-QString RefactorWidget::getRefactoredText() const
-{
-    return m_applyText;
-}
-
 void RefactorWidget::setRange(const Utils::Text::Range &range)
 {
     m_range = range;
diff --git a/widgets/RefactorWidget.hpp b/widgets/RefactorWidget.hpp
index 591f791..aed17f8 100644
--- a/widgets/RefactorWidget.hpp
+++ b/widgets/RefactorWidget.hpp
@@ -58,9 +58,7 @@ public:
     void setApplyText(const QString &text) { m_applyText = text; }
     void setRange(const Utils::Text::Range &range);
     void setEditorWidth(int width);
-    
-    QString getRefactoredText() const;
-    
+
     void setApplyCallback(std::function callback);
     void setDeclineCallback(std::function callback);
 
diff --git a/widgets/RefactorWidgetHandler.hpp b/widgets/RefactorWidgetHandler.hpp
index 452dc3e..8abdbeb 100644
--- a/widgets/RefactorWidgetHandler.hpp
+++ b/widgets/RefactorWidgetHandler.hpp
@@ -37,8 +37,6 @@ public:
         const QString &contextAfter);
     
     void hideRefactorWidget();
-    
-    bool isWidgetVisible() const { return !m_refactorWidget.isNull(); }
 
     void setApplyCallback(std::function callback);
     void setDeclineCallback(std::function callback);