From 35bbaa1af084e414dc670a26d42798cbf3c48940 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:26:07 +0200 Subject: [PATCH] fix: Found and fix review mistakes --- ChatView/ChatAgentController.cpp | 6 + ChatView/ChatCompressor.cpp | 45 ++-- ChatView/ChatCompressor.hpp | 2 +- ChatView/ChatHistoryStore.cpp | 14 +- ChatView/ChatHistoryStore.hpp | 6 +- ChatView/ChatModel.cpp | 81 +++++-- ChatView/ChatModel.hpp | 6 +- ChatView/ChatRootView.cpp | 69 ++++-- ChatView/ChatRootView.hpp | 19 +- ChatView/ChatSerializer.cpp | 118 +++++++--- ChatView/ChatSerializer.hpp | 24 +- ChatView/ClientInterface.cpp | 34 +-- ChatView/ClientInterface.hpp | 4 +- ChatView/InputTokenCounter.cpp | 2 +- ChatView/SessionFileRegistry.hpp | 5 - ChatView/qml/RootItem.qml | 6 +- ChatView/qml/controls/BottomBar.qml | 2 +- LLMClientInterface.cpp | 47 +++- LLMClientInterface.hpp | 10 +- QodeAssistClient.cpp | 54 ++++- QodeAssistClient.hpp | 3 +- QuickRefactorHandler.cpp | 118 +++++----- QuickRefactorHandler.hpp | 18 +- README.md | 32 +-- context/ChangesManager.cpp | 10 +- context/ChangesManager.h | 9 +- context/DocumentContextReader.cpp | 2 +- context/DocumentContextReader.hpp | 4 +- context/EnvBlockFormatter.cpp | 4 +- context/ProjectScannerQtCreator.cpp | 3 +- docs/architecture.md | 37 ++- docs/claude-configuration.md | 22 +- docs/google-ai-configuration.md | 22 +- docs/llamacpp-configuration.md | 24 +- docs/mistral-configuration.md | 23 +- docs/ollama-configuration.md | 59 ++--- docs/openai-configuration.md | 40 ++-- docs/troubleshooting.md | 10 +- mcp/McpClientsManager.cpp | 47 ---- mcp/McpClientsManager.hpp | 9 - qodeassist.cpp | 40 ++-- settings/AgentDetailPane.cpp | 20 +- settings/AgentListItem.cpp | 5 +- settings/AgentListPane.cpp | 73 +++--- settings/AgentListPane.hpp | 1 + settings/AgentModelDialog.cpp | 15 +- settings/AgentModelDialog.hpp | 2 +- settings/CMakeLists.txt | 1 - settings/ChatAssistantSettings.cpp | 5 +- settings/CodeCompletionSettings.cpp | 21 +- settings/CollapsibleHeader.cpp | 5 +- settings/GeneralSettings.cpp | 28 ++- settings/GeneralSettings.hpp | 3 +- settings/ProviderDetailPane.cpp | 28 +-- settings/ProviderListItem.cpp | 5 +- settings/ProviderNameMigration.hpp | 30 --- settings/ProviderSettings.cpp | 215 +----------------- settings/ProviderSettings.hpp | 14 +- settings/ProvidersSettingsPage.cpp | 78 +++++-- settings/ProvidersSettingsPage.hpp | 7 +- settings/SettingsTheme.hpp | 4 - settings/SettingsUiBuilders.cpp | 8 +- settings/TagChip.cpp | 6 +- settings/TagFilterStrip.cpp | 31 +-- settings/TagFilterStrip.hpp | 7 +- sources/Session/ContextAssembler.cpp | 35 +-- sources/Session/ConversationHistory.cpp | 20 +- sources/Session/ConversationHistory.hpp | 2 +- sources/Session/ErrorInfo.hpp | 10 +- sources/Session/Message.hpp | 10 + sources/Session/MessageSerializer.cpp | 9 +- sources/Session/ResponseEvent.hpp | 3 +- sources/Session/ResponseRouter.cpp | 13 +- sources/Session/Session.cpp | 66 ++++-- sources/Session/Session.hpp | 11 +- sources/Session/SessionManager.cpp | 25 +- sources/Session/SessionManager.hpp | 3 +- sources/Session/SystemPromptBuilder.cpp | 12 +- sources/agents/Agent.cpp | 3 +- sources/agents/Agent.hpp | 4 +- sources/agents/AgentLoader.cpp | 154 +++++++------ sources/agents/AgentLoader.hpp | 2 +- sources/agents/AgentRouter.cpp | 18 +- sources/agents/ContextRenderer.cpp | 22 +- sources/agents/agents.qrc | 2 + sources/agents/deepseek_chat.toml | 16 ++ sources/agents/ollama_chat_gemma4.toml | 4 +- sources/agents/ollama_chat_simple.toml | 6 +- sources/agents/ollama_chat_thinking.toml | 4 +- sources/agents/ollama_completion_chat.toml | 2 +- sources/agents/ollama_completion_fim.toml | 4 +- sources/agents/ollama_compression_16gb.toml | 4 +- sources/agents/ollama_compression_32gb.toml | 4 +- sources/agents/ollama_compression_8gb.toml | 4 +- .../agents/ollama_quick_refactor_gemma4.toml | 4 +- .../agents/ollama_quick_refactor_qwen35.toml | 4 +- .../agents/ollama_quick_refactor_simple.toml | 4 +- sources/agents/qwen_chat.toml | 16 ++ sources/common/ContextData.hpp | 19 +- sources/common/ResponseCleaner.hpp | 46 +++- sources/providers/ClaudeCacheControl.hpp | 4 +- sources/providers/GenericProvider.cpp | 24 +- sources/providers/GenericProvider.hpp | 11 +- sources/providers/Provider.cpp | 20 +- sources/providers/Provider.hpp | 5 +- sources/providersConfig/deepseek.toml | 8 + .../providersConfig/provider_instances.qrc | 2 + sources/providersConfig/qwen.toml | 8 + sources/settings/AgentRosterWidget.cpp | 140 ++++++------ sources/settings/AgentRosterWidget.hpp | 12 +- sources/settings/AgentSelectionDialog.cpp | 39 ++-- sources/settings/AgentSelectionDialog.hpp | 5 +- sources/settings/Pill.cpp | 7 +- sources/settings/Pill.hpp | 2 - sources/settings/PipelinesConfig.cpp | 125 +++++----- sources/settings/PipelinesConfig.hpp | 32 ++- sources/templates/JsonPromptTemplate.cpp | 122 +++------- sources/templates/JsonPromptTemplate.hpp | 24 -- sources/templates/PromptTemplate.hpp | 9 +- sources/tomlSerializer/TomlWriter.cpp | 26 ++- sources/tomlSerializer/TomlWriter.hpp | 4 +- test/AgentLoaderTest.cpp | 160 ++++++++++--- test/AgentRouterTest.cpp | 1 - test/BundledAgentsTest.cpp | 10 +- test/CMakeLists.txt | 13 +- test/ClaudeCacheControlTest.cpp | 3 +- test/ContextAssemblerTest.cpp | 26 +-- test/ContextRendererTest.cpp | 30 ++- test/DocumentContextReaderTest.cpp | 15 +- test/EnvBlockFormatterTest.cpp | 3 +- test/ErrorInfoTest.cpp | 6 +- test/JsonPromptTemplateTest.cpp | 90 +++++--- test/MessageSerializerTest.cpp | 77 ++++--- test/PipelinesConfigTest.cpp | 139 +++++++++++ test/ResponseCleanerTest.cpp | 32 ++- test/ResponseRouterTest.cpp | 91 +++++++- test/SystemPromptBuilderTest.cpp | 6 +- widgets/QuickRefactorDialog.cpp | 5 +- widgets/RefactorWidgetHandler.hpp | 2 +- 139 files changed, 2032 insertions(+), 1573 deletions(-) delete mode 100644 settings/ProviderNameMigration.hpp create mode 100644 sources/agents/deepseek_chat.toml create mode 100644 sources/agents/qwen_chat.toml create mode 100644 sources/providersConfig/deepseek.toml create mode 100644 sources/providersConfig/qwen.toml create mode 100644 test/PipelinesConfigTest.cpp diff --git a/ChatView/ChatAgentController.cpp b/ChatView/ChatAgentController.cpp index 2a5a55c..e7fc586 100644 --- a/ChatView/ChatAgentController.cpp +++ b/ChatView/ChatAgentController.cpp @@ -23,6 +23,12 @@ ChatAgentController::ChatAgentController(QObject *parent) { if (auto *settings = Core::ICore::settings()) m_currentAgent = settings->value(kChatAgentKey).toString(); + + connect( + Settings::PipelinesNotifier::instance(), + &Settings::PipelinesNotifier::pipelinesChanged, + this, + &ChatAgentController::reload); } void ChatAgentController::setAgentFactory(AgentFactory *factory) diff --git a/ChatView/ChatCompressor.cpp b/ChatView/ChatCompressor.cpp index 40f50cd..75676c6 100644 --- a/ChatView/ChatCompressor.cpp +++ b/ChatView/ChatCompressor.cpp @@ -12,6 +12,7 @@ #include #include +#include "ChatSerializer.hpp" #include "GeneralSettings.hpp" #include "logger/Logger.hpp" @@ -47,8 +48,7 @@ void ChatCompressor::setActiveAgent(const QString &agentName) m_activeAgent = agentName; } -void ChatCompressor::startCompression( - const QString &chatFilePath, ConversationHistory *sourceHistory) +void ChatCompressor::startCompression(const QString &chatFilePath, ConversationHistory *sourceHistory) { if (m_isCompressing) { emit compressionFailed(tr("Compression already in progress")); @@ -73,8 +73,7 @@ void ChatCompressor::startCompression( QString sessionError; Session *session = m_sessionManager->acquire(m_activeAgent, &sessionError); if (!session) { - emit compressionFailed( - sessionError.isEmpty() ? tr("No chat agent selected") : sessionError); + emit compressionFailed(sessionError.isEmpty() ? tr("No chat agent selected") : sessionError); return; } @@ -117,11 +116,13 @@ void ChatCompressor::startCompression( 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::finished, this, - [this](const LLMQore::RequestID &id, const QString &) { onCompressionFinished(id); }); - connect( - session, &Session::failed, this, + session, + &Session::failed, + this, [this](const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error) { onCompressionFailed(id, error.message); }); @@ -134,8 +135,8 @@ void ChatCompressor::startCompression( m_currentRequestId = session->send(std::move(blocks)); if (m_currentRequestId.isEmpty()) { - handleCompressionError(tr("Failed to start compression request: %1") - .arg(session->lastError().message)); + handleCompressionError( + tr("Failed to start compression request: %1").arg(session->lastError().message)); return; } LOG_MESSAGE(QString("Starting compression request: %1").arg(m_currentRequestId)); @@ -169,6 +170,11 @@ void ChatCompressor::onCompressionFinished(const QString &requestId) LOG_MESSAGE(QString("Received summary, length: %1 characters").arg(summary.length())); + if (summary.trimmed().isEmpty()) { + handleCompressionError(tr("Compression produced an empty summary")); + return; + } + const QString compressedPath = createCompressedChatPath(m_originalChatPath); const QString sourcePath = m_originalChatPath; @@ -209,23 +215,8 @@ QString ChatCompressor::createCompressedChatPath(const QString &originalPath) co bool ChatCompressor::createCompressedChatFile( const QString &sourcePath, const QString &destPath, const QString &summary) { - QFile sourceFile(sourcePath); - if (!sourceFile.open(QIODevice::ReadOnly)) { - LOG_MESSAGE(QString("Failed to open source chat file: %1").arg(sourcePath)); - return false; - } - - QJsonParseError parseError; - QJsonDocument doc = QJsonDocument::fromJson(sourceFile.readAll(), &parseError); - sourceFile.close(); - - if (doc.isNull() || !doc.isObject()) { - LOG_MESSAGE(QString("Invalid JSON in chat file: %1 (Error: %2)") - .arg(sourcePath, parseError.errorString())); - return false; - } - - QJsonObject root = doc.object(); + QJsonObject root; + root["version"] = ChatSerializer::VERSION; QJsonObject summaryMessage; summaryMessage["role"] = "assistant"; diff --git a/ChatView/ChatCompressor.hpp b/ChatView/ChatCompressor.hpp index bedfda1..b773632 100644 --- a/ChatView/ChatCompressor.hpp +++ b/ChatView/ChatCompressor.hpp @@ -13,7 +13,7 @@ namespace QodeAssist { class SessionManager; class Session; class ConversationHistory; -} +} // namespace QodeAssist namespace QodeAssist::Chat { diff --git a/ChatView/ChatHistoryStore.cpp b/ChatView/ChatHistoryStore.cpp index c23909e..f86be25 100644 --- a/ChatView/ChatHistoryStore.cpp +++ b/ChatView/ChatHistoryStore.cpp @@ -22,14 +22,17 @@ #include +#include "ChatModel.hpp" #include "Logger.hpp" #include "ProjectSettings.hpp" namespace QodeAssist::Chat { -ChatHistoryStore::ChatHistoryStore(ConversationHistory *history, QObject *parent) +ChatHistoryStore::ChatHistoryStore( + ConversationHistory *history, ChatModel *chatModel, QObject *parent) : QObject(parent) , m_history(history) + , m_chatModel(chatModel) {} QString ChatHistoryStore::historyDir() const @@ -118,12 +121,17 @@ QString ChatHistoryStore::autosaveFilePath( SerializationResult ChatHistoryStore::save(const QString &filePath) const { - return ChatSerializer::saveToFile(m_history, filePath); + return ChatSerializer::saveToFile( + m_history, filePath, m_chatModel ? m_chatModel->usageToJson() : QJsonObject{}); } SerializationResult ChatHistoryStore::load(const QString &filePath) const { - return ChatSerializer::loadFromFile(m_history, filePath); + QJsonObject usage; + const SerializationResult result = ChatSerializer::loadFromFile(m_history, filePath, &usage); + if (result.success && m_chatModel) + m_chatModel->restoreUsageFromJson(usage); + return result; } void ChatHistoryStore::showSaveDialog() diff --git a/ChatView/ChatHistoryStore.hpp b/ChatView/ChatHistoryStore.hpp index 8e00a92..3404c3b 100644 --- a/ChatView/ChatHistoryStore.hpp +++ b/ChatView/ChatHistoryStore.hpp @@ -15,12 +15,15 @@ class ConversationHistory; namespace QodeAssist::Chat { +class ChatModel; + class ChatHistoryStore : public QObject { Q_OBJECT public: - explicit ChatHistoryStore(ConversationHistory *history, QObject *parent = nullptr); + explicit ChatHistoryStore( + ConversationHistory *history, ChatModel *chatModel, QObject *parent = nullptr); QString historyDir() const; QString suggestedFileName() const; @@ -45,6 +48,7 @@ private: QString generateChatFileName(const QString &shortMessage, const QString &dir) const; ConversationHistory *m_history; + ChatModel *m_chatModel; }; } // namespace QodeAssist::Chat diff --git a/ChatView/ChatModel.cpp b/ChatView/ChatModel.cpp index d0b2f95..0624b9d 100644 --- a/ChatView/ChatModel.cpp +++ b/ChatView/ChatModel.cpp @@ -28,10 +28,14 @@ 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"); + 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"); } @@ -80,17 +84,25 @@ ChatModel::ChatModel(QObject *parent) { auto &changes = Context::ChangesManager::instance(); connect( - &changes, &Context::ChangesManager::fileEditApplied, - this, &ChatModel::onFileEditStatusChanged); + &changes, + &Context::ChangesManager::fileEditApplied, + this, + &ChatModel::onFileEditStatusChanged); connect( - &changes, &Context::ChangesManager::fileEditRejected, - this, &ChatModel::onFileEditStatusChanged); + &changes, + &Context::ChangesManager::fileEditRejected, + this, + &ChatModel::onFileEditStatusChanged); connect( - &changes, &Context::ChangesManager::fileEditUndone, - this, &ChatModel::onFileEditStatusChanged); + &changes, + &Context::ChangesManager::fileEditUndone, + this, + &ChatModel::onFileEditStatusChanged); connect( - &changes, &Context::ChangesManager::fileEditArchived, - this, &ChatModel::onFileEditStatusChanged); + &changes, + &Context::ChangesManager::fileEditArchived, + this, + &ChatModel::onFileEditStatusChanged); } void ChatModel::setHistory(ConversationHistory *history) @@ -105,11 +117,12 @@ void ChatModel::setHistory(ConversationHistory *history) if (m_history) { connect( - m_history, &ConversationHistory::messageAdded, - this, &ChatModel::onHistoryMessageAdded); + m_history, &ConversationHistory::messageAdded, this, &ChatModel::onHistoryMessageAdded); connect( - m_history, &ConversationHistory::messageUpdated, - this, &ChatModel::onHistoryMessageUpdated); + m_history, + &ConversationHistory::messageUpdated, + this, + &ChatModel::onHistoryMessageUpdated); connect(m_history, &ConversationHistory::cleared, this, &ChatModel::onHistoryCleared); connect(m_history, &ConversationHistory::reset, this, &ChatModel::onHistoryReset); } @@ -244,8 +257,7 @@ QString ChatModel::overlayFileEditStatus(const QString &content, const QString & obj["status_message"] = edit.statusMessage; } } - return kFileEditMarker - + QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)); + return kFileEditMarker + QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)); } QHash ChatModel::buildToolResultMap() const @@ -328,8 +340,7 @@ void ChatModel::appendRowsForMessage( row.messageId = id; row.content = content; out.append(std::move(row)); - } else if ( - auto *rth = dynamic_cast(block.get())) { + } 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) @@ -644,6 +655,36 @@ void ChatModel::setMessageUsage( emit sessionUsageChanged(); } +QJsonObject ChatModel::usageToJson() const +{ + QJsonObject out; + for (auto it = m_usageByMessageId.cbegin(); it != m_usageByMessageId.cend(); ++it) { + const Usage &u = it.value(); + if (u.prompt == 0 && u.completion == 0 && u.cached == 0 && u.reasoning == 0) + continue; + QJsonObject entry; + entry["prompt"] = u.prompt; + entry["completion"] = u.completion; + entry["cached"] = u.cached; + entry["reasoning"] = u.reasoning; + out.insert(it.key(), entry); + } + return out; +} + +void ChatModel::restoreUsageFromJson(const QJsonObject &usage) +{ + for (auto it = usage.constBegin(); it != usage.constEnd(); ++it) { + const QJsonObject entry = it.value().toObject(); + setMessageUsage( + it.key(), + entry.value("prompt").toInt(), + entry.value("completion").toInt(), + entry.value("cached").toInt(), + entry.value("reasoning").toInt()); + } +} + int ChatModel::sessionPromptTokens() const { int total = 0; diff --git a/ChatView/ChatModel.hpp b/ChatView/ChatModel.hpp index 57bf90b..2fcff92 100644 --- a/ChatView/ChatModel.hpp +++ b/ChatView/ChatModel.hpp @@ -26,7 +26,8 @@ class ChatModel : public QAbstractListModel Q_OBJECT 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 sessionCachedPromptTokens READ sessionCachedPromptTokens NOTIFY sessionUsageChanged FINAL) QML_ELEMENT public: @@ -67,6 +68,9 @@ public: int cachedPromptTokens, int reasoningTokens); + QJsonObject usageToJson() const; + void restoreUsageFromJson(const QJsonObject &usage); + int sessionPromptTokens() const; int sessionCompletionTokens() const; int sessionCachedPromptTokens() const; diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index a57b5ac..da3b3a2 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -41,12 +41,12 @@ #include "FileEditController.hpp" #include "GeneralSettings.hpp" #include "InputTokenCounter.hpp" -#include "SettingsConstants.hpp" #include "Logger.hpp" -#include "SessionFileRegistry.hpp" -#include "context/ContextManager.hpp" #include "ProjectSettings.hpp" +#include "SessionFileRegistry.hpp" +#include "SettingsConstants.hpp" #include "SkillsSettings.hpp" +#include "context/ContextManager.hpp" #include "sources/skills/SkillsManager.hpp" namespace QodeAssist::Chat { @@ -85,7 +85,7 @@ ChatRootView::ChatRootView(QQuickItem *parent) , 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_historyStore(new ChatHistoryStore(m_history, m_chatModel, this)) { m_chatModel->setHistory(m_history); m_clientInterface->setHistory(m_history); @@ -145,7 +145,6 @@ ChatRootView::ChatRootView(QQuickItem *parent) connect(m_chatModel, &QAbstractItemModel::modelReset, this, maybeEmitTitle); connect(m_chatModel, &QAbstractItemModel::rowsInserted, this, maybeEmitTitle); connect(m_chatModel, &QAbstractItemModel::rowsRemoved, this, maybeEmitTitle); - connect(m_chatModel, &QAbstractItemModel::dataChanged, this, maybeEmitTitle); connect(this, &ChatRootView::attachmentFilesChanged, this, [this]() { m_tokenCounter->setAttachments(m_attachmentFiles); }); @@ -174,6 +173,14 @@ ChatRootView::ChatRootView(QQuickItem *parent) &ChatAgentController::currentAgentChanged, this, &ChatRootView::useToolsChanged); + connect( + Settings::PipelinesNotifier::instance(), + &Settings::PipelinesNotifier::pipelinesChanged, + this, + [this]() { + emit availableChatAgentsChanged(); + emit useToolsChanged(); + }); auto editors = Core::EditorManager::instance(); @@ -223,10 +230,14 @@ ChatRootView::ChatRootView(QQuickItem *parent) }); connect( - m_clientInterface, - &ClientInterface::requestStarted, - this, - [this](const QString &requestId) { m_fileEditController->setCurrentRequestId(requestId); }); + m_clientInterface, &ClientInterface::requestStarted, this, [this](const QString &requestId) { + m_fileEditController->setCurrentRequestId(requestId); + setRequestProgressStatus(true); + }); + + connect(m_clientInterface, &ClientInterface::requestCancelled, this, [this]() { + setRequestProgressStatus(false); + }); connect( m_clientInterface, @@ -428,6 +439,11 @@ ChatModel *ChatRootView::chatModel() const void ChatRootView::sendMessage(const QString &message) { + if (message.trimmed().isEmpty() && m_attachmentFiles.isEmpty()) + return; + if (m_chatCompressor->isCompressing()) + return; + const QStringList attachments = m_attachmentFiles; const QStringList linkedFiles = m_linkedFiles; @@ -438,9 +454,7 @@ void ChatRootView::sendMessage(const QString &message) } bool ChatRootView::deferSendForAutoCompress( - const QString &message, - const QStringList &attachments, - const QStringList &linkedFiles) + const QString &message, const QStringList &attachments, const QStringList &linkedFiles) { auto &settings = Settings::chatAssistantSettings(); if (!settings.autoCompress()) @@ -456,6 +470,9 @@ bool ChatRootView::deferSendForAutoCompress( if (m_recentFilePath.isEmpty()) { QString filePath = getAutosaveFilePath(message, attachments); + if (auto registry = sessionFileRegistry()) { + filePath = registry->uniqueFreePath(filePath); + } if (filePath.isEmpty()) return false; setRecentFilePath(filePath); @@ -475,9 +492,7 @@ bool ChatRootView::deferSendForAutoCompress( } void ChatRootView::dispatchSend( - const QString &message, - const QStringList &attachments, - const QStringList &linkedFiles) + const QString &message, const QStringList &attachments, const QStringList &linkedFiles) { if (m_recentFilePath.isEmpty()) { QString filePath = getAutosaveFilePath(message, attachments); @@ -500,7 +515,6 @@ void ChatRootView::dispatchSend( m_fileManager->clearIntermediateStorage(); clearAttachmentFiles(); - setRequestProgressStatus(true); } void ChatRootView::copyToClipboard(const QString &text) @@ -568,10 +582,17 @@ void ChatRootView::loadHistory(const QString &filePath) return; } + m_clientInterface->cancelRequest(); + auto result = m_historyStore->load(filePath); if (!result.success) { LOG_MESSAGE(QString("Failed to load chat history: %1").arg(result.errorMessage)); } else { + if (!result.errorMessage.isEmpty()) { + m_lastInfoMessage = result.errorMessage; + emit lastInfoMessageChanged(); + LOG_MESSAGE(QString("Chat history loaded with issues: %1").arg(result.errorMessage)); + } setRecentFilePath(filePath); } @@ -591,6 +612,12 @@ void ChatRootView::showSaveDialog() m_historyStore->showSaveDialog(); } +void ChatRootView::resetChatTo(int index) +{ + m_clientInterface->cancelRequest(); + m_chatModel->resetModelTo(index); +} + void ChatRootView::showLoadDialog() { m_historyStore->showLoadDialog(); @@ -939,8 +966,6 @@ void ChatRootView::relocateToWindow() clearAttachmentFiles(); emit closeHostRequested(); - // Closing the source split raises the main window; re-raise the chat window once that - // queued teardown has run. The registry outlives this view, which the split close deletes. if (auto registry = sessionFileRegistry()) { QMetaObject::invokeMethod( registry, @@ -1210,7 +1235,13 @@ void ChatRootView::compressCurrentChat() if (compressionAgent.isEmpty()) return; - autosave(); + const auto saveResult = m_historyStore->save(m_recentFilePath); + if (!saveResult.success) { + m_lastErrorMessage + = tr("Failed to save chat before compression: %1").arg(saveResult.errorMessage); + emit lastErrorMessageChanged(); + return; + } m_chatCompressor->setSessionManager(sessionManager()); m_chatCompressor->setActiveAgent(compressionAgent); diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index 3175027..ace2e9d 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -22,7 +22,7 @@ class AgentFactory; class SessionManager; class ConversationHistory; class Session; -} +} // namespace QodeAssist namespace QodeAssist::Chat { @@ -57,8 +57,12 @@ class ChatRootView : public QQuickItem 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(QStringList availableChatAgents READ availableChatAgents NOTIFY availableChatAgentsChanged FINAL) - Q_PROPERTY(QString currentChatAgent READ currentChatAgent WRITE setCurrentChatAgent NOTIFY currentChatAgentChanged 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) @@ -77,6 +81,7 @@ public: Q_INVOKABLE void showSaveDialog(); Q_INVOKABLE void showLoadDialog(); + Q_INVOKABLE void resetChatTo(int index); void autosave(); QString getAutosaveFilePath() const; @@ -220,13 +225,9 @@ private: void triggerOpenChatCommand(Utils::Id commandId); void handOffSession(); bool deferSendForAutoCompress( - const QString &message, - const QStringList &attachments, - const QStringList &linkedFiles); + const QString &message, const QStringList &attachments, const QStringList &linkedFiles); void dispatchSend( - const QString &message, - const QStringList &attachments, - const QStringList &linkedFiles); + const QString &message, const QStringList &attachments, const QStringList &linkedFiles); QString configuredCompressionAgent() const; bool hasImageAttachments(const QStringList &attachments) const; diff --git a/ChatView/ChatSerializer.cpp b/ChatView/ChatSerializer.cpp index 75ec656..e53c6e0 100644 --- a/ChatView/ChatSerializer.cpp +++ b/ChatView/ChatSerializer.cpp @@ -29,7 +29,6 @@ namespace { 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) @@ -51,8 +50,21 @@ void registerEditFromResult(const QString &result) filePath, obj.value("old_content").toString(), obj.value("new_content").toString(), - /*autoApply=*/false, - /*isFromHistory=*/true); + false, + true); +} + +void appendMergingAssistants(std::vector &out, Message message) +{ + if (message.role() == Message::Role::Assistant && !out.empty() + && out.back().role() == Message::Role::Assistant) { + if (out.back().id().isEmpty() && !message.id().isEmpty()) + out.back().setId(message.id()); + for (auto &block : message.takeBlocks()) + out.back().appendBlock(std::move(block)); + return; + } + out.push_back(std::move(message)); } } // namespace @@ -60,7 +72,7 @@ void registerEditFromResult(const QString &result) const QString ChatSerializer::VERSION = "0.3"; SerializationResult ChatSerializer::saveToFile( - const ConversationHistory *history, const QString &filePath) + const ConversationHistory *history, const QString &filePath, const QJsonObject &usage) { if (!history) return {false, "No conversation history"}; @@ -74,7 +86,7 @@ SerializationResult ChatSerializer::saveToFile( return {false, QString("Failed to open file for writing: %1").arg(filePath)}; } - QJsonDocument doc(serializeChat(history)); + QJsonDocument doc(serializeChat(history, usage)); if (file.write(doc.toJson(QJsonDocument::Indented)) == -1) { return {false, QString("Failed to write to file: %1").arg(file.errorString())}; } @@ -83,7 +95,7 @@ SerializationResult ChatSerializer::saveToFile( } SerializationResult ChatSerializer::loadFromFile( - ConversationHistory *history, const QString &filePath) + ConversationHistory *history, const QString &filePath, QJsonObject *usageOut) { if (!history) return {false, "No conversation history"}; @@ -106,11 +118,12 @@ SerializationResult ChatSerializer::loadFromFile( } if (version == VERSION) - return loadCurrent(history, root); - return loadLegacy(history, root); + return loadCurrent(history, root, usageOut); + return loadLegacy(history, root, usageOut); } -QJsonObject ChatSerializer::serializeChat(const ConversationHistory *history) +QJsonObject ChatSerializer::serializeChat( + const ConversationHistory *history, const QJsonObject &usage) { QJsonArray messagesArray; for (const auto &message : history->messages()) @@ -119,29 +132,57 @@ QJsonObject ChatSerializer::serializeChat(const ConversationHistory *history) QJsonObject root; root["version"] = VERSION; root["messages"] = messagesArray; + if (!usage.isEmpty()) + root["usage"] = usage; return root; } -SerializationResult ChatSerializer::loadCurrent(ConversationHistory *history, const QJsonObject &root) +SerializationResult ChatSerializer::loadCurrent( + ConversationHistory *history, const QJsonObject &root, QJsonObject *usageOut) { history->clear(); + int skipped = 0; 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)); + else + ++skipped; } + if (usageOut) + *usageOut = root["usage"].toObject(); + registerHistoricalFileEdits(history); + if (skipped > 0) { + return {true, QString("%1 message(s) could not be parsed and were skipped").arg(skipped)}; + } return {true, QString()}; } -SerializationResult ChatSerializer::loadLegacy(ConversationHistory *history, const QJsonObject &root) +SerializationResult ChatSerializer::loadLegacy( + ConversationHistory *history, const QJsonObject &root, QJsonObject *usageOut) { history->clear(); + QJsonObject usage; + const auto collectUsage = [&usage](const QJsonObject &mj) { + const QString id = mj["id"].toString(); + const QJsonObject legacyUsage = mj["usage"].toObject(); + if (id.isEmpty() || legacyUsage.isEmpty()) + return; + QJsonObject entry; + entry["prompt"] = legacyUsage["promptTokens"].toInt(); + entry["completion"] = legacyUsage["completionTokens"].toInt(); + entry["cached"] = legacyUsage["cachedPromptTokens"].toInt(); + entry["reasoning"] = legacyUsage["reasoningTokens"].toInt(); + usage.insert(id, entry); + }; + + std::vector merged; const QJsonArray arr = root["messages"].toArray(); int i = 0; while (i < arr.size()) { @@ -152,21 +193,23 @@ SerializationResult ChatSerializer::loadLegacy(ConversationHistory *history, con Message assistant(Message::Role::Assistant); Message toolResults(Message::Role::User); while (i < arr.size() - && static_cast(arr[i].toObject()["role"].toInt()) == LegacyRole::Tool) { + && 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())); + 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)); + appendMergingAssistants(merged, std::move(assistant)); + merged.push_back(std::move(toolResults)); } continue; } @@ -174,22 +217,21 @@ SerializationResult ChatSerializer::loadLegacy(ConversationHistory *history, con ++i; if (role == LegacyRole::FileEdit) - continue; // derived from the tool result in the new model + continue; 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)); + 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)); + appendMergingAssistants(merged, std::move(assistant)); continue; } @@ -198,29 +240,41 @@ SerializationResult ChatSerializer::loadLegacy(ConversationHistory *history, con 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())); + 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())); + user.appendBlock( + std::make_unique( + io["fileName"].toString(), + io["storedPath"].toString(), + io["mediaType"].toString())); } - history->append(std::move(user)); + merged.push_back(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; + 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)); + collectUsage(mj); + if (mapped == Message::Role::Assistant) + appendMergingAssistants(merged, std::move(message)); + else + merged.push_back(std::move(message)); } } + for (auto &message : merged) + history->append(std::move(message)); + + if (usageOut) + *usageOut = usage; + registerHistoricalFileEdits(history); return {true, QString()}; } diff --git a/ChatView/ChatSerializer.hpp b/ChatView/ChatSerializer.hpp index 37cd528..dad586d 100644 --- a/ChatView/ChatSerializer.hpp +++ b/ChatView/ChatSerializer.hpp @@ -33,11 +33,13 @@ using StoredContentCache = QHash; class ChatSerializer { public: - static SerializationResult saveToFile( - const ConversationHistory *history, const QString &filePath); - static SerializationResult loadFromFile(ConversationHistory *history, const QString &filePath); + static const QString VERSION; + + static SerializationResult saveToFile( + const ConversationHistory *history, const QString &filePath, const QJsonObject &usage = {}); + static SerializationResult loadFromFile( + ConversationHistory *history, const QString &filePath, QJsonObject *usageOut = nullptr); - // Content management (images and text files) static QString getChatContentFolder(const QString &chatFilePath); static bool saveContentToStorage( const QString &chatFilePath, @@ -45,16 +47,14 @@ public: const QString &base64Data, QString &storedPath); static QString loadContentFromStorage( - const QString &chatFilePath, - const QString &storedPath, - StoredContentCache *cache = nullptr); + const QString &chatFilePath, const QString &storedPath, StoredContentCache *cache = nullptr); private: - static const QString VERSION; - - static QJsonObject serializeChat(const ConversationHistory *history); - static SerializationResult loadCurrent(ConversationHistory *history, const QJsonObject &root); - static SerializationResult loadLegacy(ConversationHistory *history, const QJsonObject &root); + static QJsonObject serializeChat(const ConversationHistory *history, const QJsonObject &usage); + static SerializationResult loadCurrent( + ConversationHistory *history, const QJsonObject &root, QJsonObject *usageOut); + static SerializationResult loadLegacy( + ConversationHistory *history, const QJsonObject &root, QJsonObject *usageOut); static void registerHistoricalFileEdits(const ConversationHistory *history); static bool ensureDirectoryExists(const QString &filePath); diff --git a/ChatView/ClientInterface.cpp b/ChatView/ClientInterface.cpp index 287dba1..afe951a 100644 --- a/ChatView/ClientInterface.cpp +++ b/ChatView/ClientInterface.cpp @@ -32,8 +32,8 @@ #include #include -#include #include +#include #include #include #include @@ -122,6 +122,9 @@ void ClientInterface::ensureSession() [this](const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error) { onSessionFailed(id, error); }); + connect(m_session, &Session::cancelled, this, [this](const LLMQore::RequestID &id) { + onSessionCancelled(id); + }); } bool ClientInterface::ensureAgentBound() @@ -147,9 +150,7 @@ bool ClientInterface::ensureAgentBound() } void ClientInterface::sendMessage( - const QString &message, - const QList &attachments, - const QList &linkedFiles) + const QString &message, const QList &attachments, const QList &linkedFiles) { if (message.trimmed().isEmpty() && attachments.isEmpty()) { LOG_MESSAGE("Ignoring empty chat message"); @@ -299,13 +300,13 @@ void ClientInterface::sendMessage( } for (const auto &image : storedImages) { - blocks.push_back(std::make_unique( - image.fileName, image.storedPath, image.mediaType)); + 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"))) { + if (auto *todoTool = qobject_cast( + client->tools()->tool("todo_tool"))) { todoTool->setCurrentSessionId(m_chatFilePath); } if (auto *historyTool = qobject_cast( @@ -396,14 +397,19 @@ void ClientInterface::onSessionFailed(const QString &requestId, const QodeAssist m_activeRequests.erase(it); } +void ClientInterface::onSessionCancelled(const QString &requestId) +{ + m_activeRequests.remove(requestId); + emit requestCancelled(); +} + 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-]*)")); + 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); @@ -415,16 +421,16 @@ QStringList ClientInterface::invokedSkillNames(const QString &message) const QString ClientInterface::buildChatContextLayer() const { - QString context - = Context::EnvBlockFormatter::formatProject(Context::EnvBlockFormatter::currentProject()); + 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()); + projectSkillDirs = Settings::SkillsSettings::splitLines( + projectSettings.projectSkillDirs()); } m_skillsManager->configure( project ? project->projectDirectory().toFSPathString() : QString(), diff --git a/ChatView/ClientInterface.hpp b/ChatView/ClientInterface.hpp index 3110fda..43a882c 100644 --- a/ChatView/ClientInterface.hpp +++ b/ChatView/ClientInterface.hpp @@ -22,7 +22,7 @@ namespace QodeAssist { class SessionManager; class Session; class ConversationHistory; -} +} // namespace QodeAssist namespace QodeAssist::Skills { class SkillsManager; @@ -62,6 +62,7 @@ signals: void errorOccurred(const QString &error); void messageReceivedCompletely(); void requestStarted(const QString &requestId); + void requestCancelled(); void messageUsageReceived( int promptTokens, int completionTokens, int cachedPromptTokens, int reasoningTokens); @@ -71,6 +72,7 @@ private: void onSessionEvent(Session *session, const QodeAssist::ResponseEvent &ev); void onSessionFinished(const QString &requestId); void onSessionFailed(const QString &requestId, const QodeAssist::ErrorInfo &error); + void onSessionCancelled(const QString &requestId); QStringList invokedSkillNames(const QString &message) const; QString buildChatContextLayer() const; diff --git a/ChatView/InputTokenCounter.cpp b/ChatView/InputTokenCounter.cpp index 05f9573..c256023 100644 --- a/ChatView/InputTokenCounter.cpp +++ b/ChatView/InputTokenCounter.cpp @@ -87,7 +87,7 @@ void InputTokenCounter::recompute() if (m_history) { for (const auto &message : m_history->messages()) { inputTokens += Context::TokenUtils::estimateTokens(message.text()); - inputTokens += 4; // + role + inputTokens += 4; } } diff --git a/ChatView/SessionFileRegistry.hpp b/ChatView/SessionFileRegistry.hpp index ca0d8de..129e093 100644 --- a/ChatView/SessionFileRegistry.hpp +++ b/ChatView/SessionFileRegistry.hpp @@ -15,9 +15,6 @@ class Session; namespace QodeAssist::Chat { -// Shared registry mapping each chat (autosave) file to the live Session that owns it, so a -// file is busy only while its owning Session is alive (a destroyed Session frees it — the -// QPointer goes null). Keeps two chat views from autosaving into the same path. class SessionFileRegistry : public QObject { Q_OBJECT @@ -33,8 +30,6 @@ public: QString uniqueFreePath(const QString &desiredPath) const; - // Handoff slot for relocating a live chat between hosts (split <-> window): the source - // chat stores its history file here, the freshly created host picks it up exactly once. void setPendingChatFile(const QString &path); QString takePendingChatFile(); diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 3314dd4..f56547c 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -317,7 +317,7 @@ ChatRootView { onResetChatToMessage: function(idx) { messageInput.text = model.content messageInput.cursorPosition = model.content.length - root.chatModel.resetModelTo(idx) + root.resetChatTo(idx) } onOpenFileRequested: function(filePath) { @@ -659,6 +659,10 @@ ChatRootView { } function sendChatMessage() { + if (root.isCompressing) + return + if (messageInput.text.trim() === "" && root.attachmentFiles.length === 0) + return root.hasActiveError = false root.sendMessage(fileMentionPopup.expandMentions(messageInput.text)) messageInput.text = "" diff --git a/ChatView/qml/controls/BottomBar.qml b/ChatView/qml/controls/BottomBar.qml index b1740b1..bbdc26c 100644 --- a/ChatView/qml/controls/BottomBar.qml +++ b/ChatView/qml/controls/BottomBar.qml @@ -185,7 +185,7 @@ Rectangle { id: sendButtonId anchors.fill: parent - enabled: root.isProcessing || root.canSend + enabled: root.isProcessing || (root.canSend && !root.isCompressing) leftPadding: root.isProcessing ? 22 : 4 icon { diff --git a/LLMClientInterface.cpp b/LLMClientInterface.cpp index 36ea5a6..9f8abf7 100644 --- a/LLMClientInterface.cpp +++ b/LLMClientInterface.cpp @@ -8,8 +8,6 @@ #include #include #include -#include -#include #include #include @@ -59,7 +57,7 @@ LLMClientInterface::LLMClientInterface( LLMClientInterface::~LLMClientInterface() { - handleCancelRequest(); + cancelAllRequests(); } Utils::FilePath LLMClientInterface::serverDeviceTemplate() const @@ -144,7 +142,7 @@ void LLMClientInterface::sendData(const QByteArray &data) } else if (method == "getCompletionsCycling") { handleCompletion(request); } else if (method == "$/cancelRequest") { - handleCancelRequest(); + handleCancelRequest(request); } else if (method == "exit") { // TODO make exit handler } else { @@ -152,7 +150,40 @@ void LLMClientInterface::sendData(const QByteArray &data) } } -void LLMClientInterface::handleCancelRequest() +void LLMClientInterface::handleCancelRequest(const QJsonObject &request) +{ + const QJsonValue lspId = request["params"].toObject()["id"]; + + QString matchedKey; + for (auto it = m_activeRequests.cbegin(); it != m_activeRequests.cend(); ++it) { + if (it.value().originalRequest["id"] == lspId) { + matchedKey = it.key(); + break; + } + } + if (matchedKey.isEmpty()) { + LOG_MESSAGE(QString("No active completion request to cancel for LSP id %1") + .arg(lspId.toVariant().toString())); + return; + } + + finishRequest(matchedKey); + + QJsonObject response; + response["jsonrpc"] = "2.0"; + response[LanguageServerProtocol::idKey] = lspId; + + QJsonObject errorObject; + errorObject["code"] = -32800; + errorObject["message"] = QStringLiteral("Request cancelled"); + response["error"] = errorObject; + + emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); + + LOG_MESSAGE(QString("Cancelled completion request %1").arg(matchedKey)); +} + +void LLMClientInterface::cancelAllRequests() { const auto requests = m_activeRequests; m_activeRequests.clear(); @@ -213,10 +244,6 @@ void LLMClientInterface::sendErrorResponse(const QJsonObject &request, const QSt response["error"] = errorObject; emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); - - // End performance measurement if it was started - QString requestId = request["id"].toString(); - m_performanceLogger.endTimeMeasurement(requestId); } void LLMClientInterface::handleCompletion(const QJsonObject &request) @@ -404,8 +431,6 @@ void LLMClientInterface::sendCompletionToClient( QString("Full response: \n%1") .arg(QString::fromUtf8(QJsonDocument(response).toJson(QJsonDocument::Indented)))); - QString requestId = request["id"].toString(); - m_performanceLogger.endTimeMeasurement(requestId); emit messageReceived(LanguageServerProtocol::JsonRpcMessage(response)); } diff --git a/LLMClientInterface.hpp b/LLMClientInterface.hpp index 9973769..11045d4 100644 --- a/LLMClientInterface.hpp +++ b/LLMClientInterface.hpp @@ -17,9 +17,6 @@ #include #include -class QNetworkReply; -class QNetworkAccessManager; - namespace QodeAssist { class AgentFactory; @@ -51,7 +48,6 @@ public: void handleCompletion(const QJsonObject &request); - // exposed for tests void sendData(const QByteArray &data) override; Context::ContextManager *contextManager() const; @@ -63,7 +59,8 @@ private: void handleInitialize(const QJsonObject &request); void handleShutdown(const QJsonObject &request); void handleTextDocumentDidOpen(const QJsonObject &request); - void handleCancelRequest(); + void handleCancelRequest(const QJsonObject &request); + void cancelAllRequests(); void sendErrorResponse(const QJsonObject &request, const QString &errorMessage); void onCompletionFinished(const QString &requestId); @@ -82,13 +79,12 @@ private: QString pickCompletionAgent(const QString &filePath) const; - const Settings::CodeCompletionSettings &m_completeSettings; const Settings::GeneralSettings &m_generalSettings; + const Settings::CodeCompletionSettings &m_completeSettings; AgentFactory &m_agentFactory; SessionManager &m_sessionManager; Context::IDocumentReader &m_documentReader; IRequestPerformanceLogger &m_performanceLogger; - QElapsedTimer m_completionTimer; Context::ContextManager *m_contextManager; QHash m_activeRequests; }; diff --git a/QodeAssistClient.cpp b/QodeAssistClient.cpp index 0b45f1a..d9f35e4 100644 --- a/QodeAssistClient.cpp +++ b/QodeAssistClient.cpp @@ -272,7 +272,6 @@ void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor) if (!isEnabled(project)) return; - if (m_llmClient->contextManager()->shouldIgnore( editor->textDocument()->filePath().toUrlishString())) { LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1") @@ -306,6 +305,12 @@ void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor) QTC_ASSERT(editor, return); handleCompletions(response, editor); }); + connect( + editor, + &TextEditorWidget::destroyed, + this, + &QodeAssistClient::onEditorDestroyed, + Qt::UniqueConnection); m_runningRequests[editor] = request; sendMessage(request); } @@ -367,10 +372,12 @@ void QodeAssistClient::scheduleRequest(TextEditor::TextEditorWidget *editor) return; requestCompletions(editor); }); - connect(editor, &TextEditorWidget::destroyed, this, [this, editor]() { - delete m_scheduledRequests.take(editor); - cancelRunningRequest(editor); - }); + connect( + editor, + &TextEditorWidget::destroyed, + this, + &QodeAssistClient::onEditorDestroyed, + Qt::UniqueConnection); it = m_scheduledRequests.insert(editor, timer); } @@ -386,9 +393,12 @@ void QodeAssistClient::handleCompletions( editor->abortAssist(); if (response.error()) { + m_runningRequests.remove(editor); log(*response.error()); - m_errorHandler - .showError(editor, tr("Code completion failed: %1").arg(response.error()->message())); + if (response.error()->code() != -32800) { + m_errorHandler + .showError(editor, tr("Code completion failed: %1").arg(response.error()->message())); + } return; } @@ -489,6 +499,13 @@ void QodeAssistClient::cancelRunningRequest(TextEditor::TextEditorWidget *editor m_runningRequests.erase(it); } +void QodeAssistClient::onEditorDestroyed(QObject *editorObject) +{ + auto *editor = static_cast(editorObject); + delete m_scheduledRequests.take(editor); + cancelRunningRequest(editor); +} + bool QodeAssistClient::isEnabled(ProjectExplorer::Project *project) const { if (!project) @@ -530,6 +547,11 @@ void QodeAssistClient::handleRefactoringResult(const RefactorResult &result) { m_progressHandler.hideProgress(); + if (result.cancelled) { + LOG_MESSAGE("Refactoring request was cancelled"); + return; + } + if (!result.success) { QString errorMessage = result.errorMessage.isEmpty() ? tr("Quick refactor failed") @@ -548,6 +570,15 @@ void QodeAssistClient::handleRefactoringResult(const RefactorResult &result) return; } + if (result.documentRevision >= 0 + && result.editor->document()->revision() != result.documentRevision) { + m_errorHandler.showError( + result.editor, + tr("Quick refactor discarded: the document changed while the request was running")); + LOG_MESSAGE("Refactoring result discarded: document revision changed"); + return; + } + int displayMode = Settings::quickRefactorSettings().displayMode(); if (displayMode == 0) { @@ -644,7 +675,14 @@ void QodeAssistClient::displayRefactoringWidget(const RefactorResult &result) displayRefactored = result.newText; } - m_refactorWidgetHandler->setApplyCallback([this, editorWidget, result](const QString &editedText) { + const int revisionAtDisplay = editorWidget->document()->revision(); + m_refactorWidgetHandler->setApplyCallback([this, editorWidget, result, revisionAtDisplay]( + const QString &editedText) { + if (editorWidget->document()->revision() != revisionAtDisplay) { + m_errorHandler.showError( + editorWidget, tr("Quick refactor discarded: the document changed before applying")); + return; + } applyRefactoringEdit(editorWidget, result.insertRange, editedText); }); diff --git a/QodeAssistClient.hpp b/QodeAssistClient.hpp index ddb238e..384b908 100644 --- a/QodeAssistClient.hpp +++ b/QodeAssistClient.hpp @@ -12,8 +12,8 @@ #include "LSPCompletion.hpp" #include "QuickRefactorHandler.hpp" #include "RefactorSuggestionHoverHandler.hpp" -#include "widgets/CompletionProgressHandler.hpp" #include "widgets/CompletionErrorHandler.hpp" +#include "widgets/CompletionProgressHandler.hpp" #include "widgets/RefactorWidgetHandler.hpp" #include @@ -47,6 +47,7 @@ private: void handleCompletions( const GetCompletionRequest::Response &response, TextEditor::TextEditorWidget *editor); void cancelRunningRequest(TextEditor::TextEditorWidget *editor); + void onEditorDestroyed(QObject *editorObject); bool isEnabled(ProjectExplorer::Project *project) const; void setupConnections(); diff --git a/QuickRefactorHandler.cpp b/QuickRefactorHandler.cpp index 51c25ba..138cd71 100644 --- a/QuickRefactorHandler.cpp +++ b/QuickRefactorHandler.cpp @@ -10,9 +10,6 @@ #include #include #include -#include -#include -#include #include #include @@ -23,10 +20,10 @@ #include #include #include -#include #include #include #include +#include #include "sources/common/ContextData.hpp" @@ -67,10 +64,11 @@ void QuickRefactorHandler::sendRefactorRequest( TextEditor::TextEditorWidget *editor, const QString &instructions) { if (m_isRefactoringInProgress) { - cancelRequest(); + abortActiveRequest(); } m_currentEditor = editor; + m_currentDocumentRevision = editor->document()->revision(); Utils::Text::Range range; if (editor->textCursor().hasSelection()) { @@ -119,7 +117,7 @@ void QuickRefactorHandler::sendRefactorRequest( QString QuickRefactorHandler::configuredAgent(AgentFactory *agentFactory) { - const QString configured = Settings::PipelinesConfig::load().rosters.quickRefactor; + const QString configured = Settings::PipelinesConfig::loadCached().rosters.quickRefactor; if (configured.isEmpty() || !agentFactory || !agentFactory->configByName(configured)) return {}; return configured; @@ -151,16 +149,17 @@ void QuickRefactorHandler::prepareAndSendRequest( const QString agentName = pickRefactorAgent(); if (agentName.isEmpty()) { - emitError(QStringLiteral( - "No quick refactor agent configured. Set one in QodeAssist > General.")); + emitError( + QStringLiteral("No quick refactor agent configured. Set one in QodeAssist > General.")); return; } QString sessionError; Session *session = m_sessionManager->acquire(agentName, &sessionError); if (!session) { - emitError(sessionError.isEmpty() ? QStringLiteral("No quick refactor agent selected") - : sessionError); + emitError( + sessionError.isEmpty() ? QStringLiteral("No quick refactor agent selected") + : sessionError); return; } @@ -177,34 +176,36 @@ void QuickRefactorHandler::prepareAndSendRequest( bindings.configDir = AgentFactory::userConfigDir(); session->setContextBindings(bindings); - const AgentConfig *agentConfig - = m_agentFactory ? m_agentFactory->configByName(agentName) : nullptr; + 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()); } - session->systemPrompt()->setLayer( - QStringLiteral("refactor"), buildContextLayer(editor, range)); + session->systemPrompt()->setLayer(QStringLiteral("refactor"), buildContextLayer(editor, range)); client->setTransferTimeout( static_cast(Settings::generalSettings().requestTimeout() * 1000)); m_isRefactoringInProgress = true; + connect(session, &Session::finished, this, [this](const LLMQore::RequestID &id, const QString &) { + onRefactorFinished(id); + }); connect( - session, &Session::finished, this, - [this](const LLMQore::RequestID &id, const QString &) { onRefactorFinished(id); }); - connect( - session, &Session::failed, this, + session, + &Session::failed, + this, [this](const LLMQore::RequestID &id, const QodeAssist::ErrorInfo &error) { onRefactorFailed(id, error); }); std::vector> blocks; - const QString userMessage = instructions.isEmpty() - ? QStringLiteral("Refactor the code to improve its quality and maintainability.") - : instructions; + 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)); @@ -218,7 +219,7 @@ void QuickRefactorHandler::prepareAndSendRequest( } m_lastRequestId = requestId; - m_activeRequests[requestId] = {QJsonObject{{"id", requestId}}, session}; + m_activeSession = session; } QString QuickRefactorHandler::buildContextLayer( @@ -312,12 +313,13 @@ QString QuickRefactorHandler::buildContextLayer( contextLayer += "\n# Code Context with Position Markers\n" + taggedContent; contextLayer += "\n\n# What to Generate:"; - contextLayer += cursor.hasSelection() - ? "\n- Generate ONLY the code that should REPLACE the selected text between " - " and markers" - "\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"; + contextLayer + += cursor.hasSelection() + ? "\n- Generate ONLY the code that should REPLACE the selected text between " + " and markers" + "\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"; QString indentNote; if (cursor.hasSelection()) { @@ -329,10 +331,12 @@ QString QuickRefactorHandler::buildContextLayer( else break; } if (leadingSpaces > 0) { - indentNote = QString("\n- CRITICAL: The code to replace starts with %1 spaces of indentation" - "\n- Your output MUST start with exactly %1 spaces (or equivalent tabs)" - "\n- Each line in your output must maintain this base indentation") - .arg(leadingSpaces); + indentNote + = QString( + "\n- CRITICAL: The code to replace starts with %1 spaces of indentation" + "\n- Your output MUST start with exactly %1 spaces (or equivalent tabs)" + "\n- Each line in your output must maintain this base indentation") + .arg(leadingSpaces); } indentNote += "\n- PRESERVE all indentation from the original code"; } else { @@ -345,9 +349,12 @@ QString QuickRefactorHandler::buildContextLayer( else break; } if (leadingSpaces > 0) { - indentNote = QString("\n- CRITICAL: Current line has %1 spaces of indentation" - "\n- If generating multiline code, EVERY line must start with at least %1 spaces" - "\n- If generating single-line code, it will be inserted inline (no indentation needed)") + indentNote = QString( + "\n- CRITICAL: Current line has %1 spaces of indentation" + "\n- If generating multiline code, EVERY line must start with at " + "least %1 spaces" + "\n- If generating single-line code, it will be inserted inline (no " + "indentation needed)") .arg(leadingSpaces); } } @@ -361,26 +368,32 @@ QString QuickRefactorHandler::buildContextLayer( return contextLayer; } +void QuickRefactorHandler::abortActiveRequest() +{ + if (!m_isRefactoringInProgress) + return; + + m_isRefactoringInProgress = false; + m_lastRequestId.clear(); + + Session *session = m_activeSession; + m_activeSession = nullptr; + if (session && m_sessionManager) + m_sessionManager->release(session); +} + void QuickRefactorHandler::cancelRequest() { if (!m_isRefactoringInProgress) return; - const auto id = m_lastRequestId; - m_isRefactoringInProgress = false; - m_lastRequestId.clear(); - - auto it = m_activeRequests.find(id); - if (it != m_activeRequests.end()) { - Session *session = it.value().session; - m_activeRequests.erase(it); - if (session && m_sessionManager) - m_sessionManager->release(session); - } + abortActiveRequest(); RefactorResult result; result.success = false; + result.cancelled = true; result.errorMessage = "Refactoring request was cancelled"; + result.editor = m_currentEditor; emit refactoringCompleted(result); } @@ -389,10 +402,8 @@ void QuickRefactorHandler::onRefactorFinished(const QString &requestId) 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); + Session *session = m_activeSession; + m_activeSession = nullptr; QString fullText; if (session) { @@ -410,6 +421,7 @@ void QuickRefactorHandler::onRefactorFinished(const QString &requestId) result.insertRange = m_currentRange; result.success = true; result.editor = m_currentEditor; + result.documentRevision = m_currentDocumentRevision; LOG_MESSAGE("Refactoring completed successfully. New code to insert: "); LOG_MESSAGE("---------- BEGIN REFACTORED CODE ----------"); @@ -428,10 +440,8 @@ void QuickRefactorHandler::onRefactorFailed( 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); + Session *session = m_activeSession; + m_activeSession = nullptr; m_isRefactoringInProgress = false; m_lastRequestId.clear(); diff --git a/QuickRefactorHandler.hpp b/QuickRefactorHandler.hpp index eeae4ac..a202c56 100644 --- a/QuickRefactorHandler.hpp +++ b/QuickRefactorHandler.hpp @@ -4,7 +4,6 @@ #pragma once -#include #include #include @@ -26,9 +25,11 @@ struct RefactorResult { QString newText; Utils::Text::Range insertRange; - bool success; + bool success = false; + bool cancelled = false; QString errorMessage; QPointer editor; + int documentRevision = -1; }; class QuickRefactorHandler : public QObject @@ -60,23 +61,18 @@ private: void onRefactorFinished(const QString &requestId); void onRefactorFailed(const QString &requestId, const QodeAssist::ErrorInfo &error); - QString buildContextLayer( - TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range); + QString buildContextLayer(TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range); QString pickRefactorAgent() const; - - struct RequestContext - { - QJsonObject originalRequest; - QPointer session; - }; + void abortActiveRequest(); QPointer m_sessionManager; QPointer m_agentFactory; - QHash m_activeRequests; + QPointer m_activeSession; QPointer m_currentEditor; Utils::Text::Range m_currentRange; bool m_isRefactoringInProgress; QString m_lastRequestId; + int m_currentDocumentRevision = -1; Context::ContextManager m_contextManager; }; diff --git a/README.md b/README.md index a4e1a62..3d9207a 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance: - **MCP Server** — expose QodeAssist's project-aware tools to external MCP clients (Claude Code, VS Code, Claude Desktop via bridge) - **MCP Client Hub** — connect QodeAssist to external MCP servers and use their tools in Chat and Quick Refactor (authenticated MCP servers are not supported yet) - **File Context** — attach, link, or auto-sync open editor files for richer prompts -- **Many Providers** — Ollama, llama.cpp, LM Studio (Chat + Responses), Claude, OpenAI (Chat + Responses), Google AI, Mistral, Codestral, OpenRouter, Qwen (OpenAI + Responses), DeepSeek, any OpenAI-compatible endpoint +- **Many Providers** — Ollama, llama.cpp, LM Studio (Chat + Responses), Claude, OpenAI (Chat + Responses), Google AI, Mistral, Codestral, OpenRouter, Qwen, 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-agent personas (agent TOML `system_prompt`), reusable refactor templates, full prompt-template control @@ -157,29 +157,17 @@ For more information, visit the [QodeAssistUpdater repository](https://github.co ## Configuration -### Quick Setup (Recommended for Beginners) +### Quick Setup -The Quick Setup feature provides one-click configuration for popular cloud AI models. Get started in 3 easy steps: -
- Quick setup: (click to expand) - Quick Setup -
+QodeAssist is configured through three settings pages (Preferences > QodeAssist): -1. **Open QodeAssist Settings** -2. **Select a Preset** - Choose from the Quick Setup dropdown: - - **Anthropic Claude** (Sonnet 4.5, Haiku 4.5, Opus 4.5) - - **OpenAI** (gpt-5.2-codex) - - **Mistral AI** (Codestral 2501) - - **Google AI** (Gemini 2.5 Flash) - - **Qwen** (Qwen3.6 Plus, Qwen3.7 Max) - - **DeepSeek** (DeepSeek V4 Flash, DeepSeek V4 Pro) -3. **Configure API Key** - Click "Configure API Key" button and enter your API key in Provider Settings +1. **Providers** — pick a bundled provider instance (Claude, OpenAI, Google AI, Mistral, Ollama, …) and enter its API key. Local providers (Ollama, llama.cpp, LM Studio) need no key. +2. **Agents** — every feature runs an *agent*: a bundled TOML preset combining a provider, model, and request template. The bundled agents work out of the box; create your own by extending a base agent under a new name. +3. **General > Agent Pipelines** — assign agents to the four feature slots: code completion (ordered, routed per file), chat assistant (picker allow-list), chat compression, and quick refactor. Local Ollama agents are pre-assigned by default; an unassigned slot disables that feature. -All settings (provider, model, template, URL) are configured automatically. Just add your API key and you're ready to go! +### Providers -### Manual Provider Configuration - -For advanced users or local models, choose your preferred provider and follow the detailed configuration guide: +Choose your preferred provider: **Local providers:** - **[Ollama](docs/ollama-configuration.md)** — native Ollama API @@ -193,8 +181,8 @@ For advanced users or local models, choose your preferred provider and follow th - **[OpenAI](docs/openai-configuration.md)** — Chat Completions and Responses API - **[Mistral AI](docs/mistral-configuration.md)** / **Codestral** - **[Google AI](docs/google-ai-configuration.md)** — Gemini -- **Qwen (Alibaba)** — DashScope OpenAI-compatible Chat and Responses endpoints -- **DeepSeek** — `deepseek-chat` and `deepseek-reasoner` (reasoning shown as thinking) +- **Qwen (Alibaba)** — DashScope OpenAI-compatible endpoint (`qwen-plus`, `qwen-max`, `qwen-coder`) +- **DeepSeek** — OpenAI-compatible endpoint, `deepseek-chat` and `deepseek-reasoner` - **OpenAI-compatible** — OpenRouter and any custom endpoint ### Recommended Models for Best Experience diff --git a/context/ChangesManager.cpp b/context/ChangesManager.cpp index 8328dc9..8416fb4 100644 --- a/context/ChangesManager.cpp +++ b/context/ChangesManager.cpp @@ -56,14 +56,14 @@ void ChangesManager::addChange( } } -QString ChangesManager::getRecentChangesContext(const TextEditor::TextDocument *currentDocument) const +QString ChangesManager::getRecentChangesContext(const QString ¤tFilePath) const { QString context; for (auto it = m_documentChanges.constBegin(); it != m_documentChanges.constEnd(); ++it) { - if (it.key() != currentDocument) { - for (const auto &change : it.value()) { - context += change.lineContent + "\n"; - } + if (it.key() && it.key()->filePath().toFSPathString() == currentFilePath) + continue; + for (const auto &change : it.value()) { + context += change.lineContent + "\n"; } } return context; diff --git a/context/ChangesManager.h b/context/ChangesManager.h index 8f9e4ad..bf1a044 100644 --- a/context/ChangesManager.h +++ b/context/ChangesManager.h @@ -67,7 +67,7 @@ public: void addChange( TextEditor::TextDocument *document, int position, int charsRemoved, int charsAdded); - QString getRecentChangesContext(const TextEditor::TextDocument *currentDocument) const; + QString getRecentChangesContext(const QString ¤tFilePath) const; void addFileEdit( const QString &editId, @@ -106,9 +106,10 @@ private: ChangesManager &operator=(const ChangesManager &) = delete; QString readFileContent(const QString &filePath) const; - - DiffInfo createDiffInfo(const QString &originalContent, const QString &modifiedContent, const QString &filePath); - + + DiffInfo createDiffInfo( + const QString &originalContent, const QString &modifiedContent, const QString &filePath); + // Helper method for fragment-based apply/undo operations bool performFragmentReplacement( const QString &filePath, diff --git a/context/DocumentContextReader.cpp b/context/DocumentContextReader.cpp index d147d0b..0b5a352 100644 --- a/context/DocumentContextReader.cpp +++ b/context/DocumentContextReader.cpp @@ -263,7 +263,7 @@ Templates::ContextData DocumentContextReader::prepareContext( if (settings.useProjectChangesCache()) fileContext.append("Recent Project Changes Context:\n ") - .append(ChangesManager::instance().getRecentChangesContext(m_textDocument)); + .append(ChangesManager::instance().getRecentChangesContext(m_filePath)); return {.prefix = contextBefore, .suffix = contextAfter, .fileContext = fileContext}; } diff --git a/context/DocumentContextReader.hpp b/context/DocumentContextReader.hpp index 8372446..7dcceb4 100644 --- a/context/DocumentContextReader.hpp +++ b/context/DocumentContextReader.hpp @@ -4,11 +4,10 @@ #pragma once -#include #include -#include #include +#include namespace QodeAssist::Context { @@ -59,7 +58,6 @@ public: int lineNumber, int cursorPosition, const Settings::CodeCompletionSettings &settings) const; private: - TextEditor::TextDocument *m_textDocument = nullptr; QTextDocument *m_document; QString m_mimeType; QString m_filePath; diff --git a/context/EnvBlockFormatter.cpp b/context/EnvBlockFormatter.cpp index 50c0c2f..e98e83d 100644 --- a/context/EnvBlockFormatter.cpp +++ b/context/EnvBlockFormatter.cpp @@ -51,8 +51,8 @@ QString formatProject(const ProjectEnv &env) QString formatFile(const FileEnv &env) { - const QString language - = LanguageServerProtocol::TextDocumentItem::mimeTypeToLanguageId(env.mimeType); + const QString language = LanguageServerProtocol::TextDocumentItem::mimeTypeToLanguageId( + env.mimeType); QString out = QStringLiteral("File information:"); if (!language.isEmpty()) diff --git a/context/ProjectScannerQtCreator.cpp b/context/ProjectScannerQtCreator.cpp index eae22c9..663c5c4 100644 --- a/context/ProjectScannerQtCreator.cpp +++ b/context/ProjectScannerQtCreator.cpp @@ -20,8 +20,7 @@ ProjectScannerQtCreator::ProjectScannerQtCreator() ProjectScannerQtCreator::~ProjectScannerQtCreator() = default; -QList ProjectScannerQtCreator::openedTextFiles( - const QStringList &excludeFiles) const +QList ProjectScannerQtCreator::openedTextFiles(const QStringList &excludeFiles) const { QList files; diff --git a/docs/architecture.md b/docs/architecture.md index 34f1e53..da71ece 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,15 +63,24 @@ Agent(config, provider) 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 +SessionManager — three ways to obtain a Session: + • createDetachedSession(externalHistory) — chat: one persistent, caller-owned + session over an externally-owned + history; the agent is (re)bound via + rebindAgentByName when the picker + changes (rebinding cancels any + in-flight request) + • createSession(agentName, externalHistory?) — manager-tracked session, removed + via removeSession • 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 + cleared system-prompt layers, + cleared client tools and cleared + pinned context / content loader / + bindings; release refuses to pool + external-history sessions ▼ Session(agent[, externalHistory]) ├─ ConversationHistory — messages as polymorphic ContentBlocks @@ -214,20 +223,24 @@ ChatRootView (QML) — owns ConversationHistory m_history chatAssistant allow-list; active agent persisted ▼ dispatchSend ClientInterface - session = sessionManager.createSession(activeAgent, m_history) + session = sessionManager.createDetachedSession(m_history) — one persistent + session per chat view; ensureAgentBound → rebindAgentByName on + picker change 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 + finished(id) → ChangesManager.applyPendingEditsForRequest + persist + failed(id, ErrorInfo) → surface error + cancelled(id) → clear busy state (the persistent session and history survive + every outcome) -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 +ChatCompressor → acquire(chatCompression agent — single configured) → send the + flattened transcript as one user message → read summary from the + compression session's own history → write a fresh v0.3 file → + release InputTokenCounter → estimates over ConversationHistory (calibrated by Usage events) ChatSerializer → persists ConversationHistory via MessageSerializer (v0.3); imports legacy v0.1/v0.2 files diff --git a/docs/claude-configuration.md b/docs/claude-configuration.md index 375ca1b..44ff90e 100644 --- a/docs/claude-configuration.md +++ b/docs/claude-configuration.md @@ -1,16 +1,12 @@ # Configure for Anthropic Claude -1. Open Qt Creator settings and navigate to the QodeAssist section -2. Go to Provider Settings tab and configure Claude api key -3. Return to General tab and configure: - - Set "Claude" as the provider for code completion or/and chat assistant - - Set the Claude URL (https://api.anthropic.com) - - Select your preferred model (e.g., claude-3-5-sonnet-20241022) - - Choose the Claude template for code completion or/and chat - -
- Example of Claude settings: (click to expand) - -Claude Settings -
+1. Open Qt Creator settings and navigate to QodeAssist > Providers +2. Select the bundled **Claude** provider and enter your Anthropic API key +3. Go to QodeAssist > General > Agent Pipelines and assign Claude agents to the features you want: + - Code completion: **Claude Completion** + - Chat assistant: **Claude Chat — Sonnet** (or an Opus variant) + - Chat compression: **Claude Compression** + - Quick refactor: **Claude Quick Refactor** (or the Fast variant) +To change the model or request parameters, duplicate the bundled agent on the +QodeAssist > Agents page (it extends the Claude base agent) and edit your copy. diff --git a/docs/google-ai-configuration.md b/docs/google-ai-configuration.md index 224f5c0..151ceab 100644 --- a/docs/google-ai-configuration.md +++ b/docs/google-ai-configuration.md @@ -1,16 +1,12 @@ # Configure for Google AI -1. Open Qt Creator settings and navigate to the QodeAssist section -2. Go to Provider Settings tab and configure Google AI api key -3. Return to General tab and configure: - - Set "Google AI" as the provider for code completion or/and chat assistant - - Set the OpenAI URL (https://generativelanguage.googleapis.com/v1beta) - - Select your preferred model (e.g., gemini-2.0-flash) - - Choose the Google AI template - -
- Example of Google AI settings: (click to expand) - -Google AI Settings -
+1. Open Qt Creator settings and navigate to QodeAssist > Providers +2. Select the bundled **Google AI** provider and enter your Google AI Studio API key +3. Go to QodeAssist > General > Agent Pipelines and assign Google agents to the features you want: + - Code completion: **Google Completion** + - Chat assistant: **Google Chat** + - Chat compression: **Google Compression** + - Quick refactor: **Google Quick Refactor** +To change the Gemini model or request parameters, duplicate the bundled agent on +the QodeAssist > Agents page (it extends the Google base agent) and edit your copy. diff --git a/docs/llamacpp-configuration.md b/docs/llamacpp-configuration.md index f7a9248..7345a45 100644 --- a/docs/llamacpp-configuration.md +++ b/docs/llamacpp-configuration.md @@ -1,16 +1,14 @@ # Configure for llama.cpp -1. Open Qt Creator settings and navigate to the QodeAssist section -2. Go to General tab and configure: - - Set "llama.cpp" as the provider for code completion or/and chat assistant - - Set the llama.cpp URL (e.g. http://localhost:8080) - - Fill in model name - - Choose template for model(e.g. llama.cpp FIM for any model with FIM support) - - Disable using tools if your model doesn't support tooling - -
- Example of llama.cpp settings: (click to expand) - -llama.cpp Settings -
+1. Start `llama-server` locally (default http://localhost:8080) +2. Open Qt Creator settings and navigate to QodeAssist > Providers + - Select the bundled **llama.cpp** provider and adjust the URL if your server runs elsewhere (no API key needed) +3. Go to QodeAssist > General > Agent Pipelines and assign llama.cpp agents to the features you want: + - Code completion: **LlamaCpp Completion — FIM** (needs a FIM-capable model) + - Chat assistant: **LlamaCpp Chat** + - Chat compression: **LlamaCpp Compression** + - Quick refactor: **LlamaCpp Quick Refactor** +To change the model or request parameters, duplicate the bundled agent on the +QodeAssist > Agents page (it extends the llama.cpp base agent) and edit your +copy. Disable `enable_tools` in your agent if the model doesn't support tool calling. diff --git a/docs/mistral-configuration.md b/docs/mistral-configuration.md index 2535bb6..e1db3d7 100644 --- a/docs/mistral-configuration.md +++ b/docs/mistral-configuration.md @@ -1,16 +1,13 @@ # Configure for Mistral AI -1. Open Qt Creator settings and navigate to the QodeAssist section -2. Go to Provider Settings tab and configure Mistral AI api key -3. Return to General tab and configure: - - Set "Mistral AI" as the provider for code completion or/and chat assistant - - Set the OpenAI URL (https://api.mistral.ai) - - Select your preferred model (e.g., mistral-large-latest) - - Choose the Mistral AI template for code completion or/and chat - -
- Example of Mistral AI settings: (click to expand) - -Mistral AI Settings -
+1. Open Qt Creator settings and navigate to QodeAssist > Providers +2. Select the bundled **Mistral AI** provider and enter your Mistral API key + - For Codestral code completion, configure the separate **Codestral** provider — it uses its own key and endpoint (codestral.mistral.ai) +3. Go to QodeAssist > General > Agent Pipelines and assign Mistral agents to the features you want: + - Code completion: **Mistral Completion — Codestral FIM** (needs the Codestral provider) + - Chat assistant: **Mistral Chat** (or the Reasoning variant) + - Chat compression: **Mistral Compression** + - Quick refactor: **Mistral Quick Refactor** +To change the model or request parameters, duplicate the bundled agent on the +QodeAssist > Agents page (it extends the Mistral base agent) and edit your copy. diff --git a/docs/ollama-configuration.md b/docs/ollama-configuration.md index 0ab9b51..fa55dd8 100644 --- a/docs/ollama-configuration.md +++ b/docs/ollama-configuration.md @@ -17,14 +17,13 @@ ollama run qwen2.5-coder:32b ``` 3. Open Qt Creator settings (Edit > Preferences on Linux/Windows, Qt Creator > Preferences on macOS) -4. Navigate to the "QodeAssist" tab -5. On the "General" page, verify: - - Ollama is selected as your LLM provider - - The URL is set to http://localhost:11434 - - Your installed model appears in the model selection - - The prompt template is Ollama Auto FIM or Ollama Auto Chat for chat assistance. You can specify template if it is not work correct - - Disable using tools if your model doesn't support tooling -6. Click Apply if you made any changes +4. Navigate to QodeAssist > Providers and verify the bundled **Ollama (Native)** provider points at http://localhost:11434 (no API key needed) +5. Navigate to QodeAssist > General > Agent Pipelines — the Ollama agents are assigned by default: + - Code completion: **Ollama Completion — FIM** (needs a base/FIM model; use **Ollama Completion — Chat-style** for instruct models) + - Chat assistant: **Ollama Chat — Simple / Thinking / Gemma 4** + - Chat compression: **Ollama Compression — 8 GB** (or the 16/32 GB tier for your machine) + - Quick refactor: **Ollama Quick Refactor — Simple** +6. Point the agents at models you actually have (see the next section) You're all set! QodeAssist is now ready to use in Qt Creator. @@ -55,45 +54,13 @@ swap it (Change…) for one you already have. ## Extended Thinking Mode -Ollama supports extended thinking mode for models that are capable of deep reasoning (such as DeepSeek-R1, QwQ, and similar reasoning models). This mode allows the model to show its step-by-step reasoning process before providing the final answer. +Ollama supports native reasoning for models trained for it (Qwen3.5, Gemma 4, DeepSeek-R1, QwQ, …). Reasoning is streamed into collapsible "Thinking" blocks in the chat. -### How to Enable +Thinking is a property of the **agent**, not a global switch: -**For Chat Assistant:** -1. Navigate to Qt Creator > Preferences > QodeAssist > Chat Assistant -2. In the "Extended Thinking (Claude, Ollama)" section, check "Enable extended thinking mode" -3. Select a reasoning-capable model (e.g., deepseek-r1:8b, qwq:32b) -4. Click Apply +- For chat, pick a thinking agent in the chat panel: **Ollama Chat — Thinking** or **Ollama Chat — Gemma 4** +- For quick refactor, assign **Ollama Quick Refactor — Qwen3.5** or **— Gemma 4** in Agent Pipelines +- In your own agents, set `think = true` in the `[body]` table (top level, not under `[body.options]`) -**For Quick Refactoring:** -1. Navigate to Qt Creator > Preferences > QodeAssist > Quick Refactor -2. Check "Enable Thinking Mode" -3. Configure thinking budget and max tokens as needed -4. Click Apply - -### Supported Models - -Thinking mode works best with models specifically designed for reasoning: -- **DeepSeek-R1** series (deepseek-r1:8b, deepseek-r1:14b, deepseek-r1:32b) -- **QwQ** series (qwq:32b) -- Other models trained for chain-of-thought reasoning - -### How It Works - -When thinking mode is enabled: -1. The model generates internal reasoning (visible in the chat as "Thinking" blocks) -2. After reasoning, it provides the final answer -3. You can collapse/expand thinking blocks to focus on the final answer -4. Temperature is automatically set to 1.0 for optimal reasoning performance - -**Technical Details:** -- Thinking mode adds the `enable_thinking: true` parameter to requests sent to Ollama -- This is natively supported by the Ollama API for compatible models -- Works in both Chat Assistant and Quick Refactoring contexts - -
- Example of Ollama settings: (click to expand) - -Ollama Settings -
+Use a reasoning-capable model with these agents — a non-reasoning model simply ignores the flag. diff --git a/docs/openai-configuration.md b/docs/openai-configuration.md index efbc177..dd0b866 100644 --- a/docs/openai-configuration.md +++ b/docs/openai-configuration.md @@ -1,32 +1,18 @@ # Configure for OpenAI -QodeAssist supports both OpenAI's standard Chat Completions API and the new Responses API, giving you access to the latest GPT models including GPT-5.1 and GPT-5.1-codex. +QodeAssist supports both OpenAI's standard Chat Completions API and the Responses API, giving you access to the latest GPT models. -## Standard OpenAI Configuration +1. Open Qt Creator settings and navigate to QodeAssist > Providers +2. Select the bundled **OpenAI (Chat Completions)** or **OpenAI (Responses API)** provider and enter your OpenAI API key +3. Go to QodeAssist > General > Agent Pipelines and assign OpenAI agents to the features you want: + - Code completion: **OpenAI Completion** + - Chat assistant: **OpenAI Chat**, **OpenAI Chat — Mini** (Chat Completions), or **OpenAI Chat — Responses** + - Chat compression: **OpenAI Compression** + - Quick refactor: **OpenAI Quick Refactor** -1. Open Qt Creator settings and navigate to the QodeAssist section -2. Go to Provider Settings tab and configure OpenAI api key -3. Return to General tab and configure: - - Set "OpenAI" as the provider for code completion or/and chat assistant - - Set the OpenAI URL (https://api.openai.com) - - Select your preferred model (e.g., gpt-4o, gpt-5.1, gpt-5.1-codex) - - Choose the OpenAI template for code completion or/and chat - -
- Example of OpenAI settings: (click to expand) - -OpenAI Settings -
- -## OpenAI Responses API Configuration - -The Responses API is OpenAI's newer endpoint that provides enhanced capabilities and improved performance. It supports the latest GPT-5.1 models. - -1. Open Qt Creator settings and navigate to the QodeAssist section -2. Go to Provider Settings tab and configure OpenAI api key -3. Return to General tab and configure: - - Set "OpenAI Responses" as the provider for code completion or/and chat assistant - - Set the OpenAI URL (https://api.openai.com) - - Select your preferred model (e.g., gpt-5.1, gpt-5.1-codex) - - Choose the OpenAI Responses template for code completion or/and chat +To change the model or request parameters, duplicate the bundled agent on the +QodeAssist > Agents page (it extends the OpenAI base agent) and edit your copy. +Note for GPT-5 family models on Chat Completions: use `max_completion_tokens` +(not `max_tokens`) and `reasoning_effort` in the agent `[body]`; reasoning +models reject `temperature`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 6e4835b..f9020cd 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -9,11 +9,11 @@ Make sure you're using the correct default URLs: - **LM Studio**: `http://localhost:1234` - **llama.cpp**: `http://localhost:8080` -### 2. Check model and template compatibility +### 2. Check agent and model compatibility -- Ensure the correct model is selected in settings -- Verify that the selected prompt template matches your model -- Some models may not support certain features (e.g., tool calling) +- Ensure each Agent Pipelines slot (QodeAssist > General) has an agent assigned — an unassigned slot disables that feature +- Verify the agent's model exists on your provider (check the agent on the QodeAssist > Agents page) +- Some models may not support certain features (e.g., tool calling or native FIM completion) — pick an agent variant that matches your model ### 3. Linux compatibility @@ -31,7 +31,7 @@ If issues persist, you can reset settings to their default values: **Note:** - API keys are preserved during reset -- You will need to re-select your model after reset +- Resetting the General page restores the default local Ollama agent pipelines — re-assign your own agents afterwards ## Chat History Migration diff --git a/mcp/McpClientsManager.cpp b/mcp/McpClientsManager.cpp index 88dd99b..9461a49 100644 --- a/mcp/McpClientsManager.cpp +++ b/mcp/McpClientsManager.cpp @@ -9,11 +9,9 @@ #include #include #include -#include #include #include #include -#include #include #include @@ -86,7 +84,6 @@ void McpClientsManager::init() m_initialized = true; ensureFileExists(); - setupWatcher(); connect( &Settings::mcpSettings().enableMcpClients, @@ -128,48 +125,6 @@ void McpClientsManager::ensureFileExists() } } -void McpClientsManager::setupWatcher() -{ - m_watcher = new QFileSystemWatcher(this); - m_reloadDebounce = new QTimer(this); - m_reloadDebounce->setSingleShot(true); - m_reloadDebounce->setInterval(300); - - connect(m_reloadDebounce.data(), &QTimer::timeout, this, [this]() { - const bool suppress = m_suppressNextWatcherReload; - m_suppressNextWatcherReload = false; - if (!suppress) - loadFromDisk(); - updateWatchedPaths(); - }); - connect(m_watcher.data(), &QFileSystemWatcher::fileChanged, this, [this]() { - m_reloadDebounce->start(); - }); - connect(m_watcher.data(), &QFileSystemWatcher::directoryChanged, this, [this]() { - m_reloadDebounce->start(); - }); - - updateWatchedPaths(); -} - -void McpClientsManager::updateWatchedPaths() -{ - if (!m_watcher) - return; - if (!m_watcher->files().isEmpty()) - m_watcher->removePaths(m_watcher->files()); - if (!m_watcher->directories().isEmpty()) - m_watcher->removePaths(m_watcher->directories()); - - const QString path = configFilePath(); - const QFileInfo info(path); - if (info.exists()) - m_watcher->addPath(path); - const QString dir = info.absolutePath(); - if (QFileInfo::exists(dir)) - m_watcher->addPath(dir); -} - QList McpClientsManager::connections() const { return m_connections; @@ -242,14 +197,12 @@ bool McpClientsManager::writeRoot(const QJsonObject &root) emit writeFailed(reason); return false; } - m_suppressNextWatcherReload = true; return true; } void McpClientsManager::reload() { loadFromDisk(); - updateWatchedPaths(); } bool McpClientsManager::setServerEnabled(const QString &name, bool enabled) diff --git a/mcp/McpClientsManager.hpp b/mcp/McpClientsManager.hpp index 4f32da5..9da0edf 100644 --- a/mcp/McpClientsManager.hpp +++ b/mcp/McpClientsManager.hpp @@ -7,14 +7,10 @@ #include #include #include -#include #include #include "McpServerConnection.hpp" -class QFileSystemWatcher; -class QTimer; - namespace QodeAssist::Mcp { class McpClientsManager : public QObject @@ -49,18 +45,13 @@ private: void loadFromDisk(); void ensureFileExists(); - void setupWatcher(); - void updateWatchedPaths(); static QJsonObject builtinServers(); QJsonObject readRoot() const; bool writeRoot(const QJsonObject &root); bool m_initialized = false; - bool m_suppressNextWatcherReload = false; QList m_connections; - QPointer m_watcher; - QPointer m_reloadDebounce; }; } // namespace QodeAssist::Mcp diff --git a/qodeassist.cpp b/qodeassist.cpp index c94cbbe..8b56437 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -33,7 +33,6 @@ #include #include -#include #include "QodeAssistClient.hpp" #include "QuickRefactorHandler.hpp" #include "UpdateStatusWidget.hpp" @@ -46,38 +45,39 @@ #include "logger/RequestPerformanceLogger.hpp" #include "mcp/McpClientsManager.hpp" #include "mcp/McpServerManager.hpp" -#include "sources/skills/SkillsManager.hpp" -#include "tools/ToolsRegistration.hpp" +#include "settings/AgentsSettingsPage.hpp" #include "settings/ChatAssistantSettings.hpp" #include "settings/GeneralSettings.hpp" #include "settings/ProjectSettingsPanel.hpp" -#include "settings/AgentsSettingsPage.hpp" #include "settings/ProvidersSettingsPage.hpp" #include "settings/QuickRefactorSettings.hpp" #include "settings/SettingsConstants.hpp" +#include "sources/skills/SkillsManager.hpp" +#include "tools/ToolsRegistration.hpp" +#include #include "ProviderInstanceFactory.hpp" #include "ProviderLauncher.hpp" #include "ProviderSecretsStore.hpp" -#include -#include -#include #include "widgets/CustomInstructionsManager.hpp" #include "widgets/QuickRefactorDialog.hpp" -#include +#include #include #include +#include #include #include -#include -#include -#include +#include +#include #include #include +#include #include #include #include +#include +#include using namespace Utils; using namespace Core; @@ -182,19 +182,20 @@ public: m_providerSecretsStore = new Providers::ProviderSecretsStore(this); m_providerLauncher = new Providers::ProviderLauncher(this); m_providersPageNavigator = new Settings::ProvidersPageNavigator(this); + + m_agentFactory = new AgentFactory(m_providerInstanceFactory, m_providerSecretsStore, this); + m_providersOptionsPage = Settings::createProvidersSettingsPage( m_providerInstanceFactory, m_providerSecretsStore, m_providerLauncher, - m_providersPageNavigator); - - m_agentFactory = new AgentFactory(m_providerInstanceFactory, m_providerSecretsStore, this); + m_providersPageNavigator, + m_agentFactory); m_sessionManager = new SessionManager(m_agentFactory, this); { auto &contributors = m_sessionManager->toolContributors(); - contributors.add([](::LLMQore::ToolsManager *tools) { - Tools::registerQodeAssistTools(tools); - }); + contributors.add( + [](::LLMQore::ToolsManager *tools) { Tools::registerQodeAssistTools(tools); }); contributors.add([skills = m_skillsManager](::LLMQore::ToolsManager *tools) { if (skills) Tools::registerSkillTool(tools, skills); @@ -221,8 +222,7 @@ public: m_agentsOptionsPage = Settings::createAgentsSettingsPage( m_agentFactory, m_agentsPageNavigator); - Settings::generalSettings().setAgentPipelinesContext( - m_agentFactory, m_agentsPageNavigator); + Settings::generalSettings().setAgentPipelinesContext(m_agentFactory, m_agentsPageNavigator); m_mcpServerManager = new Mcp::McpServerManager(this); m_mcpServerManager->init(); @@ -387,8 +387,6 @@ private: QString title = Tr::tr("QodeAssist Chat"); Core::IEditor *editor = Core::EditorManager::openEditorWithContents( Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString()); - // For the "New Chat" button pending is empty (no-op). For relocate-to-editor it - // carries the handed-off chat file and gets loaded into the freshly opened tab. if (auto chatEditor = qobject_cast(editor)) chatEditor->consumePendingChatFile(); } diff --git a/settings/AgentDetailPane.cpp b/settings/AgentDetailPane.cpp index 3d18420..4dbd2a3 100644 --- a/settings/AgentDetailPane.cpp +++ b/settings/AgentDetailPane.cpp @@ -366,7 +366,23 @@ AgentDetailPane::AgentDetailPane(QWidget *parent) void AgentDetailPane::setInstanceFactory(Providers::ProviderInstanceFactory *factory) { + if (m_instanceFactory) + disconnect(m_instanceFactory, nullptr, this, nullptr); m_instanceFactory = factory; + if (m_instanceFactory) { + connect( + m_instanceFactory, + &Providers::ProviderInstanceFactory::instancesReloaded, + this, + [this]() { + const QString selected = m_providerCombo->currentData().toString(); + m_providerComboPopulated = false; + populateProviderCombo(); + const int idx = m_providerCombo->findData(selected); + if (idx >= 0) + m_providerCombo->setCurrentIndex(idx); + }); + } m_providerComboPopulated = false; populateProviderCombo(); } @@ -607,8 +623,8 @@ void AgentDetailPane::setAgent(const AgentConfig &cfg) fillRawToml(m_rawToml, cfg.sourcePath); - const QString basePath - = m_agentFactory ? m_agentFactory->sourcePathForName(cfg.extendsName) : QString(); + const QString basePath = m_agentFactory ? m_agentFactory->sourcePathForName(cfg.extendsName) + : QString(); const bool hasBase = !cfg.extendsName.isEmpty() && !basePath.isEmpty(); m_baseRawToggle->setVisible(hasBase); m_baseRawToml->setVisible(hasBase && m_baseRawToggle->isChecked()); diff --git a/settings/AgentListItem.cpp b/settings/AgentListItem.cpp index 965579f..aa4bf43 100644 --- a/settings/AgentListItem.cpp +++ b/settings/AgentListItem.cpp @@ -127,9 +127,8 @@ void AgentListItem::applyTheme() if (m_inApplyTheme) return; QScopedValueRollback guard(m_inApplyTheme, true); - const QString accent = m_selected - ? cssColor(Utils::creatorColor(Utils::Theme::TextColorLink)) - : QStringLiteral("transparent"); + const QString accent = m_selected ? cssColor(Utils::creatorColor(Utils::Theme::TextColorLink)) + : QStringLiteral("transparent"); setStyleSheet(QStringLiteral( "#AgentListItem { background:transparent;" " border-top:1px solid %1; border-left:3px solid %2; }") diff --git a/settings/AgentListPane.cpp b/settings/AgentListPane.cpp index 0fca167..5a93b8c 100644 --- a/settings/AgentListPane.cpp +++ b/settings/AgentListPane.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -26,7 +27,6 @@ #include #include #include -#include namespace QodeAssist::Settings { @@ -74,15 +74,14 @@ AgentListPane::AgentListPane(AgentFactory *factory, QWidget *parent) 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); - }); + 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(); @@ -97,6 +96,7 @@ void AgentListPane::selectByName(const QString &name) m_expandedGroups.insert(groupKey(*cfg)); } setCurrentNameInternal(name, false); + m_notifyOnRebuild = true; rebuildList(); for (auto *item : m_rows) { if (item->agentName() == name) { @@ -117,6 +117,7 @@ void AgentListPane::refresh() for (const QString &t : a->tags) counts[t] += 1; m_tagStrip->setAvailableTags(counts); + m_notifyOnRebuild = true; rebuildList(); } @@ -132,10 +133,12 @@ void AgentListPane::applyFilterHolderTheme() if (!m_filterHolder) return; m_filterHolder->setStyleSheet( - QStringLiteral("QWidget#FilterHolder { background:%1;" - " border-bottom:1px solid %2; }") - .arg(cssColor(Utils::creatorColor(Utils::Theme::BackgroundColorNormal)), - cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)))); + QStringLiteral( + "QWidget#FilterHolder { background:%1;" + " border-bottom:1px solid %2; }") + .arg( + cssColor(Utils::creatorColor(Utils::Theme::BackgroundColorNormal)), + cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)))); } std::vector AgentListPane::visibleAgents() const @@ -198,15 +201,19 @@ void AgentListPane::rebuildList() item->setSelected(cfg->name == m_currentName); item->setActiveTags(activeTags); connect(item, &AgentListItem::clicked, this, &AgentListPane::onRowClicked); - connect(item, &AgentListItem::tagClicked, this, - [this](const QString &) { refresh(); }, - Qt::QueuedConnection); + connect( + item, + &AgentListItem::tagClicked, + this, + [this](const QString &tag) { m_tagStrip->toggleTag(tag); }, + Qt::QueuedConnection); contentLayout->addWidget(item); newRows.append(item); } }; QSet liveKeys; - auto addSection = [&](const QString &title, const QString §ionKey, + auto addSection = [&](const QString &title, + const QString §ionKey, const std::vector &agents) { if (agents.empty()) return; @@ -216,8 +223,9 @@ void AgentListPane::rebuildList() for (const AgentConfig *cfg : agents) byProvider[providerLabel(*cfg)].push_back(cfg); QStringList providers = byProvider.keys(); - std::sort(providers.begin(), providers.end(), - [](const QString &l, const QString &r) { return l.localeAwareCompare(r) < 0; }); + std::sort(providers.begin(), providers.end(), [](const QString &l, const QString &r) { + return l.localeAwareCompare(r) < 0; + }); for (const QString &provider : providers) { const std::vector &group = byProvider[provider]; @@ -229,13 +237,16 @@ void AgentListPane::rebuildList() header->setExpanded(expanded); header->setClickable(!filtersActive); if (!filtersActive) { - connect(header, &CollapsibleHeader::toggled, this, - [this, key] { - if (!m_expandedGroups.remove(key)) - m_expandedGroups.insert(key); - rebuildList(); - }, - Qt::QueuedConnection); + connect( + header, + &CollapsibleHeader::toggled, + this, + [this, key] { + if (!m_expandedGroups.remove(key)) + m_expandedGroups.insert(key); + rebuildList(); + }, + Qt::QueuedConnection); } contentLayout->addWidget(header); @@ -269,10 +280,14 @@ void AgentListPane::rebuildList() if (!current && !m_rows.isEmpty()) { const QString fallback = m_rows.front()->agentName(); m_rows.front()->setSelected(true); - setCurrentNameInternal(fallback, /*emitSignal*/ true); + m_notifyOnRebuild = false; + setCurrentNameInternal(fallback, true); return; } - emit currentAgentChanged(m_currentName); + if (m_notifyOnRebuild) { + m_notifyOnRebuild = false; + emit currentAgentChanged(m_currentName); + } } void AgentListPane::onRowClicked(const QString &name) diff --git a/settings/AgentListPane.hpp b/settings/AgentListPane.hpp index 6843cac..7d8f011 100644 --- a/settings/AgentListPane.hpp +++ b/settings/AgentListPane.hpp @@ -61,6 +61,7 @@ private: QList m_rows; QString m_currentName; QSet m_expandedGroups; + bool m_notifyOnRebuild = false; }; } // namespace QodeAssist::Settings diff --git a/settings/AgentModelDialog.cpp b/settings/AgentModelDialog.cpp index d7aea59..c2d9585 100644 --- a/settings/AgentModelDialog.cpp +++ b/settings/AgentModelDialog.cpp @@ -24,10 +24,7 @@ namespace QodeAssist::Settings { AgentModelDialog::AgentModelDialog( - AgentFactory *factory, - const QString &agentName, - const QString ¤tModel, - QWidget *parent) + AgentFactory *factory, const QString &agentName, const QString ¤tModel, QWidget *parent) : QDialog(parent) , m_factory(factory) , m_agentName(agentName) @@ -103,8 +100,9 @@ void AgentModelDialog::fetchModels() if (!m_watcher) { m_watcher = new QFutureWatcher>(this); - connect(m_watcher, &QFutureWatcher>::finished, this, - [this] { onModelsFetched(); }); + connect(m_watcher, &QFutureWatcher>::finished, this, [this] { + onModelsFetched(); + }); } m_fetchBtn->setEnabled(false); @@ -134,9 +132,8 @@ void AgentModelDialog::onModelsFetched() 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()))); + 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 index a843632..743353c 100644 --- a/settings/AgentModelDialog.hpp +++ b/settings/AgentModelDialog.hpp @@ -20,7 +20,7 @@ QT_END_NAMESPACE namespace QodeAssist { class AgentFactory; class Agent; -} +} // namespace QodeAssist namespace QodeAssist::Settings { diff --git a/settings/CMakeLists.txt b/settings/CMakeLists.txt index c519d3e..98c81f1 100644 --- a/settings/CMakeLists.txt +++ b/settings/CMakeLists.txt @@ -13,7 +13,6 @@ add_library(QodeAssistSettings STATIC ProjectSettings.hpp ProjectSettings.cpp ProjectSettingsPanel.hpp ProjectSettingsPanel.cpp ProviderSettings.hpp ProviderSettings.cpp - ProviderNameMigration.hpp ProvidersSettingsPage.hpp ProvidersSettingsPage.cpp SettingsTheme.hpp SettingsUiBuilders.hpp SettingsUiBuilders.cpp diff --git a/settings/ChatAssistantSettings.cpp b/settings/ChatAssistantSettings.cpp index 8d113b2..313fdbe 100644 --- a/settings/ChatAssistantSettings.cpp +++ b/settings/ChatAssistantSettings.cpp @@ -147,10 +147,7 @@ ChatAssistantSettings::ChatAssistantSettings() Space{8}, Group{ title(Tr::tr("Chat Settings")), - Column{ - linkOpenFiles, - autosave, - Row{autoCompress, autoCompressThreshold, Stretch{1}}}}, + Column{linkOpenFiles, autosave, Row{autoCompress, autoCompressThreshold, Stretch{1}}}}, Space{8}, Group{title(Tr::tr("Chat Settings")), Row{chatViewSettingsGrid, Stretch{1}}}, Stretch{1}}; diff --git a/settings/CodeCompletionSettings.cpp b/settings/CodeCompletionSettings.cpp index 8ea0fd6..7f7c7d8 100644 --- a/settings/CodeCompletionSettings.cpp +++ b/settings/CodeCompletionSettings.cpp @@ -257,15 +257,18 @@ CodeCompletionSettings::CodeCompletionSettings() startSuggestionTimer, Stretch{1}}}; - return Column{Row{Stretch{1}, resetToDefaults}, - Space{8}, - Group{title(TrConstants::AUTO_COMPLETION_SETTINGS), - Column{Group{title(Tr::tr("General Settings")), generalSettings}, - Space{8}, - Group{title(Tr::tr("Automatic Trigger Mode")), autoTriggerSettings}}}, - Space{8}, - Group{title(Tr::tr("Context Settings")), contextItem}, - Stretch{1}}; + return Column{ + Row{Stretch{1}, resetToDefaults}, + Space{8}, + Group{ + title(TrConstants::AUTO_COMPLETION_SETTINGS), + Column{ + Group{title(Tr::tr("General Settings")), generalSettings}, + Space{8}, + Group{title(Tr::tr("Automatic Trigger Mode")), autoTriggerSettings}}}, + Space{8}, + Group{title(Tr::tr("Context Settings")), contextItem}, + Stretch{1}}; }); } diff --git a/settings/CollapsibleHeader.cpp b/settings/CollapsibleHeader.cpp index aa09e47..e4257cd 100644 --- a/settings/CollapsibleHeader.cpp +++ b/settings/CollapsibleHeader.cpp @@ -110,8 +110,9 @@ void CollapsibleHeader::applyTheme() const QColor mid = Utils::creatorColor(Utils::Theme::PanelTextColorMid); const QColor bg = mix(base, mid, m_hovered ? 0.18 : 0.08); - setStyleSheet(QStringLiteral("#CollapsibleHeader { background:%1;" - " border-top:1px solid %2; }") + setStyleSheet(QStringLiteral( + "#CollapsibleHeader { background:%1;" + " border-top:1px solid %2; }") .arg(cssColor(bg), cssColor(mix(base, mid, 0.25)))); QPalette ap = m_arrow->palette(); diff --git a/settings/GeneralSettings.cpp b/settings/GeneralSettings.cpp index c311e80..267b6a1 100644 --- a/settings/GeneralSettings.cpp +++ b/settings/GeneralSettings.cpp @@ -5,7 +5,9 @@ #include "GeneralSettings.hpp" #include +#include #include +#include #include #include #include @@ -27,6 +29,7 @@ #include "UpdateDialog.hpp" #include +#include namespace QodeAssist::Settings { @@ -63,6 +66,14 @@ public: headerRow->setContentsMargins(0, 0, 0, 0); headerRow->setSpacing(8); headerRow->addWidget(m_titleLabel); + auto *instantApplyHint = new QLabel(tr("changes apply immediately"), this); + { + QFont hf = instantApplyHint->font(); + hf.setPointSizeF(hf.pointSizeF() * 0.9); + instantApplyHint->setFont(hf); + instantApplyHint->setEnabled(false); + } + headerRow->addWidget(instantApplyHint); headerRow->addStretch(1); auto *headerSep = new QFrame(this); @@ -79,6 +90,11 @@ public: Tr::tr(TrConstants::CODE_COMPLETION), Tr::tr(TrConstants::SLOT_HINT_CODE_COMPLETION), {QStringLiteral("completion")}); + if (auto *doc = Core::EditorManager::currentDocument()) { + AgentRouter::Context routingCtx; + routingCtx.filePath = doc->filePath().toFSPathString(); + m_completionRoster->setRoutingContext(routingCtx); + } m_chatRoster = new AgentRosterWidget(this); m_chatRoster->setSlot( @@ -121,10 +137,14 @@ public: 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(); }); + connect( + roster, + &AgentRosterWidget::editAgentRequested, + this, + &AgentPipelinesWidget::onEditAgent); + connect(roster, &AgentRosterWidget::rosterChanged, this, [this](const QStringList &) { + m_saveDebounce->start(); + }); } } diff --git a/settings/GeneralSettings.hpp b/settings/GeneralSettings.hpp index 4a4316c..48a4cef 100644 --- a/settings/GeneralSettings.hpp +++ b/settings/GeneralSettings.hpp @@ -25,8 +25,7 @@ class GeneralSettings : public Utils::AspectContainer public: GeneralSettings(); - void setAgentPipelinesContext( - AgentFactory *agentFactory, AgentsPageNavigator *agentsNavigator); + void setAgentPipelinesContext(AgentFactory *agentFactory, AgentsPageNavigator *agentsNavigator); Utils::BoolAspect enableQodeAssist{this}; Utils::BoolAspect enableLogging{this}; diff --git a/settings/ProviderDetailPane.cpp b/settings/ProviderDetailPane.cpp index 1fd0046..054bff2 100644 --- a/settings/ProviderDetailPane.cpp +++ b/settings/ProviderDetailPane.cpp @@ -120,9 +120,7 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent) identityGrid->setContentsMargins(0, 0, 0, 0); identityGrid->setHorizontalSpacing(8); identityGrid->setVerticalSpacing(4); - FormBuilder(identityGrid) - .row(tr("Name:"), m_nameEdit) - .row(tr("Description:"), m_descriptionEdit); + FormBuilder(identityGrid).row(tr("Name:"), m_nameEdit).row(tr("Description:"), m_descriptionEdit); identitySection->bodyLayout()->addLayout(identityGrid); auto *endpointSection = new SectionBox(tr("Endpoint"), this); @@ -332,13 +330,12 @@ 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{}; + const LegacyApiKeyEntry legacy = needsKey ? legacyApiKeyForClientApi(inst.clientApi, inst.name) + : 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)); + 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); @@ -425,11 +422,13 @@ void ProviderDetailPane::setLaunchState( return; } - const QString detachedNote = m_current.launch.detach - ? QStringLiteral(" %2") - .arg(Utils::creatorColor(Utils::Theme::PanelTextColorMid).name(), - tr("(detached — survives Qt Creator restart)")) - : QString(); + const QString detachedNote + = m_current.launch.detach + ? QStringLiteral(" %2") + .arg( + Utils::creatorColor(Utils::Theme::PanelTextColorMid).name(), + tr("(detached — survives Qt Creator restart)")) + : QString(); m_launchCmdLabel->setText( QStringLiteral("%1 %2%3") .arg(m_current.launch.command.toHtmlEscaped(), @@ -515,8 +514,9 @@ void ProviderDetailPane::applyPreviewPalette() { m_samplePreview->setStyleSheet( QStringLiteral("QLabel { background:%1; border:1px solid %2; }") - .arg(cssColor(Utils::creatorColor(Utils::Theme::BackgroundColorNormal)), - cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)))); + .arg( + cssColor(Utils::creatorColor(Utils::Theme::BackgroundColorNormal)), + cssColor(Utils::creatorColor(Utils::Theme::SplitterColor)))); } void ProviderDetailPane::applyTerminalPalette() diff --git a/settings/ProviderListItem.cpp b/settings/ProviderListItem.cpp index 4feb7d8..e07cd1d 100644 --- a/settings/ProviderListItem.cpp +++ b/settings/ProviderListItem.cpp @@ -105,9 +105,8 @@ void ProviderListItem::applyTheme() if (m_inApplyTheme) return; QScopedValueRollback guard(m_inApplyTheme, true); - const QString accent = m_selected - ? cssColor(Utils::creatorColor(Utils::Theme::TextColorLink)) - : QStringLiteral("transparent"); + const QString accent = m_selected ? cssColor(Utils::creatorColor(Utils::Theme::TextColorLink)) + : QStringLiteral("transparent"); setStyleSheet(QStringLiteral( "#ProvListItem { background:transparent;" " border-top:1px solid %1; border-left:3px solid %2; }") diff --git a/settings/ProviderNameMigration.hpp b/settings/ProviderNameMigration.hpp deleted file mode 100644 index a5ea1bb..0000000 --- a/settings/ProviderNameMigration.hpp +++ /dev/null @@ -1,30 +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::Settings { - -// Maps legacy provider names (used before multi-API providers were introduced -// with parenthetical suffixes) to their current canonical names. Returns the -// input unchanged if it is not a known legacy name. -inline QString migrateProviderName(const QString &oldName) -{ - static const QHash renames{ - {QStringLiteral("Ollama"), QStringLiteral("Ollama (Native)")}, - {QStringLiteral("Ollama Compatible"), QStringLiteral("Ollama (OpenAI-compatible)")}, - {QStringLiteral("OpenAI"), QStringLiteral("OpenAI (Chat Completions)")}, - {QStringLiteral("OpenAI Responses"), QStringLiteral("OpenAI (Responses API)")}, - {QStringLiteral("LM Studio"), QStringLiteral("LM Studio (Chat Completions)")}, - {QStringLiteral("LM Studio Responses"), QStringLiteral("LM Studio (Responses API)")}, - }; - - const auto it = renames.constFind(oldName); - return it != renames.constEnd() ? it.value() : oldName; -} - -} // namespace QodeAssist::Settings diff --git a/settings/ProviderSettings.cpp b/settings/ProviderSettings.cpp index 651b744..7c9e243 100644 --- a/settings/ProviderSettings.cpp +++ b/settings/ProviderSettings.cpp @@ -4,14 +4,8 @@ #include "ProviderSettings.hpp" -#include -#include -#include -#include - #include "SettingsConstants.hpp" #include "SettingsTr.hpp" -#include "SettingsUtils.hpp" namespace QodeAssist::Settings { @@ -21,13 +15,19 @@ ProviderSettings &providerSettings() return settings; } -LegacyApiKeyEntry legacyApiKeyForClientApi(const QString &clientApi) +LegacyApiKeyEntry legacyApiKeyForClientApi(const QString &clientApi, const QString &instanceName) { ProviderSettings &s = providerSettings(); QString label; QString value; - if (clientApi == "Claude") { + if (instanceName == "Qwen") { + label = QStringLiteral("Qwen"); + value = s.qwenApiKey(); + } else if (instanceName == "DeepSeek") { + label = QStringLiteral("DeepSeek"); + value = s.deepSeekApiKey(); + } else if (clientApi == "Claude") { label = QStringLiteral("Claude"); value = s.claudeApiKey(); } else if (clientApi == "OpenRouter") { @@ -45,15 +45,15 @@ LegacyApiKeyEntry legacyApiKeyForClientApi(const QString &clientApi) } 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(); + } else if (clientApi == "Google AI") { + label = QStringLiteral("Google AI"); + value = s.googleAiApiKey(); } if (value.isEmpty()) @@ -67,233 +67,40 @@ ProviderSettings::ProviderSettings() setDisplayName(Tr::tr("Provider Settings")); - // OpenRouter Settings openRouterApiKey.setSettingsKey(Constants::OPEN_ROUTER_API_KEY); - openRouterApiKey.setLabelText(Tr::tr("OpenRouter API Key:")); - openRouterApiKey.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - openRouterApiKey.setPlaceHolderText(Tr::tr("Enter your API key here")); - openRouterApiKey.setHistoryCompleter(Constants::OPEN_ROUTER_API_KEY_HISTORY); openRouterApiKey.setDefaultValue(""); - openRouterApiKey.setAutoApply(true); - // OpenAI Compatible Settings openAiCompatApiKey.setSettingsKey(Constants::OPEN_AI_COMPAT_API_KEY); - openAiCompatApiKey.setLabelText(Tr::tr("OpenAI Compatible API Key:")); - openAiCompatApiKey.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - openAiCompatApiKey.setPlaceHolderText(Tr::tr("Enter your API key here")); - openAiCompatApiKey.setHistoryCompleter(Constants::OPEN_AI_COMPAT_API_KEY_HISTORY); openAiCompatApiKey.setDefaultValue(""); - openAiCompatApiKey.setAutoApply(true); - // Claude Compatible Settings claudeApiKey.setSettingsKey(Constants::CLAUDE_API_KEY); - claudeApiKey.setLabelText(Tr::tr("Claude API Key:")); - claudeApiKey.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - claudeApiKey.setPlaceHolderText(Tr::tr("Enter your API key here")); - claudeApiKey.setHistoryCompleter(Constants::CLAUDE_API_KEY_HISTORY); claudeApiKey.setDefaultValue(""); - claudeApiKey.setAutoApply(true); - claudeEnablePromptCaching.setSettingsKey(Constants::CLAUDE_ENABLE_PROMPT_CACHING); - claudeEnablePromptCaching.setLabelText(Tr::tr("Enable prompt caching")); - claudeEnablePromptCaching.setToolTip( - Tr::tr("Marks the system prompt, tool definitions, and stable chat history with " - "cache_control so Anthropic caches the request prefix (5-minute TTL). " - "Reduces cost and latency on repeated turns.")); - claudeEnablePromptCaching.setDefaultValue(false); - claudeEnablePromptCaching.setAutoApply(true); - - claudeUseExtendedCacheTTL.setSettingsKey(Constants::CLAUDE_USE_EXTENDED_CACHE_TTL); - claudeUseExtendedCacheTTL.setLabelText(Tr::tr("Use 1h cache TTL (beta)")); - claudeUseExtendedCacheTTL.setToolTip( - Tr::tr("Requests Anthropic's 1-hour cache TTL instead of the default 5 minutes. " - "Sends the extended-cache-ttl-2025-04-11 beta header.")); - claudeUseExtendedCacheTTL.setDefaultValue(false); - claudeUseExtendedCacheTTL.setAutoApply(true); - - // OpenAI Settings openAiApiKey.setSettingsKey(Constants::OPEN_AI_API_KEY); - openAiApiKey.setLabelText(Tr::tr("OpenAI API Key:")); - openAiApiKey.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - openAiApiKey.setPlaceHolderText(Tr::tr("Enter your API key here")); - openAiApiKey.setHistoryCompleter(Constants::OPEN_AI_API_KEY_HISTORY); openAiApiKey.setDefaultValue(""); - openAiApiKey.setAutoApply(true); - // MistralAI Settings mistralAiApiKey.setSettingsKey(Constants::MISTRAL_AI_API_KEY); - mistralAiApiKey.setLabelText(Tr::tr("Mistral AI API Key:")); - mistralAiApiKey.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - mistralAiApiKey.setPlaceHolderText(Tr::tr("Enter your API key here")); - mistralAiApiKey.setHistoryCompleter(Constants::MISTRAL_AI_API_KEY_HISTORY); mistralAiApiKey.setDefaultValue(""); - mistralAiApiKey.setAutoApply(true); codestralApiKey.setSettingsKey(Constants::CODESTRAL_API_KEY); - codestralApiKey.setLabelText(Tr::tr("Codestral API Key:")); - codestralApiKey.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - codestralApiKey.setPlaceHolderText(Tr::tr("Enter your API key here")); - codestralApiKey.setHistoryCompleter(Constants::CODESTRAL_API_KEY_HISTORY); codestralApiKey.setDefaultValue(""); - codestralApiKey.setAutoApply(true); - // GoogleAI Settings googleAiApiKey.setSettingsKey(Constants::GOOGLE_AI_API_KEY); - googleAiApiKey.setLabelText(Tr::tr("Google AI API Key:")); - googleAiApiKey.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - googleAiApiKey.setPlaceHolderText(Tr::tr("Enter your API key here")); - googleAiApiKey.setHistoryCompleter(Constants::GOOGLE_AI_API_KEY_HISTORY); googleAiApiKey.setDefaultValue(""); - googleAiApiKey.setAutoApply(true); - // Ollama with BasicAuth Settings ollamaBasicAuthApiKey.setSettingsKey(Constants::OLLAMA_BASIC_AUTH_API_KEY); - ollamaBasicAuthApiKey.setLabelText(Tr::tr("Ollama(Bearer) API Key:")); - ollamaBasicAuthApiKey.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - ollamaBasicAuthApiKey.setPlaceHolderText(Tr::tr("Enter your API key here")); - ollamaBasicAuthApiKey.setHistoryCompleter(Constants::OLLAMA_BASIC_AUTH_API_KEY_HISTORY); ollamaBasicAuthApiKey.setDefaultValue(""); - ollamaBasicAuthApiKey.setAutoApply(true); - // llama.cpp Settings llamaCppApiKey.setSettingsKey(Constants::LLAMA_CPP_API_KEY); - llamaCppApiKey.setLabelText(Tr::tr("llama.cpp API Key:")); - llamaCppApiKey.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - llamaCppApiKey.setPlaceHolderText(Tr::tr("Enter your API key here")); - llamaCppApiKey.setHistoryCompleter(Constants::LLAMA_CPP_API_KEY_HISTORY); llamaCppApiKey.setDefaultValue(""); - llamaCppApiKey.setAutoApply(true); - // Qwen (Alibaba) Settings qwenApiKey.setSettingsKey(Constants::QWEN_API_KEY); - qwenApiKey.setLabelText(Tr::tr("Qwen API Key:")); - qwenApiKey.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - qwenApiKey.setPlaceHolderText(Tr::tr("Enter your API key here")); - qwenApiKey.setHistoryCompleter(Constants::QWEN_API_KEY_HISTORY); qwenApiKey.setDefaultValue(""); - qwenApiKey.setAutoApply(true); - // DeepSeek Settings deepSeekApiKey.setSettingsKey(Constants::DEEPSEEK_API_KEY); - deepSeekApiKey.setLabelText(Tr::tr("DeepSeek API Key:")); - deepSeekApiKey.setDisplayStyle(Utils::StringAspect::LineEditDisplay); - deepSeekApiKey.setPlaceHolderText(Tr::tr("Enter your API key here")); - deepSeekApiKey.setHistoryCompleter(Constants::DEEPSEEK_API_KEY_HISTORY); deepSeekApiKey.setDefaultValue(""); - deepSeekApiKey.setAutoApply(true); - - resetToDefaults.m_buttonText = Tr::tr("Reset Page to Defaults"); readSettings(); - - setupConnections(); - - setLayouter([this]() { - using namespace Layouting; - - return Column{ - Row{Stretch{1}, resetToDefaults}, - Space{8}, - Group{title(Tr::tr("OpenRouter Settings")), Column{openRouterApiKey}}, - Space{8}, - Group{title(Tr::tr("OpenAI Settings")), Column{openAiApiKey}}, - Space{8}, - Group{title(Tr::tr("OpenAI Compatible Settings")), Column{openAiCompatApiKey}}, - Space{8}, - Group{ - title(Tr::tr("Claude Settings")), - Column{claudeApiKey, claudeEnablePromptCaching, claudeUseExtendedCacheTTL}}, - Space{8}, - Group{title(Tr::tr("Mistral AI Settings")), Column{mistralAiApiKey, codestralApiKey}}, - Space{8}, - Group{title(Tr::tr("Google AI Settings")), Column{googleAiApiKey}}, - Space{8}, - Group{title(Tr::tr("Ollama Settings")), Column{ollamaBasicAuthApiKey}}, - Space{8}, - Group{title(Tr::tr("llama.cpp Settings")), Column{llamaCppApiKey}}, - Space{8}, - Group{title(Tr::tr("Qwen (Alibaba) Settings")), Column{qwenApiKey}}, - Space{8}, - Group{title(Tr::tr("DeepSeek Settings")), Column{deepSeekApiKey}}, - Stretch{1}}; - }); } -void ProviderSettings::setupConnections() -{ - connect( - &resetToDefaults, &ButtonAspect::clicked, this, &ProviderSettings::resetSettingsToDefaults); - connect(&openRouterApiKey, &ButtonAspect::changed, this, [this]() { - openRouterApiKey.writeSettings(); - }); - connect(&openAiCompatApiKey, &ButtonAspect::changed, this, [this]() { - openAiCompatApiKey.writeSettings(); - }); - connect(&claudeApiKey, &ButtonAspect::changed, this, [this]() { claudeApiKey.writeSettings(); }); - connect(&claudeEnablePromptCaching, &Utils::BoolAspect::changed, this, [this]() { - claudeEnablePromptCaching.writeSettings(); - }); - connect(&claudeUseExtendedCacheTTL, &Utils::BoolAspect::changed, this, [this]() { - claudeUseExtendedCacheTTL.writeSettings(); - }); - connect(&openAiApiKey, &ButtonAspect::changed, this, [this]() { openAiApiKey.writeSettings(); }); - connect(&mistralAiApiKey, &ButtonAspect::changed, this, [this]() { - mistralAiApiKey.writeSettings(); - }); - connect(&codestralApiKey, &ButtonAspect::changed, this, [this]() { - codestralApiKey.writeSettings(); - }); - connect(&googleAiApiKey, &ButtonAspect::changed, this, [this]() { - googleAiApiKey.writeSettings(); - }); - connect(&ollamaBasicAuthApiKey, &ButtonAspect::changed, this, [this]() { - ollamaBasicAuthApiKey.writeSettings(); - }); - connect(&llamaCppApiKey, &ButtonAspect::changed, this, [this]() { - llamaCppApiKey.writeSettings(); - }); - connect(&qwenApiKey, &ButtonAspect::changed, this, [this]() { qwenApiKey.writeSettings(); }); - connect(&deepSeekApiKey, &ButtonAspect::changed, this, [this]() { - deepSeekApiKey.writeSettings(); - }); -} - -void ProviderSettings::resetSettingsToDefaults() -{ - QMessageBox::StandardButton reply; - reply = QMessageBox::question( - Core::ICore::dialogParent(), - Tr::tr("Reset Settings"), - Tr::tr("Are you sure you want to reset all settings to default values?"), - QMessageBox::Yes | QMessageBox::No); - - if (reply == QMessageBox::Yes) { - resetAspect(openRouterApiKey); - resetAspect(openAiCompatApiKey); - resetAspect(claudeApiKey); - resetAspect(claudeEnablePromptCaching); - resetAspect(claudeUseExtendedCacheTTL); - resetAspect(openAiApiKey); - resetAspect(mistralAiApiKey); - resetAspect(googleAiApiKey); - resetAspect(ollamaBasicAuthApiKey); - resetAspect(llamaCppApiKey); - resetAspect(qwenApiKey); - resetAspect(deepSeekApiKey); - writeSettings(); - } -} - -class ProviderSettingsPage : public Core::IOptionsPage -{ -public: - ProviderSettingsPage() - { - setId(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID); - setDisplayName(Tr::tr("Provider Settings")); - setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY); - setSettingsProvider([] { return &providerSettings(); }); - } -}; - } // namespace QodeAssist::Settings diff --git a/settings/ProviderSettings.hpp b/settings/ProviderSettings.hpp index 8bf68da..18e1676 100644 --- a/settings/ProviderSettings.hpp +++ b/settings/ProviderSettings.hpp @@ -6,8 +6,6 @@ #include -#include "ButtonAspect.hpp" - namespace QodeAssist::Settings { class ProviderSettings : public Utils::AspectContainer @@ -15,14 +13,9 @@ class ProviderSettings : public Utils::AspectContainer public: ProviderSettings(); - ButtonAspect resetToDefaults{this}; - - // API Keys Utils::StringAspect openRouterApiKey{this}; Utils::StringAspect openAiCompatApiKey{this}; Utils::StringAspect claudeApiKey{this}; - Utils::BoolAspect claudeEnablePromptCaching{this}; - Utils::BoolAspect claudeUseExtendedCacheTTL{this}; Utils::StringAspect openAiApiKey{this}; Utils::StringAspect mistralAiApiKey{this}; Utils::StringAspect codestralApiKey{this}; @@ -31,10 +24,6 @@ public: Utils::StringAspect llamaCppApiKey{this}; Utils::StringAspect qwenApiKey{this}; Utils::StringAspect deepSeekApiKey{this}; - -private: - void setupConnections(); - void resetSettingsToDefaults(); }; ProviderSettings &providerSettings(); @@ -45,6 +34,7 @@ struct LegacyApiKeyEntry QString value; }; -LegacyApiKeyEntry legacyApiKeyForClientApi(const QString &clientApi); +LegacyApiKeyEntry legacyApiKeyForClientApi( + const QString &clientApi, const QString &instanceName = {}); } // namespace QodeAssist::Settings diff --git a/settings/ProvidersSettingsPage.cpp b/settings/ProvidersSettingsPage.cpp index 1452306..45c483d 100644 --- a/settings/ProvidersSettingsPage.cpp +++ b/settings/ProvidersSettingsPage.cpp @@ -30,6 +30,9 @@ #include #include +#include +#include + #include "ProviderDetailPane.hpp" #include "ProviderInstance.hpp" #include "ProviderInstanceFactory.hpp" @@ -69,11 +72,13 @@ public: Providers::ProviderInstanceFactory *factory, Providers::ProviderSecretsStore *secrets, Providers::ProviderLauncher *launcher, - ProvidersPageNavigator *navigator) + ProvidersPageNavigator *navigator, + AgentFactory *agents) : m_factory(factory) , m_secrets(secrets) , m_launcher(launcher) , m_navigator(navigator) + , m_agents(agents) { m_titleLabel = new QLabel(tr("Providers"), this); QFont tf = m_titleLabel->font(); @@ -132,8 +137,11 @@ public: this, &ProvidersPageWidget::onApiKeySave); connect(m_detailPane, &ProviderDetailPane::apiKeyClearRequested, this, &ProvidersPageWidget::onApiKeyClear); - connect(m_detailPane, &ProviderDetailPane::apiKeyRevealRequested, - this, &ProvidersPageWidget::onApiKeyReveal); + connect( + m_detailPane, + &ProviderDetailPane::apiKeyRevealRequested, + this, + &ProvidersPageWidget::onApiKeyReveal); connect(m_detailPane, &ProviderDetailPane::launchStartRequested, this, &ProvidersPageWidget::onLaunchStart); connect(m_detailPane, &ProviderDetailPane::launchStopRequested, @@ -231,7 +239,7 @@ private slots: delete item; } m_rows.clear(); - m_listLayout->addStretch(1); // re-add trailing stretch + m_listLayout->addStretch(1); const QString filter = m_filterEdit->text().trimmed().toLower(); auto matches = [&](const Providers::ProviderInstance &inst) { @@ -242,7 +250,6 @@ private slots: || inst.url.toLower().contains(filter); }; - auto addSection = [&](const QString &title, bool userSection) { auto *header = makeSectionHeader(title, m_listContent); m_listLayout->insertWidget(m_listLayout->count() - 1, header); @@ -279,7 +286,8 @@ private slots: m_listContent); empty->setContentsMargins(10, 6, 10, 6); QPalette ep = empty->palette(); - ep.setColor(QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); + ep.setColor( + QPalette::WindowText, Utils::creatorColor(Utils::Theme::PanelTextColorMid)); empty->setPalette(ep); m_listLayout->insertWidget(m_listLayout->count() - 1, empty); } @@ -346,6 +354,18 @@ private slots: selectInstance(copy.name); } + QStringList agentsReferencing(const QString &providerName) const + { + QStringList out; + if (!m_agents) + return out; + for (const auto &cfg : m_agents->configs()) { + if (cfg.providerInstance == providerName) + out.append(cfg.name); + } + return out; + } + void onRemoveClicked() { if (!m_factory || m_currentName.isEmpty()) @@ -357,16 +377,26 @@ private slots: const QString instName = instPtr->name; const QString sourcePath = instPtr->sourcePath; - if (QMessageBox::question( - this, tr("Delete provider"), - tr("Delete user provider '%1'?\n\nFile: %2").arg(instName, sourcePath)) - != QMessageBox::Yes) + const QString apiKeyRef = instPtr->apiKeyRef; + + QString question = tr("Delete user provider '%1'?\n\nFile: %2").arg(instName, sourcePath); + const QStringList referencing = agentsReferencing(instName); + if (!referencing.isEmpty()) { + question += QStringLiteral("\n\n") + + tr("%n agent(s) reference this provider and will stop working:\n%1", + nullptr, + static_cast(referencing.size())) + .arg(referencing.join(QStringLiteral(", "))); + } + if (QMessageBox::question(this, tr("Delete provider"), question) != QMessageBox::Yes) return; if (!QFile::remove(sourcePath)) { QMessageBox::warning(this, tr("Delete provider"), tr("Failed to delete file:\n%1").arg(sourcePath)); return; } + if (m_secrets && !apiKeyRef.isEmpty()) + m_secrets->eraseKey(apiKeyRef); m_currentName.clear(); m_factory->reload(); m_detailPane->clear(); @@ -401,6 +431,21 @@ private slots: tr("An instance named '%1' already exists.").arg(e.name)); return; } + const QStringList referencing = agentsReferencing(priorName); + if (!referencing.isEmpty()) { + if (QMessageBox::warning( + this, + tr("Save"), + tr("%n agent(s) reference '%1' and will stop working until " + "updated to '%2':\n%3\n\nRename anyway?", + nullptr, + static_cast(referencing.size())) + .arg(priorName, e.name, referencing.join(QStringLiteral(", "))), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) + != QMessageBox::Yes) + return; + } } const QString softWarning = Providers::ProviderInstance::warnings(e); if (!softWarning.isEmpty()) { @@ -540,6 +585,7 @@ private: QPointer m_factory; QPointer m_secrets; QPointer m_navigator; + QPointer m_agents; QLabel *m_titleLabel = nullptr; QLineEdit *m_filterEdit = nullptr; @@ -598,13 +644,14 @@ public: Providers::ProviderInstanceFactory *factory, Providers::ProviderSecretsStore *secrets, Providers::ProviderLauncher *launcher, - ProvidersPageNavigator *navigator) + ProvidersPageNavigator *navigator, + AgentFactory *agents) { setId(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID); setDisplayName(QObject::tr("Providers")); setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY); - setWidgetCreator([factory, secrets, launcher, navigator] { - return new ProvidersPageWidget(factory, secrets, launcher, navigator); + setWidgetCreator([factory, secrets, launcher, navigator, agents] { + return new ProvidersPageWidget(factory, secrets, launcher, navigator, agents); }); } }; @@ -615,10 +662,11 @@ std::unique_ptr createProvidersSettingsPage( Providers::ProviderInstanceFactory *instanceFactory, Providers::ProviderSecretsStore *secrets, Providers::ProviderLauncher *launcher, - ProvidersPageNavigator *navigator) + ProvidersPageNavigator *navigator, + AgentFactory *agents) { return std::make_unique( - instanceFactory, secrets, launcher, navigator); + instanceFactory, secrets, launcher, navigator, agents); } } // namespace QodeAssist::Settings diff --git a/settings/ProvidersSettingsPage.hpp b/settings/ProvidersSettingsPage.hpp index 1d4b41f..bf13109 100644 --- a/settings/ProvidersSettingsPage.hpp +++ b/settings/ProvidersSettingsPage.hpp @@ -11,6 +11,10 @@ namespace Core { class IOptionsPage; } +namespace QodeAssist { +class AgentFactory; +} + namespace QodeAssist::Providers { class ProviderInstanceFactory; class ProviderSecretsStore; @@ -39,6 +43,7 @@ std::unique_ptr createProvidersSettingsPage( Providers::ProviderInstanceFactory *instanceFactory, Providers::ProviderSecretsStore *secrets, Providers::ProviderLauncher *launcher, - ProvidersPageNavigator *navigator); + ProvidersPageNavigator *navigator, + AgentFactory *agents); } // namespace QodeAssist::Settings diff --git a/settings/SettingsTheme.hpp b/settings/SettingsTheme.hpp index a165f0a..3dd8bb9 100644 --- a/settings/SettingsTheme.hpp +++ b/settings/SettingsTheme.hpp @@ -17,7 +17,6 @@ inline bool isDarkPalette(const QPalette &p) return p.color(QPalette::Window).lightness() < 128; } -// 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 double s = 1.0 - t; @@ -28,9 +27,6 @@ inline QColor mix(const QColor &a, const QColor &b, double 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)") diff --git a/settings/SettingsUiBuilders.cpp b/settings/SettingsUiBuilders.cpp index 336dc78..fa6225f 100644 --- a/settings/SettingsUiBuilders.cpp +++ b/settings/SettingsUiBuilders.cpp @@ -43,10 +43,10 @@ QLabel *makeSectionHeader(const QString &title, QWidget *parent) header->setAutoFillBackground(true); const QColor base = Utils::creatorColor(Utils::Theme::BackgroundColorNormal); const QColor mid = Utils::creatorColor(Utils::Theme::PanelTextColorMid); - header->setStyleSheet( - QStringLiteral("QLabel { background:%1; border-top:1px solid %2;" - " border-bottom:1px solid %2; }") - .arg(cssColor(mix(base, mid, 0.16)), cssColor(mix(base, mid, 0.30)))); + header->setStyleSheet(QStringLiteral( + "QLabel { background:%1; border-top:1px solid %2;" + " border-bottom:1px solid %2; }") + .arg(cssColor(mix(base, mid, 0.16)), cssColor(mix(base, mid, 0.30)))); return header; } diff --git a/settings/TagChip.cpp b/settings/TagChip.cpp index e64875b..c06b852 100644 --- a/settings/TagChip.cpp +++ b/settings/TagChip.cpp @@ -125,9 +125,9 @@ void TagChip::applyTheme() countColor = muted; } - setStyleSheet(QStringLiteral( - "#TagChip { background:%1; border:1px solid %2; border-radius:10px; }") - .arg(bg, cssColor(border))); + setStyleSheet( + QStringLiteral("#TagChip { background:%1; border:1px solid %2; border-radius:10px; }") + .arg(bg, cssColor(border))); QFont lf = m_label->font(); lf.setBold(m_active); diff --git a/settings/TagFilterStrip.cpp b/settings/TagFilterStrip.cpp index 1d90f7f..070febf 100644 --- a/settings/TagFilterStrip.cpp +++ b/settings/TagFilterStrip.cpp @@ -9,6 +9,7 @@ #include +#include #include #include #include @@ -22,7 +23,6 @@ #include #include #include -#include namespace QodeAssist::Settings { @@ -87,6 +87,8 @@ void TagFilterStrip::refreshActiveStates() { for (auto it = m_chipByTag.cbegin(); it != m_chipByTag.cend(); ++it) it.value()->setActive(m_activeTags.contains(it.key())); + if (m_clearLink) + m_clearLink->setVisible(!m_activeTags.isEmpty()); } void TagFilterStrip::applyTheme() @@ -96,8 +98,9 @@ void TagFilterStrip::applyTheme() QScopedValueRollback guard(m_inApplyTheme, true); 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; }") + setStyleSheet(QStringLiteral( + "QWidget#TagStrip { background:%1;" + " border-bottom:1px solid %2; }") .arg(bg, line)); } @@ -116,6 +119,7 @@ void TagFilterStrip::rebuild() delete item; } m_chipByTag.clear(); + m_clearLink = nullptr; if (m_counts.isEmpty()) { setVisible(false); @@ -141,17 +145,16 @@ void TagFilterStrip::rebuild() } headerLine->addWidget(andHint); headerLine->addStretch(1); - if (!m_activeTags.isEmpty()) { - auto *clear = new QLabel(QStringLiteral("%1").arg(tr("clear")), this); - connect(clear, &QLabel::linkActivated, this, [this](const QString &) { - if (m_activeTags.isEmpty()) - return; - m_activeTags.clear(); - refreshActiveStates(); - emit activeTagsChanged(m_activeTags); - }); - headerLine->addWidget(clear); - } + m_clearLink = new QLabel(QStringLiteral("%1").arg(tr("clear")), this); + m_clearLink->setVisible(!m_activeTags.isEmpty()); + connect(m_clearLink, &QLabel::linkActivated, this, [this](const QString &) { + if (m_activeTags.isEmpty()) + return; + m_activeTags.clear(); + refreshActiveStates(); + emit activeTagsChanged(m_activeTags); + }); + headerLine->addWidget(m_clearLink); m_layout->addLayout(headerLine); std::vector> sorted; diff --git a/settings/TagFilterStrip.hpp b/settings/TagFilterStrip.hpp index 6ffaaad..120d8ea 100644 --- a/settings/TagFilterStrip.hpp +++ b/settings/TagFilterStrip.hpp @@ -10,6 +10,7 @@ #include #include +class QLabel; class QVBoxLayout; namespace QodeAssist::Settings { @@ -23,10 +24,10 @@ public: explicit TagFilterStrip(QWidget *parent = nullptr); void setAvailableTags(const QMap &countsByTag); - void setAvailableTags( - const QMap &countsByTag, const QSet &activeTags); + void setAvailableTags(const QMap &countsByTag, const QSet &activeTags); void setVisibleCounts(const QMap &countsByTag); const QSet &activeTags() const { return m_activeTags; } + void toggleTag(const QString &tag); signals: void activeTagsChanged(const QSet &tags); @@ -38,12 +39,12 @@ private: void rebuild(); void refreshActiveStates(); void applyTheme(); - void toggleTag(const QString &tag); QMap m_counts; QSet m_activeTags; QVBoxLayout *m_layout = nullptr; QHash m_chipByTag; + QLabel *m_clearLink = nullptr; bool m_inApplyTheme = false; }; diff --git a/sources/Session/ContextAssembler.cpp b/sources/Session/ContextAssembler.cpp index 8a63cc7..e1cc369 100644 --- a/sources/Session/ContextAssembler.cpp +++ b/sources/Session/ContextAssembler.cpp @@ -22,9 +22,12 @@ 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"); + case Message::Role::System: + return QStringLiteral("system"); + case Message::Role::User: + return QStringLiteral("user"); + case Message::Role::Assistant: + return QStringLiteral("assistant"); } return QStringLiteral("user"); } @@ -70,7 +73,9 @@ QString Manifest::summary() const if (unsupportedBlocks > 0) s += QStringLiteral(", unsupported=%1").arg(unsupportedBlocks); if (!elided.isEmpty()) - s += QStringLiteral(", elided=%1 [%2]").arg(elided.size()).arg(elided.join(QStringLiteral("; "))); + s += QStringLiteral(", elided=%1 [%2]") + .arg(elided.size()) + .arg(elided.join(QStringLiteral("; "))); return s; } @@ -135,8 +140,7 @@ Templates::ContextData assemble( e.kind = ContentBlockEntry::Kind::Image; e.imageData = img->data(); e.mediaType = img->mediaType(); - e.isImageUrl - = (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url); + e.isImageUrl = (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url); blockEntries.append(std::move(e)); ++manifest.imageBlocks; } else if (auto *si = dynamic_cast(block)) { @@ -144,8 +148,7 @@ Templates::ContextData assemble( if (base64.isEmpty()) { blockEntries.append( makeTextEntry(placeholderFor(QStringLiteral("Image"), si->fileName()))); - manifest.elided - << QStringLiteral("image unavailable: %1").arg(si->fileName()); + manifest.elided << QStringLiteral("image unavailable: %1").arg(si->fileName()); qCWarning(ctxLog).noquote() << "stored image unavailable, placeholder inserted:" << si->fileName(); continue; @@ -165,8 +168,7 @@ Templates::ContextData assemble( manifest.elided << QStringLiteral("attachment unavailable: %1").arg(sa->fileName()); qCWarning(ctxLog).noquote() - << "stored attachment unavailable, placeholder inserted:" - << sa->fileName(); + << "stored attachment unavailable, placeholder inserted:" << sa->fileName(); continue; } const QString text = QString::fromUtf8(QByteArray::fromBase64(stored.toUtf8())); @@ -199,8 +201,7 @@ Templates::ContextData assemble( 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()); + manifest.elided << QStringLiteral("orphan tool_use dropped: %1").arg(tu->id()); continue; } ContentBlockEntry e; @@ -233,11 +234,11 @@ Templates::ContextData assemble( 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; - }); + 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; diff --git a/sources/Session/ConversationHistory.cpp b/sources/Session/ConversationHistory.cpp index c74c26b..de33130 100644 --- a/sources/Session/ConversationHistory.cpp +++ b/sources/Session/ConversationHistory.cpp @@ -33,7 +33,7 @@ void ConversationHistory::appendTextDeltaToLast(const QString &delta) return; auto &last = m_messages.back(); - if (auto *text = last.lastBlockOfType()) { + if (auto *text = dynamic_cast(last.lastBlock())) { text->appendText(delta); } else { last.appendBlock(std::make_unique(delta)); @@ -41,22 +41,16 @@ void ConversationHistory::appendTextDeltaToLast(const QString &delta) emit messageUpdated(static_cast(m_messages.size()) - 1); } -void ConversationHistory::appendThinkingDeltaToLast(const QString &delta, const QString &signature) +void ConversationHistory::appendThinkingBlockToLast(const QString &thinking, const QString &signature) { - if (m_messages.empty() || (delta.isEmpty() && signature.isEmpty())) + if (m_messages.empty() || (thinking.isEmpty() && signature.isEmpty())) return; auto &last = m_messages.back(); - auto *thinking = last.lastBlockOfType(); - if (!thinking) { - auto fresh = std::make_unique(delta, signature); - last.appendBlock(std::move(fresh)); - } else { - if (!delta.isEmpty()) - thinking->appendThinking(delta); - if (!signature.isEmpty()) - thinking->setSignature(signature); - } + if (thinking.isEmpty()) + last.appendBlock(std::make_unique(signature)); + else + last.appendBlock(std::make_unique(thinking, signature)); emit messageUpdated(static_cast(m_messages.size()) - 1); } diff --git a/sources/Session/ConversationHistory.hpp b/sources/Session/ConversationHistory.hpp index 0bb7979..bbee240 100644 --- a/sources/Session/ConversationHistory.hpp +++ b/sources/Session/ConversationHistory.hpp @@ -32,7 +32,7 @@ public: void appendBlockToLast(std::unique_ptr block); void appendTextDeltaToLast(const QString &delta); - void appendThinkingDeltaToLast(const QString &delta, const QString &signature = QString()); + void appendThinkingBlockToLast(const QString &thinking, const QString &signature = QString()); void clear(); void resetTo(int index); diff --git a/sources/Session/ErrorInfo.hpp b/sources/Session/ErrorInfo.hpp index e55386d..10b2731 100644 --- a/sources/Session/ErrorInfo.hpp +++ b/sources/Session/ErrorInfo.hpp @@ -43,14 +43,14 @@ struct ErrorInfo return text.contains(QLatin1String(needle)); }; - if (contains("401") || contains("403") || contains("unauthorized") - || contains("forbidden") || contains("api key") || contains("apikey") - || contains("authentication") || contains("invalid token")) + 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")) + || contains("could not resolve") || contains("unreachable") || contains("network") + || contains("ssl") || contains("refused")) return ErrorCategory::Network; return ErrorCategory::Provider; diff --git a/sources/Session/Message.hpp b/sources/Session/Message.hpp index 5b5e07c..4a8aec5 100644 --- a/sources/Session/Message.hpp +++ b/sources/Session/Message.hpp @@ -45,6 +45,16 @@ public: m_blocks.push_back(std::move(block)); } + LLMQore::ContentBlock *lastBlock() noexcept + { + return m_blocks.empty() ? nullptr : m_blocks.back().get(); + } + + std::vector> takeBlocks() noexcept + { + return std::move(m_blocks); + } + template T *lastBlockOfType() { diff --git a/sources/Session/MessageSerializer.cpp b/sources/Session/MessageSerializer.cpp index 05091cd..957908c 100644 --- a/sources/Session/MessageSerializer.cpp +++ b/sources/Session/MessageSerializer.cpp @@ -165,7 +165,7 @@ std::unique_ptr blockFromJson(const QJsonObject &obj) FileEditContent::statusFromString(obj.value("status").toString()), obj.value("statusMessage").toString()); } - return nullptr; // unknown type — skipped + return nullptr; } } // namespace @@ -179,8 +179,11 @@ QJsonObject MessageSerializer::toJson(const Message &message) QJsonArray blocks; for (const auto &b : message.blocks()) { - if (b) - blocks.append(blockToJson(*b)); + if (!b) + continue; + const QJsonObject blockObj = blockToJson(*b); + if (!blockObj.isEmpty()) + blocks.append(blockObj); } obj["blocks"] = blocks; return obj; diff --git a/sources/Session/ResponseEvent.hpp b/sources/Session/ResponseEvent.hpp index e12fb3b..4f21367 100644 --- a/sources/Session/ResponseEvent.hpp +++ b/sources/Session/ResponseEvent.hpp @@ -144,8 +144,7 @@ public: ResponseEvents::Usage{inputTokens, outputTokens, cachedTokens, reasoningTokens}}; } - static ResponseEvent error( - QString message, ErrorCategory category = ErrorCategory::Provider) + static ResponseEvent error(QString message, ErrorCategory category = ErrorCategory::Provider) { return {Kind::Error, ResponseEvents::Error{std::move(message), category}}; } diff --git a/sources/Session/ResponseRouter.cpp b/sources/Session/ResponseRouter.cpp index 0e8b703..f5e8fed 100644 --- a/sources/Session/ResponseRouter.cpp +++ b/sources/Session/ResponseRouter.cpp @@ -102,7 +102,7 @@ void ResponseRouter::onThinking( return; ensureAssistantOpen(); if (m_history) - m_history->appendThinkingDeltaToLast(thinking, signature); + m_history->appendThinkingBlockToLast(thinking, signature); emit event(ResponseEvent::thinkingDelta(thinking, signature)); } @@ -153,11 +153,12 @@ 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::usage( + info.usage->promptTokens, + info.usage->completionTokens, + info.usage->cachedPromptTokens, + info.usage->reasoningTokens)); } emit event(ResponseEvent::messageStop(info.stopReason)); endRequest(); diff --git a/sources/Session/Session.cpp b/sources/Session/Session.cpp index fcb39e1..02a59fa 100644 --- a/sources/Session/Session.cpp +++ b/sources/Session/Session.cpp @@ -28,13 +28,14 @@ namespace { } // namespace Session::Session(Agent *agent, QObject *parent) - : Session(agent, /*externalHistory=*/nullptr, parent) + : Session(agent, nullptr, parent) {} Session::Session(Agent *agent, ConversationHistory *externalHistory, QObject *parent) : QObject(parent) , m_history(externalHistory ? externalHistory : new ConversationHistory(this)) , m_systemPrompt(new SystemPromptBuilder(this)) + , m_externalHistory(externalHistory != nullptr) { if (agent) setAgent(agent); @@ -45,15 +46,16 @@ void Session::setAgent(Agent *agent) if (agent == m_agent) return; - if (isInFlight()) - teardownInFlight(); + cancel(); if (m_router) { - delete m_router; + m_router->disconnect(this); + m_router->deleteLater(); m_router = nullptr; } - delete m_agent; + if (m_agent) + m_agent->deleteLater(); m_agent = agent; m_invalidReason.clear(); @@ -84,8 +86,7 @@ void Session::setAgent(Agent *agent) Session::~Session() { - if (isInFlight()) - teardownInFlight(); + cancel(); } bool Session::isValid() const noexcept @@ -154,6 +155,11 @@ void Session::unpinContext(const QString &id) std::erase_if(m_pinnedProviders, [&id](const auto &entry) { return entry.first == id; }); } +void Session::clearPinnedContext() +{ + m_pinnedProviders.clear(); +} + LLMQore::RequestID Session::send(std::vector> userBlocks) { if (!canSend()) { @@ -165,19 +171,24 @@ LLMQore::RequestID Session::send(std::vectorsize(); Message msg(Message::Role::User); for (auto &b : userBlocks) msg.appendBlock(std::move(b)); m_history->append(std::move(msg)); - return dispatch(); + const auto id = dispatch(); + if (id.isEmpty()) + m_history->resetTo(preSendSize); + return id; } QVector Session::materializePinned() const @@ -223,8 +234,8 @@ LLMQore::RequestID Session::dispatch() m_systemPrompt->clearLayer(QStringLiteral("agent.system")); } else { QString renderErr; - const QString renderedContext = Templates::ContextRenderer::render( - cfg.systemPrompt, m_contextBindings, &renderErr); + const QString renderedContext + = Templates::ContextRenderer::render(cfg.systemPrompt, m_contextBindings, &renderErr); if (!renderErr.isEmpty()) { m_lastError = makeError( ErrorCategory::Validation, @@ -237,7 +248,9 @@ LLMQore::RequestID Session::dispatch() m_systemPrompt->clearLayer(QStringLiteral("agent.system")); else m_systemPrompt->setLayer( - QStringLiteral("agent.system"), renderedContext, SystemPromptBuilder::kAgentPriority); + QStringLiteral("agent.system"), + renderedContext, + SystemPromptBuilder::kAgentPriority); } return dispatchContext(assembleContext(), cfg.enableTools); @@ -259,13 +272,37 @@ LLMQore::RequestID Session::dispatchContext(const Templates::ContextData &ctx, b QString endpoint = cfg.endpoint; endpoint.replace(QStringLiteral("${MODEL}"), cfg.model); + + LLMQore::RequestID earlyFailId; + QString earlyFailError; + QMetaObject::Connection earlyFailConn; + if (auto *cl = client()) { + earlyFailConn = connect( + cl, + &LLMQore::BaseClient::requestFailed, + this, + [&earlyFailId, &earlyFailError](const LLMQore::RequestID &fid, const QString &err) { + earlyFailId = fid; + earlyFailError = err; + }, + Qt::DirectConnection); + } + const auto id = provider->sendRequest(QUrl(provider->url()), payload, endpoint); + + if (earlyFailConn) + disconnect(earlyFailConn); + if (id.isEmpty()) { m_lastError = makeError( ErrorCategory::Provider, QStringLiteral("Provider '%1' failed to start the request").arg(provider->name())); return {}; } + if (id == earlyFailId && !earlyFailError.isEmpty()) { + m_lastError = makeError(categorizeProviderError(earlyFailError), earlyFailError); + return {}; + } m_inFlight = id; if (m_router) @@ -274,8 +311,7 @@ LLMQore::RequestID Session::dispatchContext(const Templates::ContextData &ctx, b return id; } -QJsonObject Session::buildPayload( - const Templates::ContextData &ctx, bool tools, QString *errOut) const +QJsonObject Session::buildPayload(const Templates::ContextData &ctx, bool tools, QString *errOut) const { auto *provider = m_agent->provider(); auto *tmpl = m_agent->promptTemplate(); @@ -302,7 +338,7 @@ Templates::ContextData Session::assembleContext() const void Session::onRouterEvent(const ResponseEvent &ev) { if (m_inFlight.isEmpty()) - return; // stale events after cancel + return; emit event(ev); diff --git a/sources/Session/Session.hpp b/sources/Session/Session.hpp index f95949c..fbf39b1 100644 --- a/sources/Session/Session.hpp +++ b/sources/Session/Session.hpp @@ -56,10 +56,12 @@ public: using PinnedProvider = std::function; void pinContext(const QString &id, PinnedProvider provider); void unpinContext(const QString &id); + void clearPinnedContext(); Agent *agent() noexcept { return m_agent; } void setAgent(Agent *agent); ConversationHistory *history() const noexcept { return m_history; } + bool usesExternalHistory() const noexcept { return m_externalHistory; } SystemPromptBuilder *systemPrompt() const noexcept { return m_systemPrompt; } LLMQore::BaseClient *client() const noexcept; @@ -89,10 +91,11 @@ private: 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 - SystemPromptBuilder *m_systemPrompt = nullptr; // child - ResponseRouter *m_router = nullptr; // child, only when valid + Agent *m_agent = nullptr; + QPointer m_history; + SystemPromptBuilder *m_systemPrompt = nullptr; + ResponseRouter *m_router = nullptr; + bool m_externalHistory = false; LLMQore::RequestID m_inFlight; QString m_invalidReason; diff --git a/sources/Session/SessionManager.cpp b/sources/Session/SessionManager.cpp index 98b3f6c..054cb27 100644 --- a/sources/Session/SessionManager.cpp +++ b/sources/Session/SessionManager.cpp @@ -27,7 +27,7 @@ SessionManager::~SessionManager() = default; Session *SessionManager::createSession(const QString &agentName, QString *errorOut) { - return createSession(agentName, /*externalHistory=*/nullptr, errorOut); + return createSession(agentName, nullptr, errorOut); } Session *SessionManager::createSession( @@ -40,7 +40,7 @@ Session *SessionManager::createSession( } QString agentErr; - Agent *agent = m_agentFactory->create(agentName, /*parent=*/nullptr, &agentErr); + Agent *agent = m_agentFactory->create(agentName, nullptr, &agentErr); if (!agent) { if (errorOut) *errorOut = agentErr.isEmpty() @@ -53,7 +53,7 @@ Session *SessionManager::createSession( if (!session->isValid()) { if (errorOut) *errorOut = session->invalidReason(); - delete session; // also deletes the reparented agent + delete session; return nullptr; } @@ -64,7 +64,7 @@ Session *SessionManager::createSession( Session *SessionManager::createDetachedSession(ConversationHistory *externalHistory, QObject *parent) { - return new Session(/*agent=*/nullptr, externalHistory, parent); + return new Session(nullptr, externalHistory, parent); } bool SessionManager::rebindAgentByName(Session *session, const QString &agentName, QString *errorOut) @@ -81,7 +81,7 @@ bool SessionManager::rebindAgentByName(Session *session, const QString &agentNam } QString agentErr; - Agent *agent = m_agentFactory->create(agentName, /*parent=*/nullptr, &agentErr); + Agent *agent = m_agentFactory->create(agentName, nullptr, &agentErr); if (!agent) { if (errorOut) *errorOut = agentErr.isEmpty() @@ -113,7 +113,7 @@ Session *SessionManager::acquire(const QString &agentName, QString *errorOut) pooled->deleteLater(); } - return createSession(agentName, /*externalHistory=*/nullptr, errorOut); + return createSession(agentName, nullptr, errorOut); } void SessionManager::release(Session *session) @@ -130,10 +130,16 @@ void SessionManager::release(Session *session) session->cancel(); session->disconnect(); + + if (session->usesExternalHistory()) { + emit sessionRemoved(session); + session->deleteLater(); + return; + } + resetSession(session); - const QString agentName - = session->agent() ? session->agent()->config().name : QString(); + const QString agentName = session->agent() ? session->agent()->config().name : QString(); QList> &bucket = m_pool[agentName]; if (agentName.isEmpty() || bucket.size() >= kMaxPooledPerAgent) { emit sessionRemoved(session); @@ -155,6 +161,9 @@ void SessionManager::resetSession(Session *session) if (auto *tools = client->tools()) tools->removeAllTools(); } + session->setContentLoader({}); + session->setContextBindings({}); + session->clearPinnedContext(); } bool SessionManager::pooledAgentMatchesCurrent(Session *session, const QString &agentName) const diff --git a/sources/Session/SessionManager.hpp b/sources/Session/SessionManager.hpp index d5344a7..e588725 100644 --- a/sources/Session/SessionManager.hpp +++ b/sources/Session/SessionManager.hpp @@ -53,8 +53,7 @@ signals: private: void resetSession(Session *session); void flushPool(); - [[nodiscard]] bool pooledAgentMatchesCurrent( - Session *session, const QString &agentName) const; + [[nodiscard]] bool pooledAgentMatchesCurrent(Session *session, const QString &agentName) const; static constexpr int kMaxPooledPerAgent = 2; diff --git a/sources/Session/SystemPromptBuilder.cpp b/sources/Session/SystemPromptBuilder.cpp index 05fb023..f5c45ea 100644 --- a/sources/Session/SystemPromptBuilder.cpp +++ b/sources/Session/SystemPromptBuilder.cpp @@ -49,7 +49,8 @@ void SystemPromptBuilder::clear() QString SystemPromptBuilder::layer(const QString &name) const { for (const auto &l : m_layers) { - if (l.name == name) return l.text; + if (l.name == name) + return l.text; } return {}; } @@ -58,16 +59,17 @@ QStringList SystemPromptBuilder::layerNames() const { QStringList out; out.reserve(m_layers.size()); - for (const auto &l : m_layers) out.append(l.name); + 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; }); + std::stable_sort(ordered.begin(), ordered.end(), [](const Layer &a, const Layer &b) { + return a.priority < b.priority; + }); QStringList parts; parts.reserve(ordered.size()); diff --git a/sources/agents/Agent.cpp b/sources/agents/Agent.cpp index dc5eebd..8516786 100644 --- a/sources/agents/Agent.cpp +++ b/sources/agents/Agent.cpp @@ -56,7 +56,8 @@ Agent::Agent(AgentConfig config, Providers::Provider *providerOwned, QObject *pa } m_provider->setParent(this); m_provider->setPromptCaching( - m_config.cachePrompt, m_config.cacheTtl == QLatin1StringView{"1h"}, + m_config.cachePrompt, + m_config.cacheTtl == QLatin1StringView{"1h"}, m_config.cacheBreakpoints); QString tmplErr; diff --git a/sources/agents/Agent.hpp b/sources/agents/Agent.hpp index d098358..a994452 100644 --- a/sources/agents/Agent.hpp +++ b/sources/agents/Agent.hpp @@ -46,8 +46,8 @@ public: private: AgentConfig m_config; - std::unique_ptr m_promptTemplate; // owned - Providers::Provider *m_provider = nullptr; // child of this + std::unique_ptr m_promptTemplate; + Providers::Provider *m_provider = nullptr; QString m_invalidReason; }; diff --git a/sources/agents/AgentLoader.cpp b/sources/agents/AgentLoader.cpp index db95de6..f5f5fdf 100644 --- a/sources/agents/AgentLoader.cpp +++ b/sources/agents/AgentLoader.cpp @@ -124,8 +124,8 @@ AgentConfig configFromMerged(const QJsonObject &obj) 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.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")); @@ -153,26 +153,34 @@ 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")}; + 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)); + warnings.append( + QStringLiteral("Unknown key '%1' in %2 — ignored (typo?)").arg(it.key(), filePath)); } } const QJsonObject matchObj = obj.value("match").toObject(); @@ -183,12 +191,13 @@ void lintUnknownKeys(const QJsonObject &obj, const QString &filePath, QStringLis } } - static const QSet kCacheBreakpoints = { - QStringLiteral("system"), QStringLiteral("tools"), QStringLiteral("history")}; + 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)") + warnings.append(QStringLiteral( + "Unknown cache_breakpoint '%1' in %2 — ignored " + "(use system/tools/history)") .arg(bp.toString(), filePath)); } } @@ -201,10 +210,12 @@ void scanDir( QStringList &errors, QStringList *warnings) { - if (dir.isEmpty()) return; + if (dir.isEmpty()) + return; QDir d(dir); - if (!d.exists()) return; - const QStringList files = d.entryList({"*.toml"}, QDir::Files); + if (!d.exists()) + return; + const QStringList files = d.entryList({"*.toml"}, QDir::Files, QDir::Name); for (const QString &fname : files) { const QString fullPath = d.filePath(fname); QString err; @@ -222,17 +233,16 @@ void scanDir( 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)); + 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)); + 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}); } @@ -251,7 +261,7 @@ QJsonObject mergeChild(const QJsonObject &parentMerged, const QJsonObject &self, return merged; } -QJsonObject resolveExtends( +std::optional resolveExtends( const QString &name, const QHash &raw, QSet &visiting, @@ -262,15 +272,15 @@ QJsonObject resolveExtends( errors.append(QStringLiteral("Agent extends chain too deep (>%1) at '%2'") .arg(kMaxExtendsDepth) .arg(name)); - return {}; + return std::nullopt; } if (visiting.contains(name)) { errors.append(QStringLiteral("Cyclic 'extends' involving agent '%1'").arg(name)); - return {}; + return std::nullopt; } if (!raw.contains(name)) { errors.append(QStringLiteral("Unknown agent '%1'").arg(name)); - return {}; + return std::nullopt; } visiting.insert(name); @@ -281,11 +291,14 @@ QJsonObject resolveExtends( errors.append(QStringLiteral("Agent '%1' extends unknown agent '%2' (%3)") .arg(name, parent, raw.value(name).filePath)); visiting.remove(name); - return {}; + return std::nullopt; } - const QJsonObject parentMerged - = resolveExtends(parent, raw, visiting, errors, depth + 1); - self = mergeChild(parentMerged, self, name); + const auto parentMerged = resolveExtends(parent, raw, visiting, errors, depth + 1); + if (!parentMerged) { + visiting.remove(name); + return std::nullopt; + } + self = mergeChild(*parentMerged, self, name); } visiting.remove(name); return self; @@ -294,17 +307,15 @@ QJsonObject resolveExtends( } // namespace std::optional AgentLoader::parseFile( - const QString &path, - const QString &qrcPrefix, - QString *error, - QStringList *warnings) + const QString &path, const QString &qrcPrefix, QString *error, QStringList *warnings) { auto objOpt = parseTomlFile(path, error); if (!objOpt) return std::nullopt; const QString name = objOpt->value("name").toString(); if (name.isEmpty()) { - if (error) *error = QStringLiteral("Agent at %1 has no 'name'").arg(path); + if (error) + *error = QStringLiteral("Agent at %1 has no 'name'").arg(path); return std::nullopt; } if (warnings) @@ -312,28 +323,40 @@ std::optional AgentLoader::parseFile( 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}); + scanDir(qrcPrefix, false, raw, scanErrors, nullptr); - QSet visiting; - QStringList resolveErrors; - const QJsonObject merged = resolveExtends(name, raw, visiting, resolveErrors); - if (!resolveErrors.isEmpty() || merged.isEmpty()) { + const auto bundled = raw.constFind(name); + if (bundled != raw.constEnd() && !bundled->isUserLayer) { if (error) { - *error = resolveErrors.isEmpty() - ? QStringLiteral("Agent '%1' resolved to an empty config").arg(name) - : resolveErrors.join(QStringLiteral("; ")); + *error = 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, path); } return std::nullopt; } - AgentConfig cfg = configFromMerged(merged); + scanDir(QFileInfo(path).absolutePath(), true, raw, scanErrors, nullptr); + raw.insert(name, {*objOpt, path, true}); + + QSet visiting; + QStringList resolveErrors; + const auto merged = resolveExtends(name, raw, visiting, resolveErrors); + if (!merged) { + if (error) + *error = 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); + *error = QStringLiteral( + "Agent '%1' is abstract — extend it instead of " + "loading it directly") + .arg(name); } return std::nullopt; } @@ -345,8 +368,8 @@ AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QStrin LoadResult result; QHash raw; - scanDir(qrcPrefix, /*isUserLayer=*/false, raw, result.errors, &result.warnings); - scanDir(userDir, /*isUserLayer=*/true, raw, result.errors, &result.warnings); + scanDir(qrcPrefix, false, raw, result.errors, &result.warnings); + scanDir(userDir, true, raw, result.errors, &result.warnings); for (auto it = raw.constBegin(); it != raw.constEnd(); ++it) result.sourcePathByName.insert(it.key(), it.value().filePath); @@ -355,10 +378,11 @@ AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QStrin const QString &name = it.key(); QSet visiting; - const QJsonObject merged = resolveExtends(name, raw, visiting, result.errors); - if (merged.isEmpty()) continue; + const auto merged = resolveExtends(name, raw, visiting, result.errors); + if (!merged) + continue; - AgentConfig cfg = configFromMerged(merged); + AgentConfig cfg = configFromMerged(*merged); cfg.sourcePath = it.value().filePath; if (cfg.abstract) continue; diff --git a/sources/agents/AgentLoader.hpp b/sources/agents/AgentLoader.hpp index 80f3eb6..f68e34e 100644 --- a/sources/agents/AgentLoader.hpp +++ b/sources/agents/AgentLoader.hpp @@ -4,10 +4,10 @@ #pragma once +#include #include #include #include -#include #include "AgentConfig.hpp" diff --git a/sources/agents/AgentRouter.cpp b/sources/agents/AgentRouter.cpp index 58607c6..f54017f 100644 --- a/sources/agents/AgentRouter.cpp +++ b/sources/agents/AgentRouter.cpp @@ -24,11 +24,11 @@ QRegularExpression compiledGlob(const QString &pattern) const auto it = cache.constFind(pattern); if (it != cache.constEnd()) return *it; - const QRegularExpression re( - QRegularExpression::anchoredPattern( - QRegularExpression::wildcardToRegularExpression( - pattern, QRegularExpression::NonPathWildcardConversion)), - QRegularExpression::CaseInsensitiveOption); + const QRegularExpression + re(QRegularExpression::anchoredPattern( + QRegularExpression::wildcardToRegularExpression( + pattern, QRegularExpression::NonPathWildcardConversion)), + QRegularExpression::CaseInsensitiveOption); cache.insert(pattern, re); return re; } @@ -67,11 +67,9 @@ bool matchesPathPatterns(const QStringList &patterns, const QString &filePath) bool matchesProjectNames(const QStringList &names, const QString &projectName) { if (names.isEmpty()) - return true; // dimension unconstrained + return true; if (projectName.isEmpty()) return false; - // Project names are user-facing identifiers, not paths — case - // sensitive comparison matches what ProjectExplorer hands us. return names.contains(projectName); } @@ -80,7 +78,7 @@ bool matchesProjectNames(const QStringList &names, const QString &projectName) bool matches(const AgentConfig::Match &m, const Context &ctx) { if (m.isEmpty()) - return true; // explicit catch-all + return true; return matchesFilePatterns(m.filePatterns, ctx.filePath) && matchesPathPatterns(m.pathPatterns, ctx.filePath) && matchesProjectNames(m.projectNames, ctx.projectName); @@ -92,7 +90,7 @@ QString pickAgent( for (const QString &name : roster) { const AgentConfig *cfg = factory.configByName(name); if (!cfg) - continue; // stale roster entry — silently skip + continue; if (matches(cfg->match, ctx)) return name; } diff --git a/sources/agents/ContextRenderer.cpp b/sources/agents/ContextRenderer.cpp index 5d38f98..e464bf7 100644 --- a/sources/agents/ContextRenderer.cpp +++ b/sources/agents/ContextRenderer.cpp @@ -61,11 +61,18 @@ QString expandAndResolvePath(const QString &raw, const Bindings &b) return p; } +bool hasUnresolvedVars(const QString &p) +{ + return p.contains(QStringLiteral("${PROJECT_DIR}")) + || p.contains(QStringLiteral("${CONFIG_DIR}")); +} + [[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") + QStringLiteral( + "%1: path is outside the allowed read roots " + "(the project directory, ~/qodeassist, or bundled :/ resources): %2") .arg(QString::fromLatin1(fn), path) .toStdString()); } @@ -77,6 +84,13 @@ void registerReadFile(inja::Environment &env, const Bindings &b) const QString path = expandAndResolvePath(QString::fromStdString(args.at(0)->get()), caps); + if (hasUnresolvedVars(path)) { + throw std::runtime_error(QStringLiteral( + "read_file: ${PROJECT_DIR}/${CONFIG_DIR} is not available " + "(no project open?): %1") + .arg(path) + .toStdString()); + } if (!isPathAllowed(path, caps)) throwOutsideRoots("read_file", path); @@ -97,6 +111,8 @@ void registerFileExists(inja::Environment &env, const Bindings &b) env.add_callback("file_exists", 1, [caps](inja::Arguments &args) -> nlohmann::json { const QString p = expandAndResolvePath(QString::fromStdString(args.at(0)->get()), caps); + if (hasUnresolvedVars(p)) + return false; if (!isPathAllowed(p, caps)) throwOutsideRoots("file_exists", p); return QFileInfo::exists(p); @@ -110,6 +126,8 @@ void registerReadDir(inja::Environment &env, const Bindings &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 (hasUnresolvedVars(p)) + return nlohmann::json::array(); if (!isPathAllowed(p, caps)) throwOutsideRoots("read_dir", p); QDir dir(p); diff --git a/sources/agents/agents.qrc b/sources/agents/agents.qrc index 9359bc0..b25ae72 100644 --- a/sources/agents/agents.qrc +++ b/sources/agents/agents.qrc @@ -16,6 +16,8 @@ openai_completion.toml openai_compression.toml openai_quick_refactor.toml + deepseek_chat.toml + qwen_chat.toml google_base_chat.toml google_chat.toml google_completion.toml diff --git a/sources/agents/deepseek_chat.toml b/sources/agents/deepseek_chat.toml new file mode 100644 index 0000000..0b9587f --- /dev/null +++ b/sources/agents/deepseek_chat.toml @@ -0,0 +1,16 @@ +schema_version = 1 + +extends = "OpenAI Base Chat" +name = "DeepSeek Chat" +description = "DeepSeek coding chat (deepseek-chat, V4 family) over the OpenAI-compatible Chat Completions API — conversational assistant with IDE tool use." + +provider_instance = "DeepSeek" +model = "deepseek-chat" +enable_tools = true +tags = ["chat", "deepseek", "cloud"] + +system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}""" + +[body] +max_tokens = 8192 +temperature = 0.3 diff --git a/sources/agents/ollama_chat_gemma4.toml b/sources/agents/ollama_chat_gemma4.toml index 9f038e8..bc0a198 100644 --- a/sources/agents/ollama_chat_gemma4.toml +++ b/sources/agents/ollama_chat_gemma4.toml @@ -12,11 +12,11 @@ tags = ["chat", "ollama", "local", "16gb"] system_prompt = """{{ read_file(":/roles/agentic-coder.md") }}""" [body] -think = true +think = true +keep_alive = "5m" [body.options] temperature = 1.0 top_p = 0.95 top_k = 64 num_predict = 8096 -keep_alive = "5m" diff --git a/sources/agents/ollama_chat_simple.toml b/sources/agents/ollama_chat_simple.toml index 284ee6d..400e592 100644 --- a/sources/agents/ollama_chat_simple.toml +++ b/sources/agents/ollama_chat_simple.toml @@ -2,14 +2,16 @@ schema_version = 1 extends = "Ollama Base Chat" name = "Ollama Chat — Simple" -description = "Local Ollama coding chat for any model — plain conversational assistant, no tools, no thinking (Qwen2.5-Coder 7B by default)." +description = "Local Ollama coding chat for any model — plain conversational assistant, no tools, no thinking (Qwen3.5 4B by default)." model = "qwen3.5:4b" tags = ["chat", "ollama", "local", "8gb"] system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}""" +[body] +keep_alive = "5m" + [body.options] num_predict = 2048 temperature = 0.7 -keep_alive = "5m" diff --git a/sources/agents/ollama_chat_thinking.toml b/sources/agents/ollama_chat_thinking.toml index 16f0d40..2b30f13 100644 --- a/sources/agents/ollama_chat_thinking.toml +++ b/sources/agents/ollama_chat_thinking.toml @@ -12,8 +12,8 @@ tags = ["chat", "ollama", "local", "16gb"] system_prompt = """{{ read_file(":/roles/agentic-coder.md") }}""" [body] -think = true +think = true +keep_alive = "5m" [body.options] num_predict = 8192 -keep_alive = "5m" diff --git a/sources/agents/ollama_completion_chat.toml b/sources/agents/ollama_completion_chat.toml index e32f160..df87d60 100644 --- a/sources/agents/ollama_completion_chat.toml +++ b/sources/agents/ollama_completion_chat.toml @@ -15,6 +15,7 @@ system_prompt = """ {{ read_file(":/tasks/code-completion.md") }}""" [body] +keep_alive = "5m" messages = """ [ {% if existsIn(ctx, "system_prompt") %} @@ -27,5 +28,4 @@ messages = """ [body.options] num_predict = 256 temperature = 0.2 -keep_alive = "5m" stop = [""] diff --git a/sources/agents/ollama_completion_fim.toml b/sources/agents/ollama_completion_fim.toml index eaa6521..0b7f2cb 100644 --- a/sources/agents/ollama_completion_fim.toml +++ b/sources/agents/ollama_completion_fim.toml @@ -7,7 +7,9 @@ description = "Native fill-in-the-middle completion — uses the model's OWN FIM model = "qwen2.5-coder:7b-base-q5_K_M" tags = ["completion", "ollama", "local", "fim", "8gb"] +[body] +keep_alive = "5m" + [body.options] num_predict = 256 temperature = 0 -keep_alive = "5m" diff --git a/sources/agents/ollama_compression_16gb.toml b/sources/agents/ollama_compression_16gb.toml index e0e63d0..55ee1d4 100644 --- a/sources/agents/ollama_compression_16gb.toml +++ b/sources/agents/ollama_compression_16gb.toml @@ -11,10 +11,10 @@ tags = ["compression", "ollama", "local", "16gb"] system_prompt = """{{ read_file(":/tasks/chat-compressor.md") }}""" [body] -think = false +think = false +keep_alive = "5m" [body.options] num_predict = 2048 temperature = 0.3 num_ctx = 8192 -keep_alive = "5m" diff --git a/sources/agents/ollama_compression_32gb.toml b/sources/agents/ollama_compression_32gb.toml index f1362d5..2e30838 100644 --- a/sources/agents/ollama_compression_32gb.toml +++ b/sources/agents/ollama_compression_32gb.toml @@ -11,10 +11,10 @@ tags = ["compression", "ollama", "local", "32gb"] system_prompt = """{{ read_file(":/tasks/chat-compressor.md") }}""" [body] -think = false +think = false +keep_alive = "5m" [body.options] num_predict = 2048 temperature = 0.3 num_ctx = 24576 -keep_alive = "5m" diff --git a/sources/agents/ollama_compression_8gb.toml b/sources/agents/ollama_compression_8gb.toml index 0f4e341..70f6ca3 100644 --- a/sources/agents/ollama_compression_8gb.toml +++ b/sources/agents/ollama_compression_8gb.toml @@ -11,10 +11,10 @@ tags = ["compression", "ollama", "local", "8gb"] system_prompt = """{{ read_file(":/tasks/chat-compressor.md") }}""" [body] -think = false +think = false +keep_alive = "5m" [body.options] num_predict = 2048 temperature = 0.3 num_ctx = 8192 -keep_alive = "5m" diff --git a/sources/agents/ollama_quick_refactor_gemma4.toml b/sources/agents/ollama_quick_refactor_gemma4.toml index 571bdc1..5f85980 100644 --- a/sources/agents/ollama_quick_refactor_gemma4.toml +++ b/sources/agents/ollama_quick_refactor_gemma4.toml @@ -12,8 +12,8 @@ tags = ["refactor", "ollama", "local", "16gb"] system_prompt = """{{ read_file(":/tasks/quick-refactor.md") }}""" [body] -think = true +think = true +keep_alive = "5m" [body.options] num_predict = 4096 -keep_alive = "5m" diff --git a/sources/agents/ollama_quick_refactor_qwen35.toml b/sources/agents/ollama_quick_refactor_qwen35.toml index 6ec9613..bfb2427 100644 --- a/sources/agents/ollama_quick_refactor_qwen35.toml +++ b/sources/agents/ollama_quick_refactor_qwen35.toml @@ -12,8 +12,8 @@ tags = ["refactor", "ollama", "local", "16gb"] system_prompt = """{{ read_file(":/tasks/quick-refactor.md") }}""" [body] -think = true +think = true +keep_alive = "5m" [body.options] num_predict = 4096 -keep_alive = "5m" diff --git a/sources/agents/ollama_quick_refactor_simple.toml b/sources/agents/ollama_quick_refactor_simple.toml index 1fa14eb..83c7833 100644 --- a/sources/agents/ollama_quick_refactor_simple.toml +++ b/sources/agents/ollama_quick_refactor_simple.toml @@ -10,7 +10,9 @@ tags = ["refactor", "ollama", "local", "8gb"] system_prompt = """{{ read_file(":/tasks/quick-refactor.md") }}""" +[body] +keep_alive = "5m" + [body.options] num_predict = 2048 temperature = 0.2 -keep_alive = "5m" diff --git a/sources/agents/qwen_chat.toml b/sources/agents/qwen_chat.toml new file mode 100644 index 0000000..5fc9978 --- /dev/null +++ b/sources/agents/qwen_chat.toml @@ -0,0 +1,16 @@ +schema_version = 1 + +extends = "OpenAI Base Chat" +name = "Qwen Chat" +description = "Alibaba Qwen coding chat (qwen-plus stable alias, currently the Qwen3.6 Plus family) over the DashScope OpenAI-compatible Chat Completions API — conversational assistant with IDE tool use." + +provider_instance = "Qwen" +model = "qwen-plus" +enable_tools = true +tags = ["chat", "qwen", "cloud"] + +system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}""" + +[body] +max_tokens = 8192 +temperature = 0.3 diff --git a/sources/common/ContextData.hpp b/sources/common/ContextData.hpp index 650353b..5f11a85 100644 --- a/sources/common/ContextData.hpp +++ b/sources/common/ContextData.hpp @@ -24,15 +24,15 @@ struct ContentBlockEntry Kind kind = Kind::Text; - QString text; // Text - QString thinking; // Thinking - QString signature; // Thinking / RedactedThinking - QString toolUseId; // ToolUse / ToolResult - QString toolName; // ToolUse - QJsonObject toolInput; // ToolUse - QString result; // ToolResult - QString imageData; // Image (base64 or url) - QString mediaType; // Image + QString text; + QString thinking; + QString signature; + QString toolUseId; + QString toolName; + QJsonObject toolInput; + QString result; + QString imageData; + QString mediaType; bool isImageUrl = false; bool operator==(const ContentBlockEntry &) const = default; @@ -43,7 +43,6 @@ struct Message QString role; QVector blocks; - // Convenience for callers that only need a single text block. static Message text(const QString &role, const QString &text) { Message m; diff --git a/sources/common/ResponseCleaner.hpp b/sources/common/ResponseCleaner.hpp index 8b2642c..344a49f 100644 --- a/sources/common/ResponseCleaner.hpp +++ b/sources/common/ResponseCleaner.hpp @@ -4,9 +4,9 @@ #pragma once +#include #include #include -#include namespace QodeAssist { @@ -15,14 +15,15 @@ class ResponseCleaner public: static QString clean(const QString &response) { - QString cleaned = removeCodeBlocks(response); + bool extractedFromFence = false; + QString cleaned = removeCodeBlocks(response, extractedFromFence); cleaned = trimWhitespace(cleaned); - cleaned = removeExplanations(cleaned); + cleaned = removeExplanations(cleaned, extractedFromFence); return cleaned; } private: - static QString removeCodeBlocks(const QString &text) + static QString removeCodeBlocks(const QString &text, bool &extractedFromFence) { if (!text.contains("```")) { return text; @@ -31,6 +32,7 @@ private: QRegularExpression codeBlockRegex("```\\w*\\n([\\s\\S]*?)```"); QRegularExpressionMatch match = codeBlockRegex.match(text); if (match.hasMatch()) { + extractedFromFence = true; return match.captured(1); } @@ -39,6 +41,7 @@ private: if (firstFence != -1 && lastFence > firstFence) { int firstNewLine = text.indexOf('\n', firstFence); if (firstNewLine != -1) { + extractedFromFence = true; return text.mid(firstNewLine + 1, lastFence - firstNewLine - 1); } } @@ -58,13 +61,34 @@ private: return result; } - static QString removeExplanations(const QString &text) + static bool isProseHeader(const QString &line) { - static const QStringList explanationPrefixes = { - "here's the", "here is the", "here's", "here is", - "the refactored", "refactored code:", "code:", - "i've refactored", "i refactored", "i've changed", "i changed" - }; + if (line.length() >= 50 || !line.endsWith(':') || !line.contains(' ')) { + return false; + } + const QString head = line.left(line.length() - 1); + for (const QChar c : head) { + if (!c.isLetter() && c != ' ') { + return false; + } + } + return true; + } + + static QString removeExplanations(const QString &text, bool extractedFromFence) + { + static const QStringList explanationPrefixes + = {"here's the", + "here is the", + "here's", + "here is", + "the refactored", + "refactored code:", + "code:", + "i've refactored", + "i refactored", + "i've changed", + "i changed"}; QStringList lines = text.split('\n'); int startLine = 0; @@ -80,7 +104,7 @@ private: } } - if (line.length() < 50 && line.endsWith(':')) { + if (!extractedFromFence && isProseHeader(line)) { isExplanation = true; } diff --git a/sources/providers/ClaudeCacheControl.hpp b/sources/providers/ClaudeCacheControl.hpp index 40a5b4e..dc26921 100644 --- a/sources/providers/ClaudeCacheControl.hpp +++ b/sources/providers/ClaudeCacheControl.hpp @@ -38,8 +38,8 @@ inline void applyToSystem(QJsonObject &request, const QJsonObject &cacheControl) if (sys.isString()) { const QString text = sys.toString(); if (!text.isEmpty()) { - request["system"] = QJsonArray{QJsonObject{ - {"type", "text"}, {"text", text}, {"cache_control", cacheControl}}}; + request["system"] = QJsonArray{ + QJsonObject{{"type", "text"}, {"text", text}, {"cache_control", cacheControl}}}; } } else if (sys.isArray()) { QJsonArray blocks = sys.toArray(); diff --git a/sources/providers/GenericProvider.cpp b/sources/providers/GenericProvider.cpp index 8b09a5e..be4b7cb 100644 --- a/sources/providers/GenericProvider.cpp +++ b/sources/providers/GenericProvider.cpp @@ -73,8 +73,6 @@ QString GenericProvider::modelsEndpoint(const QString &url) const 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"); @@ -98,9 +96,7 @@ GenericProvider::ClientFactory makeFactory() void registerBuiltinProviders() { - const auto reg = [](const QString &api, - ProviderID id, - GenericProvider::ClientFactory factory) { + const auto reg = [](const QString &api, ProviderID id, GenericProvider::ClientFactory factory) { ProviderFactory::registerType(api, [=](QObject *parent) -> Provider * { return new GenericProvider(api, id, factory, parent); }); @@ -109,21 +105,23 @@ void registerBuiltinProviders() 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, + reg("LM Studio (Chat Completions)", + ProviderID::LMStudio, makeFactory<::LLMQore::OpenAIClient>()); - reg("LM Studio (Responses API)", ProviderID::OpenAIResponses, + 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, + reg("Ollama (OpenAI-compatible)", + ProviderID::OpenAICompatible, makeFactory<::LLMQore::OpenAIClient>()); - reg("OpenAI (Chat Completions)", ProviderID::OpenAI, - makeFactory<::LLMQore::OpenAIClient>()); - reg("OpenAI (Responses API)", ProviderID::OpenAIResponses, + 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("OpenAI Compatible", ProviderID::OpenAICompatible, makeFactory<::LLMQore::OpenAIClient>()); reg("OpenRouter", ProviderID::OpenRouter, makeFactory<::LLMQore::OpenAIClient>()); } diff --git a/sources/providers/GenericProvider.hpp b/sources/providers/GenericProvider.hpp index c1c8c1d..1df939f 100644 --- a/sources/providers/GenericProvider.hpp +++ b/sources/providers/GenericProvider.hpp @@ -14,10 +14,6 @@ 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 @@ -25,10 +21,7 @@ public: using ClientFactory = std::function<::LLMQore::BaseClient *(QObject *)>; GenericProvider( - QString name, - ProviderID id, - const ClientFactory &clientFactory, - QObject *parent = nullptr); + QString name, ProviderID id, const ClientFactory &clientFactory, QObject *parent = nullptr); QString name() const override; QFuture> getInstalledModels(const QString &url) override; @@ -46,8 +39,6 @@ private: ::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 8455106..1de12aa 100644 --- a/sources/providers/Provider.cpp +++ b/sources/providers/Provider.cpp @@ -40,14 +40,13 @@ bool Provider::prepareRequest( return fail(QString("Provider '%1': null template").arg(name())); if (!prompt->isSupportProvider(providerID())) { - return fail(QString("Template '%1' doesn't support provider '%2'") - .arg(prompt->name(), name())); + return fail( + QString("Template '%1' doesn't support provider '%2'").arg(prompt->name(), name())); } if (!prompt->buildFullRequest(request, context)) { - return fail( - QString("Provider '%1': template '%2' failed to build request (see log)") - .arg(name(), prompt->name())); + return fail(QString("Provider '%1': template '%2' failed to build request (see log)") + .arg(name(), prompt->name())); } if (isToolsEnabled) { @@ -58,18 +57,23 @@ bool Provider::prepareRequest( } if (m_promptCachingEnabled) - ClaudeCacheControl::apply( - request, m_promptCachingExtendedTtl, m_promptCacheBreakpoints); + ClaudeCacheControl::apply(request, m_promptCachingExtendedTtl, m_promptCacheBreakpoints); return true; } void Provider::setPromptCaching(bool enabled, bool extendedTtl, const QStringList &breakpoints) { + auto *claude = qobject_cast<::LLMQore::ClaudeClient *>(client()); + if (enabled && !claude) { + LOG_MESSAGE( + QString("%1: cache_prompt is only supported by Claude providers, ignoring").arg(name())); + enabled = false; + } m_promptCachingEnabled = enabled; m_promptCachingExtendedTtl = enabled && extendedTtl; m_promptCacheBreakpoints = breakpoints; - if (auto *claude = qobject_cast<::LLMQore::ClaudeClient *>(client())) + if (claude) claude->setUseExtendedCacheTTL(m_promptCachingExtendedTtl); } diff --git a/sources/providers/Provider.hpp b/sources/providers/Provider.hpp index 07c1c1f..9dfe822 100644 --- a/sources/providers/Provider.hpp +++ b/sources/providers/Provider.hpp @@ -8,14 +8,12 @@ #include #include #include -#include #include "ContextData.hpp" #include "ProviderID.hpp" #include "LLMQore/BaseClient.hpp" namespace LLMQore { -class BaseClient; class ToolsManager; } @@ -63,8 +61,7 @@ public: void cancelRequest(const RequestID &requestId); ::LLMQore::ToolsManager *toolsManager() const; - void setPromptCaching( - bool enabled, bool extendedTtl, const QStringList &breakpoints = {}); + void setPromptCaching(bool enabled, bool extendedTtl, const QStringList &breakpoints = {}); private: QString m_url; diff --git a/sources/providersConfig/deepseek.toml b/sources/providersConfig/deepseek.toml new file mode 100644 index 0000000..e2c8ec6 --- /dev/null +++ b/sources/providersConfig/deepseek.toml @@ -0,0 +1,8 @@ +schema_version = 1 + +name = "DeepSeek" +client_api = "OpenAI Compatible" +description = "Cloud (DeepSeek). OpenAI-compatible Chat Completions API — deepseek-chat (V4 family) and deepseek-reasoner. Get an API key at platform.deepseek.com." + +url = "https://api.deepseek.com/v1" +api_key_ref = "qodeassist/providers/DeepSeek" diff --git a/sources/providersConfig/provider_instances.qrc b/sources/providersConfig/provider_instances.qrc index aa8a855..bfac314 100644 --- a/sources/providersConfig/provider_instances.qrc +++ b/sources/providersConfig/provider_instances.qrc @@ -13,5 +13,7 @@ codestral.toml googleai.toml llamacpp.toml + deepseek.toml + qwen.toml diff --git a/sources/providersConfig/qwen.toml b/sources/providersConfig/qwen.toml new file mode 100644 index 0000000..72c89bb --- /dev/null +++ b/sources/providersConfig/qwen.toml @@ -0,0 +1,8 @@ +schema_version = 1 + +name = "Qwen" +client_api = "OpenAI Compatible" +description = "Cloud (Alibaba Qwen via DashScope, international endpoint). OpenAI-compatible Chat Completions API — qwen-plus / qwen-max / qwen-coder model families. Get an API key in the Alibaba Cloud Model Studio console." + +url = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" +api_key_ref = "qodeassist/providers/Qwen" diff --git a/sources/settings/AgentRosterWidget.cpp b/sources/settings/AgentRosterWidget.cpp index d2b99f1..2bf74ce 100644 --- a/sources/settings/AgentRosterWidget.cpp +++ b/sources/settings/AgentRosterWidget.cpp @@ -33,12 +33,12 @@ namespace { enum class PillKind { Template, - On, // capability on (thinking/tools) - Off, // capability off ("plain") - User, // user-defined agent - Active, // ✓ active - Match, // matched-this-row chip background - Tag, // free-form discoverability tag from AgentConfig::tags + On, + Off, + User, + Active, + Match, + Tag, Neutral, }; @@ -192,16 +192,17 @@ class AgentRosterRow : public QFrame { Q_OBJECT 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); + 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); signals: void moveUpRequested(int index); @@ -221,17 +222,19 @@ private: int m_index; }; -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) +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) { setAutoFillBackground(true); QPalette pal = palette(); @@ -334,8 +337,8 @@ QWidget *AgentRosterRow::buildIdentityLine(const QString &displayName, return w; } -QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, bool showMatch, - 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); @@ -352,14 +355,16 @@ QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, bool QString chipText = QStringLiteral( "%1 " "%3: %4") - .arg(sm.icon, - hex(active ? t.pill(PillKind::Match).fg : t.textFaint), - sm.kind, - sm.value.toHtmlEscaped()); + .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;") + 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); @@ -457,14 +462,14 @@ 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:4px; }") + 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 agent selected yet — use \"Add agent…\" below."), - m_rowsFrame); + 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); QFont eh = m_emptyHint->font(); @@ -497,9 +502,7 @@ AgentRosterWidget::AgentRosterWidget(QWidget *parent) } void AgentRosterWidget::setSlot( - const QString &title, - const QString &hint, - const QStringList &presetTags) + const QString &title, const QString &hint, const QStringList &presetTags) { m_presetTags = presetTags; m_titleLabel->setText(title); @@ -514,13 +517,14 @@ void AgentRosterWidget::setRoster(const QStringList &names, AgentFactory *factor 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_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; @@ -558,6 +562,13 @@ void AgentRosterWidget::applyMode() m_footerHint->setVisible(!footer.isEmpty()); } +void AgentRosterWidget::setRoutingContext(const AgentRouter::Context &ctx) +{ + m_routingCtx = ctx; + if (!m_names.isEmpty()) + rebuildRows(); +} + void AgentRosterWidget::recomputeActive() { if (!m_orderable || !m_factory || m_names.isEmpty() @@ -571,7 +582,6 @@ void AgentRosterWidget::recomputeActive() void AgentRosterWidget::rebuildRows() { - // Tear down existing row widgets (keep the empty hint). while (m_rowsLayout->count() > 0) { QLayoutItem *it = m_rowsLayout->takeAt(0); if (auto *w = it->widget()) { @@ -598,16 +608,17 @@ void AgentRosterWidget::rebuildRows() 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); + 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); connect(row, &AgentRosterRow::moveDownRequested, this, &AgentRosterWidget::onRowMoveDown); connect(row, &AgentRosterRow::editRequested, this, &AgentRosterWidget::onRowEdit); @@ -621,11 +632,12 @@ void AgentRosterWidget::onAddClicked() if (!m_factory) return; - AgentSelectionDialog dialog(m_factory->configs(), - /*currentName*/ m_single ? m_names.value(0) : QString{}, - m_factory.data(), - m_presetTags, - Core::ICore::dialogParent()); + AgentSelectionDialog dialog( + m_factory->configs(), + /*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(); diff --git a/sources/settings/AgentRosterWidget.hpp b/sources/settings/AgentRosterWidget.hpp index b7a1fe7..c628d76 100644 --- a/sources/settings/AgentRosterWidget.hpp +++ b/sources/settings/AgentRosterWidget.hpp @@ -31,20 +31,12 @@ class AgentRosterWidget : public QWidget public: explicit AgentRosterWidget(QWidget *parent = nullptr); - void setSlot( - const QString &title, - const QString &hint, - const QStringList &presetTags = {}); + void setSlot(const QString &title, const QString &hint, const QStringList &presetTags = {}); void setRoster(const QStringList &names, AgentFactory *factory); + void setRoutingContext(const AgentRouter::Context &ctx); - // 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); - // 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; } diff --git a/sources/settings/AgentSelectionDialog.cpp b/sources/settings/AgentSelectionDialog.cpp index 825fced..df31452 100644 --- a/sources/settings/AgentSelectionDialog.cpp +++ b/sources/settings/AgentSelectionDialog.cpp @@ -4,8 +4,8 @@ #include "AgentSelectionDialog.hpp" -#include "PipelinesConfig.hpp" #include "Pill.hpp" +#include "PipelinesConfig.hpp" #include "SettingsTr.hpp" #include "TagFilterStrip.hpp" @@ -13,6 +13,7 @@ #include +#include #include #include #include @@ -27,12 +28,9 @@ #include #include #include -#include namespace QodeAssist::Settings { -// -- ListRowCard ------------------------------------------------------- - ListRowCard::ListRowCard(QWidget *parent) : QFrame(parent) { @@ -128,16 +126,13 @@ void ListRowCard::applyTheme() .arg(bg, bd)); } -// -- AgentRowCard ------------------------------------------------------ - 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.systemPrompt, - cfg.endpoint}; + QStringList haystack{ + cfg.name, cfg.providerInstance, cfg.model, cfg.description, cfg.systemPrompt, cfg.endpoint}; haystack += cfg.tags; buildSearchHaystack(haystack); @@ -249,8 +244,6 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent) setToolTip(tooltip.trimmed()); } -// -- ProviderSection --------------------------------------------------- - ProviderSection::ProviderSection(const QString &name, QWidget *parent) : QWidget(parent) { @@ -337,8 +330,6 @@ bool ProviderSection::eventFilter(QObject *watched, QEvent *event) return QWidget::eventFilter(watched, event); } -// -- AgentSelectionDialog ---------------------------------------------- - AgentSelectionDialog::AgentSelectionDialog( const std::vector &configs, const QString ¤tName, @@ -380,10 +371,10 @@ AgentSelectionDialog::AgentSelectionDialog( 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 *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); @@ -425,8 +416,9 @@ AgentSelectionDialog::AgentSelectionDialog( preset.insert(tag); m_tagStrip->setAvailableTags(tagCounts, preset); - connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this, - [this](const QSet &) { applyFilters(); }); + connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this, [this](const QSet &) { + applyFilters(); + }); connect(expandAll, &QLabel::linkActivated, this, [this](const QString &) { setAllExpanded(true); }); @@ -436,8 +428,7 @@ AgentSelectionDialog::AgentSelectionDialog( rebuild(currentName); - connect(m_filter, &QLineEdit::textChanged, this, - [this](const QString &) { applyFilters(); }); + connect(m_filter, &QLineEdit::textChanged, this, [this](const QString &) { applyFilters(); }); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); @@ -486,7 +477,8 @@ void AgentSelectionDialog::rebuild(const QString ¤tName) QMap> byProvider; for (const auto &cfg : configs) { - if (cfg.hidden) continue; // hidden profiles stay loaded but don't surface in the picker + if (cfg.hidden) + continue; const QString key = cfg.providerInstance.isEmpty() ? Tr::tr("(Unknown provider instance)") : cfg.providerInstance; @@ -565,8 +557,7 @@ void AgentSelectionDialog::applyFilters() 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)); + m_resultCount->setText(total == 0 ? tr("No matches") : tr("%n agent(s)", nullptr, total)); if (m_tagStrip) { QMap liveCounts; diff --git a/sources/settings/AgentSelectionDialog.hpp b/sources/settings/AgentSelectionDialog.hpp index f7f750a..7ad7d12 100644 --- a/sources/settings/AgentSelectionDialog.hpp +++ b/sources/settings/AgentSelectionDialog.hpp @@ -4,6 +4,7 @@ #pragma once +#include #include #include #include @@ -11,7 +12,6 @@ #include #include #include -#include #include @@ -142,7 +142,6 @@ public: void setExpanded(bool expanded); const QList &cards() const { return m_cards; } - // returns number of visible cards int applyFilter(const QString &needle, const QSet &activeTags); protected: @@ -189,7 +188,7 @@ private: QString m_selectedName; AgentFactory *m_agentFactory = nullptr; - std::vector m_localConfigs; // fallback when no factory + std::vector m_localConfigs; QStringList m_presetTags; }; diff --git a/sources/settings/Pill.cpp b/sources/settings/Pill.cpp index a223b02..85431cf 100644 --- a/sources/settings/Pill.cpp +++ b/sources/settings/Pill.cpp @@ -87,9 +87,10 @@ void Pill::applyTheme() 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; }") + 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))); } diff --git a/sources/settings/Pill.hpp b/sources/settings/Pill.hpp index 3bb33a8..86ca993 100644 --- a/sources/settings/Pill.hpp +++ b/sources/settings/Pill.hpp @@ -9,8 +9,6 @@ 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 diff --git a/sources/settings/PipelinesConfig.cpp b/sources/settings/PipelinesConfig.cpp index 3d5119a..99d602f 100644 --- a/sources/settings/PipelinesConfig.cpp +++ b/sources/settings/PipelinesConfig.cpp @@ -18,7 +18,6 @@ #include "Logger.hpp" #include "TomlWriter.hpp" -#include namespace QodeAssist::Settings { @@ -45,8 +44,6 @@ QString toSingleString(const toml::node *node, const QString &slotKey, bool *sch 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()) { @@ -115,19 +112,53 @@ void fillMissingFromDefaults(PipelineRosters &r, const toml::table §ion) r.quickRefactor = defs.quickRefactor; } +struct CacheState +{ + PipelinesLoadResult result; + QDateTime mtime; + qint64 size = -1; + bool valid = false; +}; + +CacheState &cacheState() +{ + static CacheState s; + return s; +} + +QString &filePathOverride() +{ + static QString p; + return p; +} + } // namespace PipelineRosters PipelineRosters::defaults() { - return PipelineRosters{}; + return PipelineRosters{ + {QStringLiteral("Ollama Completion — FIM")}, + {QStringLiteral("Ollama Chat — Simple"), + QStringLiteral("Ollama Chat — Thinking"), + QStringLiteral("Ollama Chat — Gemma 4")}, + QStringLiteral("Ollama Compression — 8 GB"), + QStringLiteral("Ollama Quick Refactor — Simple")}; } QString PipelinesConfig::filePath() { + if (!filePathOverride().isEmpty()) + return filePathOverride(); return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/pipelines.toml")) .toFSPathString(); } +void PipelinesConfig::setFilePathForTests(const QString &path) +{ + filePathOverride() = path; + cacheState() = {}; +} + PipelinesLoadResult PipelinesConfig::load() { PipelinesLoadResult result; @@ -206,19 +237,18 @@ PipelinesLoadResult PipelinesConfig::load() PipelinesLoadResult PipelinesConfig::loadCached() { - static PipelinesLoadResult cached; - static QDateTime cachedMTime; - static bool valid = false; - + auto &cache = cacheState(); const QFileInfo info(filePath()); const QDateTime mtime = info.exists() ? info.lastModified() : QDateTime(); - if (valid && mtime == cachedMTime) - return cached; + const qint64 size = info.exists() ? info.size() : -1; + if (cache.valid && mtime == cache.mtime && size == cache.size) + return cache.result; - cached = load(); - cachedMTime = mtime; - valid = true; - return cached; + cache.result = load(); + cache.mtime = mtime; + cache.size = size; + cache.valid = true; + return cache.result; } bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut) @@ -236,15 +266,14 @@ bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut) TomlSerializer::TomlWriter w; w.writeComment(QStringLiteral("QodeAssist pipelines — which agent each feature uses.")); - w.writeComment(QStringLiteral( - "code_completion: ordered list; the router walks it top-down and uses")); - w.writeComment(QStringLiteral( - " 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("code_completion: ordered list; the router walks it top-down and uses")); + w.writeComment( + QStringLiteral(" 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.writeComment(QStringLiteral("chat_compression / quick_refactor: a single agent name.")); w.writeBlankLine(); w.writeTableHeader(QString::fromUtf8(kSection)); w.writeStringArray(QString::fromUtf8(kCodeCompletion), rosters.codeCompletion); @@ -276,56 +305,8 @@ bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut) *errorOut = out.errorString(); return false; } - return true; -} - -bool PipelinesConfig::validate(const QodeAssist::AgentFactory &factory, QString *errorOut) -{ - PipelinesLoadResult lr = load(); - PipelineRosters &r = lr.rosters; - bool changed = false; - - auto fix = [&](QStringList ¤t) { - QStringList kept; - kept.reserve(current.size()); - for (const QString &raw : current) { - const QString name = trimAndCap(raw); - if (name.isEmpty() || kept.contains(name)) - continue; - if (factory.configByName(name)) - kept.append(name); - } - if (kept != current) { - current = std::move(kept); - changed = true; - } - }; - - 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); - fixOne(r.chatCompression); - fixOne(r.quickRefactor); - - if (!changed && lr.status == PipelinesLoadStatus::Ok) - return true; - - QString saveErr; - if (!save(r, &saveErr)) { - const QString msg = QStringLiteral("failed to persist after validation: %1").arg(saveErr); - LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(msg)); - if (errorOut) - *errorOut = msg; - return false; - } + cacheState() = {}; + PipelinesNotifier::instance()->notifyChanged(); return true; } diff --git a/sources/settings/PipelinesConfig.hpp b/sources/settings/PipelinesConfig.hpp index bfc0547..d37265a 100644 --- a/sources/settings/PipelinesConfig.hpp +++ b/sources/settings/PipelinesConfig.hpp @@ -4,24 +4,35 @@ #pragma once +#include #include #include -namespace QodeAssist { -class AgentFactory; -} - namespace QodeAssist::Settings { +class PipelinesNotifier : public QObject +{ + Q_OBJECT +public: + static PipelinesNotifier *instance() + { + static PipelinesNotifier notifier; + return ¬ifier; + } + + void notifyChanged() { emit pipelinesChanged(); } + +signals: + void pipelinesChanged(); + +private: + PipelinesNotifier() = default; +}; + 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; - // Compression and quick refactor each use a single fixed agent. QString chatCompression; QString quickRefactor; @@ -48,8 +59,7 @@ public: [[nodiscard]] static bool save(const PipelineRosters &rosters, QString *errorOut = nullptr); - [[nodiscard]] static bool validate( - const QodeAssist::AgentFactory &factory, QString *errorOut = nullptr); + static void setFilePathForTests(const QString &path); }; } // namespace QodeAssist::Settings diff --git a/sources/templates/JsonPromptTemplate.cpp b/sources/templates/JsonPromptTemplate.cpp index 7d81f01..4bbcc8b 100644 --- a/sources/templates/JsonPromptTemplate.cpp +++ b/sources/templates/JsonPromptTemplate.cpp @@ -46,8 +46,6 @@ 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()) @@ -89,8 +87,7 @@ nlohmann::json buildContextJson(const ContextData &context) bj["name"] = b.toolName.toStdString(); const std::string inputStr = QJsonDocument(b.toolInput).toJson(QJsonDocument::Compact).toStdString(); - nlohmann::json parsedInput - = nlohmann::json::parse(inputStr, nullptr, /*allow_exceptions=*/false); + nlohmann::json parsedInput = nlohmann::json::parse(inputStr, nullptr, false); if (parsedInput.is_discarded()) { if (!b.toolInput.isEmpty()) { qWarning("[QodeAssist] tool_use '%s' has unparseable input " @@ -141,12 +138,6 @@ 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; @@ -176,23 +167,19 @@ std::string stripTrailingCommas(const std::string &in) && (in[j] == ' ' || in[j] == '\t' || in[j] == '\n' || in[j] == '\r')) ++j; if (j < in.size() && (in[j] == '}' || in[j] == ']')) - continue; // drop this comma + continue; } 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 { + [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 + "'"); @@ -208,24 +195,14 @@ void setIncludeResolver(inja::Environment &env, std::vector roots) void registerStandardCallbacks(inja::Environment &env) { - // `{% 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); - // Disable inja's `##` line-statement shorthand — collides with - // Markdown headings inside template bodies. Same rationale as in - // ContextRenderer; retarget to an unreachable sentinel. env.set_line_statement("@@@inja@@@"); env.add_callback("tojson", 1, [](inja::Arguments &args) -> nlohmann::json { 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(); @@ -261,58 +238,26 @@ void registerStandardCallbacks(inja::Environment &env) } return result; }); - - env.add_callback("filter_skip_empty_thinking", 1, [](inja::Arguments &args) -> nlohmann::json { - const nlohmann::json &history = *args.at(0); - nlohmann::json result = nlohmann::json::array(); - for (const auto &msg : history) { - const bool isThinking = msg.value("is_thinking", false); - const std::string sig = msg.value("signature", ""); - if (isThinking && sig.empty()) { - continue; - } - result.push_back(msg); - } - return result; - }); - - env.add_callback( - "filter_skip_empty_parts_thinking", 1, [](inja::Arguments &args) -> nlohmann::json { - const nlohmann::json &history = *args.at(0); - nlohmann::json result = nlohmann::json::array(); - for (const auto &msg : history) { - const bool isThinking = msg.value("is_thinking", false); - const std::string content = msg.value("content", ""); - const std::string sig = msg.value("signature", ""); - if (isThinking && content.empty() && sig.empty()) { - continue; - } - result.push_back(msg); - } - return result; - }); } -// 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() { + const QString hostile = QStringLiteral("va\"li\\da\ntion"); + ContextData ctx; - ctx.systemPrompt = QStringLiteral("validation"); - ctx.prefix = QStringLiteral("prefix"); - ctx.suffix = QStringLiteral("suffix"); + ctx.systemPrompt = QStringLiteral("system ") + hostile; + ctx.prefix = QStringLiteral("prefix ") + hostile; + ctx.suffix = QStringLiteral("suffix ") + hostile; QVector history; - history.append(Message::text(QStringLiteral("user"), QStringLiteral("hello"))); + history.append(Message::text(QStringLiteral("user"), QStringLiteral("hello ") + hostile)); Message asst; asst.role = QStringLiteral("assistant"); { ContentBlockEntry th; th.kind = ContentBlockEntry::Kind::Thinking; - th.thinking = QStringLiteral("reasoning"); + th.thinking = QStringLiteral("reasoning ") + hostile; th.signature = QStringLiteral("sig"); asst.blocks.append(th); ContentBlockEntry rth; @@ -321,13 +266,13 @@ ContextData makeValidationContext() asst.blocks.append(rth); ContentBlockEntry t; t.kind = ContentBlockEntry::Kind::Text; - t.text = QStringLiteral("hi"); + t.text = QStringLiteral("hi ") + hostile; 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")}}; + tu.toolInput = QJsonObject{{QStringLiteral("path"), hostile}}; asst.blocks.append(tu); } history.append(asst); @@ -338,7 +283,7 @@ ContextData makeValidationContext() ContentBlockEntry tr; tr.kind = ContentBlockEntry::Kind::ToolResult; tr.toolUseId = QStringLiteral("call_1"); - tr.result = QStringLiteral("ok"); + tr.result = QStringLiteral("ok ") + hostile; toolMsg.blocks.append(tr); } history.append(toolMsg); @@ -348,7 +293,7 @@ ContextData makeValidationContext() { ContentBlockEntry te; te.kind = ContentBlockEntry::Kind::Text; - te.text = QStringLiteral("look"); + te.text = QStringLiteral("look ") + hostile; imgMsg.blocks.append(te); ContentBlockEntry im; im.kind = ContentBlockEntry::Kind::Image; @@ -391,11 +336,11 @@ std::unique_ptr JsonPromptTemplate::fromConfig( registerStandardCallbacks(tpl->m_env); 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)); + setError(QStringLiteral( + "Agent '%1' [body] failed to render to valid JSON " + "(see log)") + .arg(cfg.name)); return nullptr; } return tpl; @@ -403,11 +348,6 @@ std::unique_ptr JsonPromptTemplate::fromConfig( 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, @@ -463,8 +403,8 @@ bool renderValue( try { rendered = env.render(s.toStdString(), data); } catch (const std::exception &e) { - qWarning("[QodeAssist] Template '%s' field render failed: %s", - qUtf8Printable(tplName), e.what()); + qWarning( + "[QodeAssist] Template '%s' field render failed: %s", qUtf8Printable(tplName), e.what()); return false; } @@ -474,17 +414,17 @@ bool renderValue( return true; } - // 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)); + qWarning( + "[QodeAssist] Template '%s' field produced invalid JSON: %s\n" + "--- rendered (truncated) ---\n%s", + qUtf8Printable(tplName), + qUtf8Printable(perr.errorString()), + qUtf8Printable(snippet)); return false; } out = doc.object().value(QStringLiteral("v")); @@ -519,13 +459,7 @@ std::optional JsonPromptTemplate::renderBody(const ContextData &con return request; } -void JsonPromptTemplate::prepareRequest(QJsonObject &request, const ContextData &context) const -{ - mergeRenderedBody(request, renderBody(context)); -} - -bool JsonPromptTemplate::buildFullRequest( - QJsonObject &request, const ContextData &context) const +bool JsonPromptTemplate::buildFullRequest(QJsonObject &request, const ContextData &context) const { return mergeRenderedBody(request, renderBody(context)); } diff --git a/sources/templates/JsonPromptTemplate.hpp b/sources/templates/JsonPromptTemplate.hpp index e4435e3..c3ff541 100644 --- a/sources/templates/JsonPromptTemplate.hpp +++ b/sources/templates/JsonPromptTemplate.hpp @@ -22,28 +22,17 @@ struct AgentConfig; namespace QodeAssist::Templates { -// Renderer for the request-body jinja template embedded in an -// AgentConfig. One per Agent — built inline from the config (no shared -// template registry, no model/provider filtering). class JsonPromptTemplate : public PromptTemplate { public: - // Build a renderer from an already-parsed agent config. Compiles - // the jinja source via inja once. On failure returns nullptr and - // populates `*error` (existing content preserved; pass nullptr to - // discard). static std::unique_ptr fromConfig( const AgentConfig &cfg, QString *error = nullptr); QString name() const override { return m_name; } QString description() const override { return m_description; } - // 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; } - void prepareRequest(QJsonObject &request, const ContextData &context) const override; - [[nodiscard]] bool buildFullRequest( QJsonObject &request, const ContextData &context) const override; @@ -54,21 +43,8 @@ private: 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; mutable std::mutex m_renderMutex; }; diff --git a/sources/templates/PromptTemplate.hpp b/sources/templates/PromptTemplate.hpp index 7536b90..1e04de9 100644 --- a/sources/templates/PromptTemplate.hpp +++ b/sources/templates/PromptTemplate.hpp @@ -27,15 +27,10 @@ public: PromptTemplate &operator=(PromptTemplate &&) = delete; virtual QString name() const = 0; - virtual void prepareRequest(QJsonObject &request, const ContextData &context) const = 0; virtual QString description() const = 0; virtual bool isSupportProvider(ProviderID id) const = 0; - [[nodiscard]] virtual bool buildFullRequest( - QJsonObject &request, const ContextData &context) const - { - prepareRequest(request, context); - return true; - } + [[nodiscard]] virtual bool buildFullRequest(QJsonObject &request, const ContextData &context) const + = 0; }; } // namespace QodeAssist::Templates diff --git a/sources/tomlSerializer/TomlWriter.cpp b/sources/tomlSerializer/TomlWriter.cpp index 3cc9b4f..29910f6 100644 --- a/sources/tomlSerializer/TomlWriter.cpp +++ b/sources/tomlSerializer/TomlWriter.cpp @@ -52,11 +52,31 @@ void TomlWriter::writeTableHeader(const QString &name) m_out += QLatin1String("]\n"); } +namespace { + +bool isBareKey(const QString &key) +{ + if (key.isEmpty()) + return false; + for (QChar c : key) { + if (!c.isLetterOrNumber() && c != QLatin1Char('_') && c != QLatin1Char('-')) + return false; + if (c.unicode() > 0x7f) + return false; + } + return true; +} + +} // namespace + void TomlWriter::writeKeyPrefix(const QString &key) { - m_out += key; - if (m_keyColumnWidth > key.size()) - m_out += QString(m_keyColumnWidth - key.size(), QLatin1Char(' ')); + const QString rendered = isBareKey(key) + ? key + : QLatin1Char('"') + escapeBasic(key) + QLatin1Char('"'); + m_out += rendered; + if (m_keyColumnWidth > rendered.size()) + m_out += QString(m_keyColumnWidth - rendered.size(), QLatin1Char(' ')); m_out += QLatin1String(" = "); } diff --git a/sources/tomlSerializer/TomlWriter.hpp b/sources/tomlSerializer/TomlWriter.hpp index 922369e..c785b7d 100644 --- a/sources/tomlSerializer/TomlWriter.hpp +++ b/sources/tomlSerializer/TomlWriter.hpp @@ -22,8 +22,8 @@ public: void setKeyColumnWidth(int width) { m_keyColumnWidth = width; } void writeBlankLine(); - void writeComment(const QString &line); // "# line\n" - void writeTableHeader(const QString &name); // "[name]\n" + void writeComment(const QString &line); + void writeTableHeader(const QString &name); void writeString(const QString &key, const QString &value); void writeBool(const QString &key, bool value); diff --git a/test/AgentLoaderTest.cpp b/test/AgentLoaderTest.cpp index 158da9b..2fc26e2 100644 --- a/test/AgentLoaderTest.cpp +++ b/test/AgentLoaderTest.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -25,13 +26,14 @@ void writeFile(const QString &dir, const QString &name, const QByteArray &conten 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"; + 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) @@ -58,11 +60,14 @@ 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")); + 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(); @@ -124,23 +129,29 @@ TEST(AgentLoaderTest, UserAgentExtendsBundledProviderBase) 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"); + 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(); @@ -163,7 +174,8 @@ TEST(AgentLoaderTest, ExtendsUnknownParentErrorNamesChild) 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_TRUE( + anyContains(result.errors, QStringLiteral("'Child' extends unknown agent 'NoSuchBase'"))); EXPECT_EQ(findConfig(result, QStringLiteral("Child")), nullptr); } @@ -182,3 +194,91 @@ TEST(AgentLoaderTest, ParseFileReportsWarningsForOwnFileOnly) EXPECT_TRUE(anyContains(warnings, QStringLiteral("another_bogus"))); EXPECT_FALSE(anyContains(warnings, QStringLiteral("bogus_key"))); } + +TEST(AgentLoaderTest, CyclicExtendsRejectsAllInvolvedAgents) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + writeFile(dir.path(), "a.toml", minimalAgent("A", "extends = \"B\"\n")); + writeFile(dir.path(), "b.toml", minimalAgent("B", "extends = \"A\"\n")); + + const auto result = AgentLoader::load(QString(), dir.path()); + EXPECT_TRUE(anyContains(result.errors, QStringLiteral("Cyclic 'extends'"))); + EXPECT_EQ(findConfig(result, QStringLiteral("A")), nullptr); + EXPECT_EQ(findConfig(result, QStringLiteral("B")), nullptr); +} + +TEST(AgentLoaderTest, SelfExtendsIsRejected) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + writeFile(dir.path(), "a.toml", minimalAgent("A", "extends = \"A\"\n")); + + const auto result = AgentLoader::load(QString(), dir.path()); + EXPECT_TRUE(anyContains(result.errors, QStringLiteral("Cyclic 'extends'"))); + EXPECT_EQ(findConfig(result, QStringLiteral("A")), nullptr); +} + +TEST(AgentLoaderTest, ExtendsChainTooDeepRejectsChain) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const int chainLength = 40; + writeFile(dir.path(), "agent0.toml", minimalAgent("Agent0")); + for (int i = 1; i <= chainLength; ++i) { + writeFile( + dir.path(), + QStringLiteral("agent%1.toml").arg(i), + minimalAgent( + QStringLiteral("Agent%1").arg(i).toUtf8(), + QStringLiteral("extends = \"Agent%1\"\n").arg(i - 1).toUtf8())); + } + + const auto result = AgentLoader::load(QString(), dir.path()); + EXPECT_TRUE(anyContains(result.errors, QStringLiteral("too deep"))); + EXPECT_EQ(findConfig(result, QStringLiteral("Agent%1").arg(chainLength)), nullptr); + EXPECT_NE(findConfig(result, QStringLiteral("Agent0")), nullptr); +} + +TEST(AgentLoaderTest, ParseFileRejectsBundledAgentShadow) +{ + 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(), "shadow.toml", minimalAgent("A", "description = \"mine\"\n")); + + QString error; + const auto cfg = AgentLoader::parseFile( + user.path() + QStringLiteral("/shadow.toml"), bundled.path(), &error); + EXPECT_FALSE(cfg.has_value()); + EXPECT_TRUE(error.contains(QStringLiteral("cannot be replaced"))) << error.toStdString(); +} + +TEST(AgentLoaderTest, ChildArrayReplacesParentArray) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + writeFile( + dir.path(), + "parent.toml", + minimalAgent("Parent", "tags = [\"a\", \"b\"]\n") + "stop = [\"one\", \"two\"]\n"); + writeFile( + dir.path(), + "child.toml", + "name = \"Child\"\n" + "extends = \"Parent\"\n" + "tags = [\"c\"]\n" + "[body]\n" + "stop = [\"three\"]\n"); + + const auto result = AgentLoader::load(QString(), dir.path()); + EXPECT_TRUE(result.errors.isEmpty()) << result.errors.join("; ").toStdString(); + const AgentConfig *child = findConfig(result, QStringLiteral("Child")); + ASSERT_NE(child, nullptr); + EXPECT_EQ(child->tags, QStringList{QStringLiteral("c")}); + const auto stops = child->body.value(QStringLiteral("stop")).toArray(); + ASSERT_EQ(stops.size(), 1); + EXPECT_EQ(stops.first().toString(), QStringLiteral("three")); +} diff --git a/test/AgentRouterTest.cpp b/test/AgentRouterTest.cpp index 42b6945..74338a6 100644 --- a/test/AgentRouterTest.cpp +++ b/test/AgentRouterTest.cpp @@ -99,6 +99,5 @@ 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 index b8e4818..9e19165 100644 --- a/test/BundledAgentsTest.cpp +++ b/test/BundledAgentsTest.cpp @@ -23,8 +23,7 @@ TEST(BundledAgentsTest, AllBundledAgentsLoadResolveAndRender) const AgentLoader::LoadResult result = AgentLoader::load(QStringLiteral(":/agents"), QString()); EXPECT_TRUE(result.errors.isEmpty()) - << "bundled agent load errors: " - << result.errors.join(QStringLiteral("; ")).toStdString(); + << "bundled agent load errors: " << result.errors.join(QStringLiteral("; ")).toStdString(); EXPECT_TRUE(result.warnings.isEmpty()) << "bundled agent load warnings: " @@ -45,8 +44,7 @@ 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_TRUE(result.errors.isEmpty()) << result.errors.join(QStringLiteral("; ")).toStdString(); ASSERT_FALSE(result.configs.empty()); const QStringList languages = {QString(), QStringLiteral("qml"), QStringLiteral("c-like")}; @@ -64,8 +62,8 @@ TEST(BundledAgentsTest, AllBundledSystemPromptsResolveQrcResources) 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(); + << "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"; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d1526cc..474ebe9 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,6 +1,7 @@ add_executable(QodeAssistTest ../CodeHandler.cpp ../LLMSuggestion.cpp + ../sources/settings/PipelinesConfig.cpp CodeHandlerTest.cpp DocumentContextReaderTest.cpp EnvBlockFormatterTest.cpp @@ -16,9 +17,9 @@ add_executable(QodeAssistTest ContextRendererTest.cpp ErrorInfoTest.cpp MessageSerializerTest.cpp + PipelinesConfigTest.cpp ResponseCleanerTest.cpp SystemPromptBuilderTest.cpp - # LLMClientInterfaceTests.cpp unittest_main.cpp ) @@ -35,9 +36,17 @@ target_link_libraries(QodeAssistTest PRIVATE Templates Agents Session + TomlSerializer + tomlplusplus::tomlplusplus + QodeAssistLogger ) -target_include_directories(QodeAssistTest PRIVATE ${CMAKE_SOURCE_DIR}) +target_include_directories(QodeAssistTest PRIVATE + ${CMAKE_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/sources/settings + ${CMAKE_SOURCE_DIR}/sources/tomlSerializer + ${CMAKE_SOURCE_DIR}/logger +) target_compile_definitions(QodeAssistTest PRIVATE CMAKE_CURRENT_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") diff --git a/test/ClaudeCacheControlTest.cpp b/test/ClaudeCacheControlTest.cpp index 8b992ea..b988ec0 100644 --- a/test/ClaudeCacheControlTest.cpp +++ b/test/ClaudeCacheControlTest.cpp @@ -226,8 +226,7 @@ TEST(ClaudeCacheControlTest, EmptyBreakpointListMarksEveryDimension) apply(request, false, QStringList()); EXPECT_TRUE(request.value("system").isArray()); - EXPECT_TRUE( - request.value("tools").toArray().last().toObject().contains("cache_control")); + 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 index ab06e2a..367d42d 100644 --- a/test/ContextAssemblerTest.cpp +++ b/test/ContextAssemblerTest.cpp @@ -27,9 +27,7 @@ Message textMessage(Message::Role role, const QString &text) ContextAssembler::ContentLoader base64Loader(const QString &content) { - return [content](const QString &) { - return QString::fromUtf8(content.toUtf8().toBase64()); - }; + return [content](const QString &) { return QString::fromUtf8(content.toUtf8().toBase64()); }; } ContextAssembler::ContentLoader emptyLoader() @@ -145,8 +143,7 @@ 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())); + m.appendBlock(std::make_unique("tu1", "read_file", QJsonObject())); history.push_back(std::move(m)); ContextAssembler::Manifest manifest; @@ -179,8 +176,8 @@ TEST(ContextAssemblerTest, PairedToolUseAndResultAreKept) { std::vector history; Message use(Message::Role::Assistant); - use.appendBlock(std::make_unique( - "tu1", "read_file", QJsonObject{{"path", "a.cpp"}})); + 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")); @@ -205,8 +202,7 @@ TEST(ContextAssemblerTest, AttachmentMaterializedThroughLoader) 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")); + const auto ctx = ContextAssembler::assemble(history, QString(), base64Loader("file body")); ASSERT_TRUE(ctx.history.has_value()); const auto &block = ctx.history->first().blocks.first(); @@ -235,8 +231,7 @@ TEST(ContextAssemblerTest, StoredImageMaterializedThroughLoader) { std::vector history; Message m(Message::Role::User); - m.appendBlock( - std::make_unique("shot.png", "stored/shot", "image/png")); + m.appendBlock(std::make_unique("shot.png", "stored/shot", "image/png")); history.push_back(std::move(m)); ContextAssembler::Manifest manifest; @@ -255,8 +250,7 @@ TEST(ContextAssemblerTest, MissingImageWithNullLoaderGetsPlaceholder) { std::vector history; Message m(Message::Role::User); - m.appendBlock( - std::make_unique("shot.png", "stored/shot", "image/png")); + m.appendBlock(std::make_unique("shot.png", "stored/shot", "image/png")); history.push_back(std::move(m)); ContextAssembler::Manifest manifest; @@ -343,8 +337,7 @@ 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())); + 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")); @@ -366,8 +359,7 @@ TEST(ContextAssemblerTest, PinnedInsertedAfterLeadingToolResults) { std::vector history; Message use(Message::Role::Assistant); - use.appendBlock( - std::make_unique("tu1", "edit_file", QJsonObject())); + 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")); diff --git a/test/ContextRendererTest.cpp b/test/ContextRendererTest.cpp index bd347c7..59ffae8 100644 --- a/test/ContextRendererTest.cpp +++ b/test/ContextRendererTest.cpp @@ -138,6 +138,30 @@ TEST(ContextRendererTest, FileExistsOutsideAllowedRootsThrowsLoudly) EXPECT_TRUE(error.contains(QStringLiteral("file_exists"))); } +TEST(ContextRendererTest, FileExistsWithUnresolvedProjectDirReturnsFalse) +{ + QString error; + EXPECT_EQ( + render( + QStringLiteral( + "{% if file_exists(\"${PROJECT_DIR}/x.md\") %}yes{% else %}no{% endif %}"), + Bindings{QString(), QString()}, + &error), + QStringLiteral("no")); + EXPECT_TRUE(error.isEmpty()); +} + +TEST(ContextRendererTest, ReadFileWithUnresolvedProjectDirThrowsLoudly) +{ + QString error; + const QString out = render( + QStringLiteral("{{ read_file(\"${PROJECT_DIR}/x.md\") }}"), + Bindings{QString(), QString()}, + &error); + EXPECT_TRUE(out.isEmpty()); + EXPECT_TRUE(error.contains(QStringLiteral("read_file"))); +} + TEST(ContextRendererTest, HeadLinesTakesLeadingLines) { QTemporaryDir proj; @@ -153,8 +177,7 @@ TEST(ContextRendererTest, HeadLinesTakesLeadingLines) TEST(ContextRendererTest, StringHelpers) { const Bindings none{}; - EXPECT_EQ( - render(QStringLiteral("{{ basename(\"/a/b/c.txt\") }}"), none), QStringLiteral("c.txt")); + 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")); @@ -189,7 +212,8 @@ TEST(ContextRendererTest, SelectsCompletionRoleByLanguageFromQrc) 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 if language == \"c-like\" %}{{ read_file(\":/roles/code-completion-c-like.md\") " + "}}" "{%- else %}{{ read_file(\":/roles/code-completion.md\") }}" "{%- endif %}"); diff --git a/test/DocumentContextReaderTest.cpp b/test/DocumentContextReaderTest.cpp index 3fcbd91..2446cf5 100644 --- a/test/DocumentContextReaderTest.cpp +++ b/test/DocumentContextReaderTest.cpp @@ -370,24 +370,27 @@ TEST_F(DocumentContextReaderTest, testPrepareContext) (QodeAssist::Templates::ContextData{ .prefix = "Line 0\nLine 1\nLin", .suffix = "e 2\nLine 3\nLine 4", - .fileContext = "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n" - "Recent Project Changes Context:\n "})); + .fileContext + = "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n" + "Recent Project Changes Context:\n "})); EXPECT_EQ( reader.prepareContext(2, 3, *createSettingsForLines(1, 1)), (QodeAssist::Templates::ContextData{ .prefix = "Line 1\nLin", .suffix = "e 2\nLine 3", - .fileContext = "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n" - "Recent Project Changes Context:\n "})); + .fileContext + = "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n" + "Recent Project Changes Context:\n "})); EXPECT_EQ( reader.prepareContext(2, 3, *createSettingsForLines(2, 2)), (QodeAssist::Templates::ContextData{ .prefix = "Line 0\nLine 1\nLin", .suffix = "e 2\nLine 3\nLine 4", - .fileContext = "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n" - "Recent Project Changes Context:\n "})); + .fileContext + = "\nFile information:\nMIME type: text/python\nFile path: /path/to/file\n\n" + "Recent Project Changes Context:\n "})); } #include "DocumentContextReaderTest.moc" diff --git a/test/EnvBlockFormatterTest.cpp b/test/EnvBlockFormatterTest.cpp index f2bd8fc..4e660b8 100644 --- a/test/EnvBlockFormatterTest.cpp +++ b/test/EnvBlockFormatterTest.cpp @@ -34,8 +34,7 @@ TEST(EnvBlockFormatterTest, FormatProjectEmptyEnv) TEST(EnvBlockFormatterTest, FormatFileWithKnownMime) { - const QString out - = EnvBlockFormatter::formatFile({"/home/dev/myapp/main.cpp", "text/x-c++src"}); + 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:")); diff --git a/test/ErrorInfoTest.cpp b/test/ErrorInfoTest.cpp index 53003ba..8052156 100644 --- a/test/ErrorInfoTest.cpp +++ b/test/ErrorInfoTest.cpp @@ -10,7 +10,8 @@ using namespace QodeAssist; TEST(ErrorInfoTest, MakeErrorPopulatesFields) { - const ErrorInfo e = makeError(ErrorCategory::Tool, QStringLiteral("boom"), QStringLiteral("detail")); + 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")); @@ -42,7 +43,8 @@ TEST(ErrorInfoTest, CategorizesForbiddenAsAuth) TEST(ErrorInfoTest, CategorizesApiKeyAsAuth) { - EXPECT_EQ(categorizeProviderError(QStringLiteral("invalid api key supplied")), ErrorCategory::Auth); + EXPECT_EQ( + categorizeProviderError(QStringLiteral("invalid api key supplied")), ErrorCategory::Auth); } TEST(ErrorInfoTest, CategorizesAuthenticationAsAuth) diff --git a/test/JsonPromptTemplateTest.cpp b/test/JsonPromptTemplateTest.cpp index 14d22bb..b86d0dc 100644 --- a/test/JsonPromptTemplateTest.cpp +++ b/test/JsonPromptTemplateTest.cpp @@ -25,8 +25,8 @@ AgentConfig makeConfig(const QJsonObject &body) return cfg; } -const QString kUserMessages - = QStringLiteral("[ { \"role\": \"user\", \"content\": {{ tojson(ctx.prefix) }} } ]"); +const QString kUserMessages = QStringLiteral( + "[ { \"role\": \"user\", \"content\": {{ tojson(ctx.prefix) }} } ]"); const QString kSystemField = QStringLiteral( "{% if existsIn(ctx, \"system_prompt\") %}{{ tojson(ctx.system_prompt) }}{% endif %}"); @@ -35,12 +35,13 @@ const QString kSystemField = QStringLiteral( TEST(JsonPromptTemplateTest, RendersJinjaSplicesAndKeepsLiterals) { - auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{ - {"max_tokens", 128}, - {"temperature", 0.5}, - {"stream", true}, - {"messages", kUserMessages}, - })); + auto tmpl = JsonPromptTemplate::fromConfig(makeConfig( + QJsonObject{ + {"max_tokens", 128}, + {"temperature", 0.5}, + {"stream", true}, + {"messages", kUserMessages}, + })); ASSERT_NE(tmpl, nullptr); ContextData ctx; @@ -55,16 +56,16 @@ TEST(JsonPromptTemplateTest, RendersJinjaSplicesAndKeepsLiterals) const QJsonArray messages = request.value("messages").toArray(); ASSERT_EQ(messages.size(), 1); - EXPECT_EQ( - messages.at(0).toObject().value("content").toString(), QStringLiteral("hello world")); + 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}, - })); + auto tmpl = JsonPromptTemplate::fromConfig(makeConfig( + QJsonObject{ + {"system", kSystemField}, + {"messages", kUserMessages}, + })); ASSERT_NE(tmpl, nullptr); ContextData ctx; @@ -79,10 +80,11 @@ TEST(JsonPromptTemplateTest, DropsKeyWhenJinjaRendersEmpty) TEST(JsonPromptTemplateTest, RendersSystemPromptWhenPresent) { - auto tmpl = JsonPromptTemplate::fromConfig(makeConfig(QJsonObject{ - {"system", kSystemField}, - {"messages", kUserMessages}, - })); + auto tmpl = JsonPromptTemplate::fromConfig(makeConfig( + QJsonObject{ + {"system", kSystemField}, + {"messages", kUserMessages}, + })); ASSERT_NE(tmpl, nullptr); ContextData ctx; @@ -92,16 +94,16 @@ TEST(JsonPromptTemplateTest, RendersSystemPromptWhenPresent) QJsonObject request; ASSERT_TRUE(tmpl->buildFullRequest(request, ctx)); - EXPECT_EQ( - request.value("system").toString(), QStringLiteral("You are a helpful assistant.")); + 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}, - })); + auto tmpl = JsonPromptTemplate::fromConfig(makeConfig( + QJsonObject{ + {"thinking", QJsonObject{{"type", "adaptive"}, {"budget", 8192}}}, + {"messages", kUserMessages}, + })); ASSERT_NE(tmpl, nullptr); ContextData ctx; @@ -119,11 +121,45 @@ TEST(JsonPromptTemplateTest, RejectsBodyThatRendersInvalidJsonAtLoad) { QString error; auto tmpl = JsonPromptTemplate::fromConfig( - makeConfig(QJsonObject{ - {"messages", QStringLiteral("[ {{ tojson(ctx.prefix) }}")}, - }), + makeConfig( + QJsonObject{ + {"messages", QStringLiteral("[ {{ tojson(ctx.prefix) }}")}, + }), &error); EXPECT_EQ(tmpl, nullptr); EXPECT_FALSE(error.isEmpty()); } + +TEST(JsonPromptTemplateTest, RejectsHandQuotedInterpolationAtLoad) +{ + QString error; + auto tmpl = JsonPromptTemplate::fromConfig( + makeConfig( + QJsonObject{ + {"messages", + QStringLiteral("[ { \"role\": \"user\", \"content\": \"{{ ctx.prefix }}\" } ]")}, + }), + &error); + + EXPECT_EQ(tmpl, nullptr); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(JsonPromptTemplateTest, RoundTripsQuotesBackslashesAndNewlinesViaTojson) +{ + auto tmpl = JsonPromptTemplate::fromConfig(makeConfig( + QJsonObject{ + {"messages", kUserMessages}, + })); + ASSERT_NE(tmpl, nullptr); + + ContextData ctx; + ctx.prefix = QStringLiteral("a \"quoted\" back\\slash\nnewline"); + + QJsonObject request; + ASSERT_TRUE(tmpl->buildFullRequest(request, ctx)); + const QJsonArray messages = request.value("messages").toArray(); + ASSERT_EQ(messages.size(), 1); + EXPECT_EQ(messages.at(0).toObject().value("content").toString(), *ctx.prefix); +} diff --git a/test/MessageSerializerTest.cpp b/test/MessageSerializerTest.cpp index 8955297..70752ac 100644 --- a/test/MessageSerializerTest.cpp +++ b/test/MessageSerializerTest.cpp @@ -17,9 +17,6 @@ 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; @@ -92,10 +89,11 @@ TEST(MessageSerializerTest, RedactedThinkingRoundtrip) TEST(MessageSerializerTest, ImageBase64Roundtrip) { Message m(Message::Role::User); - m.appendBlock(std::make_unique( - QStringLiteral("ZGF0YQ=="), - QStringLiteral("image/png"), - LLMQore::ImageContent::ImageSourceType::Base64)); + 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(); @@ -109,10 +107,11 @@ TEST(MessageSerializerTest, ImageBase64Roundtrip) 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)); + 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(); @@ -124,10 +123,9 @@ TEST(MessageSerializerTest, ImageUrlSourceTypeRoundtrip) TEST(MessageSerializerTest, ToolUseRoundtrip) { Message m(Message::Role::Assistant); - m.appendBlock(std::make_unique( - QStringLiteral("tu1"), - QStringLiteral("read_file"), - QJsonObject{{"path", "a.cpp"}})); + 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(); @@ -157,8 +155,9 @@ TEST(MessageSerializerTest, ToolResultRoundtrip) TEST(MessageSerializerTest, StoredImageRoundtrip) { Message m(Message::Role::User); - m.appendBlock(std::make_unique( - QStringLiteral("shot.png"), QStringLiteral("stored/shot"), QStringLiteral("image/png"))); + 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(); @@ -173,8 +172,9 @@ TEST(MessageSerializerTest, StoredImageRoundtrip) TEST(MessageSerializerTest, StoredAttachmentRoundtrip) { Message m(Message::Role::User); - m.appendBlock(std::make_unique( - QStringLiteral("notes.txt"), QStringLiteral("stored/notes"))); + m.appendBlock( + std::make_unique( + QStringLiteral("notes.txt"), QStringLiteral("stored/notes"))); const QJsonObject block = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject(); @@ -188,8 +188,9 @@ TEST(MessageSerializerTest, StoredAttachmentRoundtrip) TEST(MessageSerializerTest, SkillInvocationRoundtrip) { Message m(Message::Role::User); - m.appendBlock(std::make_unique( - QStringLiteral("review"), QStringLiteral("Review the code."))); + m.appendBlock( + std::make_unique( + QStringLiteral("review"), QStringLiteral("Review the code."))); const QJsonObject block = MessageSerializer::toJson(m).value("blocks").toArray().first().toObject(); @@ -203,13 +204,14 @@ TEST(MessageSerializerTest, SkillInvocationRoundtrip) 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"))); + 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(); @@ -227,11 +229,12 @@ TEST(MessageSerializerTest, FileEditRoundtripWithStatusAndMessage) TEST(MessageSerializerTest, FileEditOmitsEmptyStatusMessageAndDefaultsToPending) { Message m(Message::Role::Assistant); - m.appendBlock(std::make_unique( - QStringLiteral("e1"), - QStringLiteral("a.cpp"), - QStringLiteral("old"), - QStringLiteral("new"))); + 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(); @@ -243,8 +246,9 @@ 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())); + 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); @@ -295,8 +299,7 @@ TEST(MessageSerializerTest, UnknownBlocksSkippedButKnownKept) QJsonObject json; json["role"] = QStringLiteral("assistant"); json["blocks"] = QJsonArray{ - QJsonObject{{"type", "future_block"}}, - QJsonObject{{"type", "text"}, {"text", "kept"}}}; + QJsonObject{{"type", "future_block"}}, QJsonObject{{"type", "text"}, {"text", "kept"}}}; bool ok = false; const Message m = MessageSerializer::fromJson(json, &ok); diff --git a/test/PipelinesConfigTest.cpp b/test/PipelinesConfigTest.cpp new file mode 100644 index 0000000..16c34d4 --- /dev/null +++ b/test/PipelinesConfigTest.cpp @@ -0,0 +1,139 @@ +// 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::Agents::AgentLoader; +using QodeAssist::Settings::PipelineRosters; +using QodeAssist::Settings::PipelinesConfig; +using QodeAssist::Settings::PipelinesLoadStatus; + +namespace { + +class PipelinesConfigTest : public ::testing::Test +{ +protected: + void SetUp() override + { + ASSERT_TRUE(m_dir.isValid()); + m_path = m_dir.filePath(QStringLiteral("pipelines.toml")); + PipelinesConfig::setFilePathForTests(m_path); + } + + void TearDown() override { PipelinesConfig::setFilePathForTests(QString()); } + + QTemporaryDir m_dir; + QString m_path; +}; + +void expectEqualRosters(const PipelineRosters &a, const PipelineRosters &b) +{ + EXPECT_EQ(a.codeCompletion, b.codeCompletion); + EXPECT_EQ(a.chatAssistant, b.chatAssistant); + EXPECT_EQ(a.chatCompression, b.chatCompression); + EXPECT_EQ(a.quickRefactor, b.quickRefactor); +} + +} // namespace + +TEST_F(PipelinesConfigTest, DefaultsFillEverySlot) +{ + const PipelineRosters defs = PipelineRosters::defaults(); + EXPECT_FALSE(defs.codeCompletion.isEmpty()); + EXPECT_FALSE(defs.chatAssistant.isEmpty()); + EXPECT_FALSE(defs.chatCompression.isEmpty()); + EXPECT_FALSE(defs.quickRefactor.isEmpty()); +} + +TEST_F(PipelinesConfigTest, DefaultsReferenceBundledAgents) +{ + Q_INIT_RESOURCE(agents); + + const AgentLoader::LoadResult result = AgentLoader::load(QStringLiteral(":/agents"), QString()); + ASSERT_FALSE(result.configs.empty()); + + QStringList bundledNames; + for (const auto &cfg : result.configs) + bundledNames.append(cfg.name); + + const PipelineRosters defs = PipelineRosters::defaults(); + QStringList referenced = defs.codeCompletion + defs.chatAssistant; + referenced << defs.chatCompression << defs.quickRefactor; + for (const QString &name : std::as_const(referenced)) { + EXPECT_TRUE(bundledNames.contains(name)) + << "default roster references unknown agent '" << name.toStdString() << "'"; + } +} + +TEST_F(PipelinesConfigTest, MissingFileYieldsDefaults) +{ + const auto result = PipelinesConfig::load(); + EXPECT_EQ(result.status, PipelinesLoadStatus::FileMissing); + expectEqualRosters(result.rosters, PipelineRosters::defaults()); +} + +TEST_F(PipelinesConfigTest, SaveLoadRoundTrip) +{ + PipelineRosters rosters; + rosters.codeCompletion = {QStringLiteral("Agent A"), QStringLiteral("Agent B")}; + rosters.chatAssistant = {QStringLiteral("Agent C")}; + rosters.chatCompression = QStringLiteral("Agent D"); + rosters.quickRefactor = QString(); + + ASSERT_TRUE(PipelinesConfig::save(rosters)); + + const auto result = PipelinesConfig::load(); + EXPECT_EQ(result.status, PipelinesLoadStatus::Ok); + expectEqualRosters(result.rosters, rosters); +} + +TEST_F(PipelinesConfigTest, ExplicitlyEmptySlotStaysEmpty) +{ + PipelineRosters rosters; + ASSERT_TRUE(PipelinesConfig::save(rosters)); + + const auto result = PipelinesConfig::load(); + EXPECT_EQ(result.status, PipelinesLoadStatus::Ok); + EXPECT_TRUE(result.rosters.codeCompletion.isEmpty()); + EXPECT_TRUE(result.rosters.chatAssistant.isEmpty()); + EXPECT_TRUE(result.rosters.chatCompression.isEmpty()); + EXPECT_TRUE(result.rosters.quickRefactor.isEmpty()); +} + +TEST_F(PipelinesConfigTest, LegacyArrayCollapsesToFirstName) +{ + QFile f(m_path); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + f.write( + "[pipelines]\n" + "code_completion = [\"CC\"]\n" + "chat_assistant = [\"CA\"]\n" + "chat_compression = [\"First\", \"Second\"]\n" + "quick_refactor = [\"Only\"]\n"); + f.close(); + + const auto result = PipelinesConfig::load(); + EXPECT_EQ(result.status, PipelinesLoadStatus::Ok); + EXPECT_EQ(result.rosters.chatCompression, QStringLiteral("First")); + EXPECT_EQ(result.rosters.quickRefactor, QStringLiteral("Only")); +} + +TEST_F(PipelinesConfigTest, SaveInvalidatesCache) +{ + PipelineRosters first; + first.chatCompression = QStringLiteral("Before"); + ASSERT_TRUE(PipelinesConfig::save(first)); + EXPECT_EQ(PipelinesConfig::loadCached().rosters.chatCompression, QStringLiteral("Before")); + + PipelineRosters second; + second.chatCompression = QStringLiteral("AfterX"); + ASSERT_TRUE(PipelinesConfig::save(second)); + EXPECT_EQ(PipelinesConfig::loadCached().rosters.chatCompression, QStringLiteral("AfterX")); +} diff --git a/test/ResponseCleanerTest.cpp b/test/ResponseCleanerTest.cpp index 828e979..7ecc868 100644 --- a/test/ResponseCleanerTest.cpp +++ b/test/ResponseCleanerTest.cpp @@ -57,8 +57,36 @@ TEST(ResponseCleanerTest, TrimsLeadingAndTrailingNewlines) 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")); } + +TEST(ResponseCleanerTest, FencedPythonDefLineIsPreserved) +{ + const QString input = QStringLiteral("```python\ndef foo():\n return 1\n```"); + EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("def foo():\n return 1")); +} + +TEST(ResponseCleanerTest, FencedAccessSpecifierLineIsPreserved) +{ + const QString input = QStringLiteral("```cpp\npublic:\n void foo();\n```"); + EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("public:\n void foo();")); +} + +TEST(ResponseCleanerTest, UnfencedCodeStartingWithColonLineIsPreserved) +{ + const QString input = QStringLiteral("def foo():\n return 1"); + EXPECT_EQ(ResponseCleaner::clean(input), input); +} + +TEST(ResponseCleanerTest, UnfencedCaseLabelIsPreserved) +{ + const QString input = QStringLiteral("case 1:\n break;"); + EXPECT_EQ(ResponseCleaner::clean(input), input); +} + +TEST(ResponseCleanerTest, UnfencedProseHeaderIsStripped) +{ + const QString input = QStringLiteral("Refactored version:\nint x = 1;"); + EXPECT_EQ(ResponseCleaner::clean(input), QStringLiteral("int x = 1;")); +} diff --git a/test/ResponseRouterTest.cpp b/test/ResponseRouterTest.cpp index 1db5eb5..f7d6b8a 100644 --- a/test/ResponseRouterTest.cpp +++ b/test/ResponseRouterTest.cpp @@ -52,8 +52,7 @@ public: void fireFailed(const QString &id, const QString &error) { emit requestFailed(id, error); } protected: - LLMQore::RequestID sendMessage( - const QJsonObject &, const QString &, LLMQore::RequestMode) override + LLMQore::RequestID sendMessage(const QJsonObject &, const QString &, LLMQore::RequestMode) override { return {}; } @@ -157,3 +156,91 @@ TEST(ResponseRouterTest, IgnoresEventsForInactiveRequest) EXPECT_TRUE(history.isEmpty()); } + +TEST(ResponseRouterTest, DistinctThinkingBlocksStayDistinctWithOwnSignatures) +{ + FakeClient client; + ConversationHistory history; + ResponseRouter router(&client, &history); + + const QString id = QStringLiteral("req-4"); + router.beginRequest(id); + client.fireThinking(id, QStringLiteral("first"), QStringLiteral("sig1")); + client.fireThinking(id, QStringLiteral("second"), QStringLiteral("sig2")); + + ASSERT_EQ(history.size(), 1); + const auto &blocks = history.messages()[0].blocks(); + ASSERT_EQ(blocks.size(), 2u); + + const auto *first = dynamic_cast(blocks[0].get()); + const auto *second = dynamic_cast(blocks[1].get()); + ASSERT_NE(first, nullptr); + ASSERT_NE(second, nullptr); + EXPECT_EQ(first->thinking(), QStringLiteral("first")); + EXPECT_EQ(first->signature(), QStringLiteral("sig1")); + EXPECT_EQ(second->thinking(), QStringLiteral("second")); + EXPECT_EQ(second->signature(), QStringLiteral("sig2")); +} + +TEST(ResponseRouterTest, SignatureOnlyEmissionBecomesRedactedThinking) +{ + FakeClient client; + ConversationHistory history; + ResponseRouter router(&client, &history); + + const QString id = QStringLiteral("req-5"); + router.beginRequest(id); + client.fireThinking(id, QStringLiteral("visible"), QStringLiteral("sig1")); + client.fireThinking(id, QString(), QStringLiteral("redacted-payload")); + + ASSERT_EQ(history.size(), 1); + const auto &blocks = history.messages()[0].blocks(); + ASSERT_EQ(blocks.size(), 2u); + + const auto *visible = dynamic_cast(blocks[0].get()); + const auto *redacted = dynamic_cast(blocks[1].get()); + ASSERT_NE(visible, nullptr); + ASSERT_NE(redacted, nullptr); + EXPECT_EQ(visible->signature(), QStringLiteral("sig1")); + EXPECT_EQ(redacted->signature(), QStringLiteral("redacted-payload")); +} + +TEST(ResponseRouterTest, TextAfterToolUseStaysAfterToolUse) +{ + FakeClient client; + ConversationHistory history; + ResponseRouter router(&client, &history); + + const QString id = QStringLiteral("req-6"); + router.beginRequest(id); + client.fireChunk(id, QStringLiteral("before")); + client.fireToolStarted(id, QStringLiteral("t1"), QStringLiteral("tool"), QJsonObject{}); + client.fireChunk(id, QStringLiteral("after")); + + ASSERT_EQ(history.size(), 1); + const auto &blocks = history.messages()[0].blocks(); + ASSERT_EQ(blocks.size(), 3u); + EXPECT_NE(dynamic_cast(blocks[0].get()), nullptr); + EXPECT_NE(dynamic_cast(blocks[1].get()), nullptr); + const auto *after = dynamic_cast(blocks[2].get()); + ASSERT_NE(after, nullptr); + EXPECT_EQ(after->text(), QStringLiteral("after")); +} + +TEST(ResponseRouterTest, BatchedToolResultsMergeIntoOneUserMessage) +{ + FakeClient client; + ConversationHistory history; + ResponseRouter router(&client, &history); + + const QString id = QStringLiteral("req-7"); + router.beginRequest(id); + client.fireChunk(id, QStringLiteral("calling tools")); + client.fireToolResult(id, QStringLiteral("t1"), QStringLiteral("tool_a"), QStringLiteral("r1")); + client.fireToolResult(id, QStringLiteral("t2"), QStringLiteral("tool_b"), QStringLiteral("r2")); + + ASSERT_EQ(history.size(), 2); + const Message &results = history.messages()[1]; + EXPECT_EQ(results.role(), Message::Role::User); + EXPECT_EQ(results.blocks().size(), 2u); +} diff --git a/test/SystemPromptBuilderTest.cpp b/test/SystemPromptBuilderTest.cpp index 30a1452..1eb7ad9 100644 --- a/test/SystemPromptBuilderTest.cpp +++ b/test/SystemPromptBuilderTest.cpp @@ -49,8 +49,10 @@ TEST(SystemPromptBuilderTest, EqualPriorityKeepsInsertionOrder) TEST(SystemPromptBuilderTest, AgentPriorityComposesBeforeDefault) { SystemPromptBuilder builder; - builder.setLayer(QStringLiteral("env"), QStringLiteral("ENV"), SystemPromptBuilder::kDefaultPriority); - builder.setLayer(QStringLiteral("agent"), QStringLiteral("SYS"), SystemPromptBuilder::kAgentPriority); + builder.setLayer( + QStringLiteral("env"), QStringLiteral("ENV"), SystemPromptBuilder::kDefaultPriority); + builder.setLayer( + QStringLiteral("agent"), QStringLiteral("SYS"), SystemPromptBuilder::kAgentPriority); EXPECT_EQ(builder.compose(), QStringLiteral("SYS\n\nENV")); } diff --git a/widgets/QuickRefactorDialog.cpp b/widgets/QuickRefactorDialog.cpp index 671f12b..55a69f2 100644 --- a/widgets/QuickRefactorDialog.cpp +++ b/widgets/QuickRefactorDialog.cpp @@ -181,8 +181,9 @@ void QuickRefactorDialog::setupUi() } QLabel *agentHint = new QLabel( - Tr::tr("No Quick Refactor agent is set. " - "Assign one in the Pipelines settings."), + Tr::tr( + "No Quick Refactor agent is set. " + "Assign one in the Pipelines settings."), this); agentHint->setWordWrap(true); agentHint->setTextInteractionFlags( diff --git a/widgets/RefactorWidgetHandler.hpp b/widgets/RefactorWidgetHandler.hpp index 8abdbeb..d75d697 100644 --- a/widgets/RefactorWidgetHandler.hpp +++ b/widgets/RefactorWidgetHandler.hpp @@ -35,7 +35,7 @@ public: const Utils::Text::Range &range, const QString &contextBefore, const QString &contextAfter); - + void hideRefactorWidget(); void setApplyCallback(std::function callback);