From 161d77ac04800dc94b43d9379c2af83345b3d734 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:33:15 +0100 Subject: [PATCH] feat: Add Claude extended thinking (#254) * feat: Add Claude extended thinking * fix: Set 1.0 temperature for thinking mode --- ChatView/CMakeLists.txt | 3 + ChatView/ChatModel.cpp | 55 ++++++++ ChatView/ChatModel.hpp | 9 +- ChatView/ChatRootView.cpp | 38 ++++++ ChatView/ChatRootView.hpp | 11 +- ChatView/ChatSerializer.cpp | 6 + ChatView/ClientInterface.cpp | 22 +++- ChatView/icons/thinking-icon-off.svg | 4 + ChatView/icons/thinking-icon-on.svg | 3 + ChatView/qml/RootItem.qml | 38 ++++++ ChatView/qml/ThinkingStatusItem.qml | 181 +++++++++++++++++++++++++++ ChatView/qml/parts/TopBar.qml | 90 ++++++++----- llmcore/ContentBlocks.hpp | 64 ++++++++++ llmcore/ContextData.hpp | 3 + llmcore/Provider.hpp | 4 + providers/ClaudeMessage.cpp | 61 ++++++++- providers/ClaudeMessage.hpp | 3 + providers/ClaudeProvider.cpp | 117 ++++++++++++++++- providers/ClaudeProvider.hpp | 1 + settings/ChatAssistantSettings.cpp | 36 +++++- settings/ChatAssistantSettings.hpp | 5 + settings/SettingsConstants.hpp | 3 + templates/Claude.hpp | 28 ++++- 23 files changed, 745 insertions(+), 40 deletions(-) create mode 100644 ChatView/icons/thinking-icon-off.svg create mode 100644 ChatView/icons/thinking-icon-on.svg create mode 100644 ChatView/qml/ThinkingStatusItem.qml diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index 95daf43..d5d790a 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -18,6 +18,7 @@ qt_add_qml_module(QodeAssistChatView qml/parts/AttachedFilesPlace.qml qml/parts/Toast.qml qml/ToolStatusItem.qml + qml/ThinkingStatusItem.qml qml/FileEditItem.qml qml/parts/RulesViewer.qml qml/parts/FileEditsActionBar.qml @@ -42,6 +43,8 @@ qt_add_qml_module(QodeAssistChatView icons/apply-changes-button.svg icons/undo-changes-button.svg icons/reject-changes-button.svg + icons/thinking-icon-on.svg + icons/thinking-icon-off.svg SOURCES ChatWidget.hpp ChatWidget.cpp diff --git a/ChatView/ChatModel.cpp b/ChatView/ChatModel.cpp index e3498f4..059e0f2 100644 --- a/ChatView/ChatModel.cpp +++ b/ChatView/ChatModel.cpp @@ -81,6 +81,9 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const } return filenames; } + case Roles::IsRedacted: { + return message.isRedacted; + } default: return QVariant(); } @@ -92,6 +95,7 @@ QHash ChatModel::roleNames() const roles[Roles::RoleType] = "roleType"; roles[Roles::Content] = "content"; roles[Roles::Attachments] = "attachments"; + roles[Roles::IsRedacted] = "isRedacted"; return roles; } @@ -402,6 +406,57 @@ void ChatModel::updateToolResult( } } +void ChatModel::addThinkingBlock( + const QString &requestId, const QString &thinking, const QString &signature) +{ + LOG_MESSAGE(QString("Adding thinking block: requestId=%1, thinking length=%2, signature length=%3") + .arg(requestId) + .arg(thinking.length()) + .arg(signature.length())); + + QString displayContent = thinking; + if (!signature.isEmpty()) { + displayContent += "\n[Signature: " + signature.left(40) + "...]"; + } + + beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); + Message thinkingMessage; + thinkingMessage.role = ChatRole::Thinking; + thinkingMessage.content = displayContent; + thinkingMessage.id = requestId; + thinkingMessage.isRedacted = false; + thinkingMessage.signature = signature; + m_messages.append(thinkingMessage); + endInsertRows(); + LOG_MESSAGE(QString("Added thinking message at index %1 with signature length=%2") + .arg(m_messages.size() - 1).arg(signature.length())); +} + +void ChatModel::addRedactedThinkingBlock(const QString &requestId, const QString &signature) +{ + LOG_MESSAGE( + QString("Adding redacted thinking block: requestId=%1, signature length=%2") + .arg(requestId) + .arg(signature.length())); + + QString displayContent = "[Thinking content redacted by safety systems]"; + if (!signature.isEmpty()) { + displayContent += "\n[Signature: " + signature.left(40) + "...]"; + } + + beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size()); + Message thinkingMessage; + thinkingMessage.role = ChatRole::Thinking; + thinkingMessage.content = displayContent; + thinkingMessage.id = requestId; + thinkingMessage.isRedacted = true; + thinkingMessage.signature = signature; + m_messages.append(thinkingMessage); + endInsertRows(); + LOG_MESSAGE(QString("Added redacted thinking message at index %1 with signature length=%2") + .arg(m_messages.size() - 1).arg(signature.length())); +} + void ChatModel::updateMessageContent(const QString &messageId, const QString &newContent) { for (int i = 0; i < m_messages.size(); ++i) { diff --git a/ChatView/ChatModel.hpp b/ChatView/ChatModel.hpp index 24ac2ba..d27d4db 100644 --- a/ChatView/ChatModel.hpp +++ b/ChatView/ChatModel.hpp @@ -37,10 +37,10 @@ class ChatModel : public QAbstractListModel QML_ELEMENT public: - enum ChatRole { System, User, Assistant, Tool, FileEdit }; + enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking }; Q_ENUM(ChatRole) - enum Roles { RoleType = Qt::UserRole, Content, Attachments }; + enum Roles { RoleType = Qt::UserRole, Content, Attachments, IsRedacted }; Q_ENUM(Roles) struct Message @@ -48,6 +48,8 @@ public: ChatRole role; QString content; QString id; + bool isRedacted = false; + QString signature = QString(); QList attachments; }; @@ -83,6 +85,9 @@ public: const QString &toolId, const QString &toolName, const QString &result); + void addThinkingBlock( + const QString &requestId, const QString &thinking, const QString &signature); + void addRedactedThinkingBlock(const QString &requestId, const QString &signature); void updateMessageContent(const QString &messageId, const QString &newContent); void setLoadingFromHistory(bool loading); diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 6403d64..3589f48 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -43,6 +43,8 @@ #include "context/ContextManager.hpp" #include "context/TokenUtils.hpp" #include "llmcore/RulesLoader.hpp" +#include "ProvidersManager.hpp" +#include "GeneralSettings.hpp" namespace QodeAssist::Chat { @@ -197,12 +199,24 @@ ChatRootView::ChatRootView(QQuickItem *parent) QSettings appSettings; m_isAgentMode = appSettings.value("QodeAssist/Chat/AgentMode", false).toBool(); + m_isThinkingMode = Settings::chatAssistantSettings().enableThinkingMode(); + + connect( + &Settings::chatAssistantSettings().enableThinkingMode, + &Utils::BaseAspect::changed, + this, + [this]() { setIsThinkingMode(Settings::chatAssistantSettings().enableThinkingMode()); }); connect( &Settings::toolsSettings().useTools, &Utils::BaseAspect::changed, this, &ChatRootView::toolsSupportEnabledChanged); + connect( + &Settings::generalSettings().caProvider, + &Utils::BaseAspect::changed, + this, + &ChatRootView::isThinkingSupportChanged); } ChatModel *ChatRootView::chatModel() const @@ -779,6 +793,22 @@ void ChatRootView::setIsAgentMode(bool newIsAgentMode) } } +bool ChatRootView::isThinkingMode() const +{ + return m_isThinkingMode; +} + +void ChatRootView::setIsThinkingMode(bool newIsThinkingMode) +{ + if (m_isThinkingMode != newIsThinkingMode) { + m_isThinkingMode = newIsThinkingMode; + + Settings::chatAssistantSettings().enableThinkingMode.setValue(newIsThinkingMode); + + emit isThinkingModeChanged(); + } +} + bool ChatRootView::toolsSupportEnabled() const { return Settings::toolsSettings().useTools(); @@ -1087,4 +1117,12 @@ QString ChatRootView::lastInfoMessage() const return m_lastInfoMessage; } +bool ChatRootView::isThinkingSupport() const +{ + auto providerName = Settings::generalSettings().caProvider(); + auto provider = LLMCore::ProvidersManager::instance().getProviderByName(providerName); + + return provider && provider->supportThinking(); +} + } // namespace QodeAssist::Chat diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index fc2c3f6..0d79e90 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -49,6 +49,7 @@ class ChatRootView : public QQuickItem Q_PROPERTY(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL) Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL) Q_PROPERTY(bool isAgentMode READ isAgentMode WRITE setIsAgentMode NOTIFY isAgentModeChanged FINAL) + Q_PROPERTY(bool isThinkingMode READ isThinkingMode WRITE setIsThinkingMode NOTIFY isThinkingModeChanged FINAL) Q_PROPERTY( bool toolsSupportEnabled READ toolsSupportEnabled NOTIFY toolsSupportEnabledChanged FINAL) @@ -56,6 +57,7 @@ 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(bool isThinkingSupport READ isThinkingSupport NOTIFY isThinkingSupportChanged FINAL) QML_ELEMENT @@ -118,6 +120,8 @@ public: bool isAgentMode() const; void setIsAgentMode(bool newIsAgentMode); + bool isThinkingMode() const; + void setIsThinkingMode(bool newIsThinkingMode); bool toolsSupportEnabled() const; Q_INVOKABLE void applyFileEdit(const QString &editId); @@ -125,7 +129,6 @@ public: Q_INVOKABLE void undoFileEdit(const QString &editId); Q_INVOKABLE void openFileEditInEditor(const QString &editId); - // Mass file edit operations for current message Q_INVOKABLE void applyAllFileEditsForCurrentMessage(); Q_INVOKABLE void undoAllFileEditsForCurrentMessage(); Q_INVOKABLE void updateCurrentMessageEditsStats(); @@ -137,6 +140,8 @@ public: QString lastInfoMessage() const; + bool isThinkingSupport() const; + public slots: void sendMessage(const QString &message); void copyToClipboard(const QString &text); @@ -166,9 +171,12 @@ signals: void activeRulesCountChanged(); void isAgentModeChanged(); + void isThinkingModeChanged(); void toolsSupportEnabledChanged(); void currentMessageEditsStatsChanged(); + void isThinkingSupportChanged(); + private: void updateFileEditStatus(const QString &editId, const QString &status); QString getChatsHistoryDir() const; @@ -189,6 +197,7 @@ private: QString m_lastErrorMessage; QVariantList m_activeRules; bool m_isAgentMode; + bool m_isThinkingMode; QString m_currentMessageRequestId; int m_currentMessageTotalEdits{0}; diff --git a/ChatView/ChatSerializer.cpp b/ChatView/ChatSerializer.cpp index 75f5b03..0ee125f 100644 --- a/ChatView/ChatSerializer.cpp +++ b/ChatView/ChatSerializer.cpp @@ -83,6 +83,10 @@ QJsonObject ChatSerializer::serializeMessage(const ChatModel::Message &message) messageObj["role"] = static_cast(message.role); messageObj["content"] = message.content; messageObj["id"] = message.id; + messageObj["isRedacted"] = message.isRedacted; + if (!message.signature.isEmpty()) { + messageObj["signature"] = message.signature; + } return messageObj; } @@ -92,6 +96,8 @@ ChatModel::Message ChatSerializer::deserializeMessage(const QJsonObject &json) message.role = static_cast(json["role"].toInt()); message.content = json["content"].toString(); message.id = json["id"].toString(); + message.isRedacted = json["isRedacted"].toBool(false); + message.signature = json["signature"].toString(); return message; } diff --git a/ChatView/ClientInterface.cpp b/ChatView/ClientInterface.cpp index f097ecf..a4083c0 100644 --- a/ChatView/ClientInterface.cpp +++ b/ChatView/ClientInterface.cpp @@ -119,7 +119,15 @@ void ClientInterface::sendMessage( if (msg.role == ChatModel::ChatRole::Tool || msg.role == ChatModel::ChatRole::FileEdit) { continue; } - messages.append({msg.role == ChatModel::ChatRole::User ? "user" : "assistant", msg.content}); + + LLMCore::Message apiMessage; + apiMessage.role = msg.role == ChatModel::ChatRole::User ? "user" : "assistant"; + apiMessage.content = msg.content; + apiMessage.isThinking = (msg.role == ChatModel::ChatRole::Thinking); + apiMessage.isRedacted = msg.isRedacted; + apiMessage.signature = msg.signature; + + messages.append(apiMessage); } context.history = messages; @@ -189,6 +197,18 @@ void ClientInterface::sendMessage( this, &ClientInterface::handleCleanAccumulatedData, Qt::UniqueConnection); + connect( + provider, + &LLMCore::Provider::thinkingBlockReceived, + m_chatModel, + &ChatModel::addThinkingBlock, + Qt::UniqueConnection); + connect( + provider, + &LLMCore::Provider::redactedThinkingBlockReceived, + m_chatModel, + &ChatModel::addRedactedThinkingBlock, + Qt::UniqueConnection); provider->sendRequest(requestId, config.url, config.providerRequest); } diff --git a/ChatView/icons/thinking-icon-off.svg b/ChatView/icons/thinking-icon-off.svg new file mode 100644 index 0000000..f456a72 --- /dev/null +++ b/ChatView/icons/thinking-icon-off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ChatView/icons/thinking-icon-on.svg b/ChatView/icons/thinking-icon-on.svg new file mode 100644 index 0000000..c1ffd3f --- /dev/null +++ b/ChatView/icons/thinking-icon-on.svg @@ -0,0 +1,3 @@ + + + diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 5ec87f7..0e26a0a 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -91,6 +91,13 @@ ChatRootView { root.isAgentMode = agentModeSwitch.checked } } + thinkingMode { + checked: root.isThinkingMode + enabled: root.isThinkingSupport + onCheckedChanged: { + root.isThinkingMode = thinkingMode.checked + } + } } ListView { @@ -116,6 +123,8 @@ ChatRootView { return toolMessageComponent } else if (model.roleType === ChatModel.FileEdit) { return fileEditMessageComponent + } else if (model.roleType === ChatModel.Thinking) { + return thinkingMessageComponent } else { return chatItemComponent } @@ -199,6 +208,35 @@ ChatRootView { } } } + + Component { + id: thinkingMessageComponent + + ThinkingStatusItem { + width: parent.width + thinkingContent: { + // Extract thinking content and signature + let content = model.content + let signatureStart = content.indexOf("\n[Signature:") + if (signatureStart >= 0) { + return content.substring(0, signatureStart) + } + return content + } + signature: { + let content = model.content + let signatureStart = content.indexOf("\n[Signature: ") + if (signatureStart >= 0) { + let signatureEnd = content.indexOf("...]", signatureStart) + if (signatureEnd >= 0) { + return content.substring(signatureStart + 13, signatureEnd) + } + } + return "" + } + isRedacted: model.isRedacted !== undefined ? model.isRedacted : false + } + } } ScrollView { diff --git a/ChatView/qml/ThinkingStatusItem.qml b/ChatView/qml/ThinkingStatusItem.qml new file mode 100644 index 0000000..4d74a90 --- /dev/null +++ b/ChatView/qml/ThinkingStatusItem.qml @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +import QtQuick +import Qt.labs.platform as Platform + +Rectangle { + id: root + + property string thinkingContent: "" + property string signature: "" + property bool isRedacted: false + property bool expanded: false + + radius: 6 + color: palette.base + clip: true + + Behavior on implicitHeight { + NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } + } + + MouseArea { + id: header + + width: parent.width + height: headerRow.height + 10 + cursorShape: Qt.PointingHandCursor + onClicked: root.expanded = !root.expanded + + Row { + id: headerRow + + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + leftMargin: 10 + } + width: parent.width + spacing: 8 + + Text { + text: root.isRedacted ? qsTr("Thinking (Redacted)") + : qsTr("Thinking") + font.pixelSize: 13 + font.bold: true + color: palette.text + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.expanded ? "▼" : "▶" + font.pixelSize: 10 + color: palette.mid + } + } + } + + Column { + id: contentColumn + + anchors { + left: parent.left + right: parent.right + top: header.bottom + margins: 10 + } + spacing: 8 + + Text { + visible: root.isRedacted + width: parent.width + text: qsTr("Thinking content was redacted by safety systems") + font.pixelSize: 11 + font.italic: true + color: Qt.rgba(0.8, 0.4, 0.4, 1.0) + wrapMode: Text.WordWrap + } + + TextEdit { + id: thinkingText + + visible: !root.isRedacted + width: parent.width + text: root.thinkingContent + readOnly: true + selectByMouse: true + color: palette.text + wrapMode: Text.WordWrap + font.family: "monospace" + font.pixelSize: 11 + selectionColor: palette.highlight + } + + // Rectangle { + // visible: root.signature.length > 0 && root.expanded + // width: parent.width + // height: signatureText.height + 10 + // color: palette.alternateBase + // radius: 4 + + // Text { + // id: signatureText + + // anchors { + // left: parent.left + // right: parent.right + // verticalCenter: parent.verticalCenter + // margins: 5 + // } + // text: qsTr("Signature: %1").arg(root.signature.substring(0, Math.min(40, root.signature.length)) + "...") + // font.pixelSize: 9 + // font.family: "monospace" + // color: palette.mid + // elide: Text.ElideRight + // } + // } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: contextMenu.open() + propagateComposedEvents: true + } + + Platform.Menu { + id: contextMenu + + Platform.MenuItem { + text: root.expanded ? qsTr("Collapse") : qsTr("Expand") + onTriggered: root.expanded = !root.expanded + } + } + + Rectangle { + id: thinkingMarker + + anchors.verticalCenter: parent.verticalCenter + width: 3 + height: root.height - root.radius + color: root.isRedacted ? Qt.rgba(0.8, 0.3, 0.3, 0.9) + : (root.color.hslLightness > 0.5 ? Qt.darker(palette.alternateBase, 1.3) + : Qt.lighter(palette.alternateBase, 1.3)) + radius: root.radius + } + + states: [ + State { + when: !root.expanded + PropertyChanges { + target: root + implicitHeight: header.height + } + }, + State { + when: root.expanded + PropertyChanges { + target: root + implicitHeight: header.height + contentColumn.height + 20 + } + } + ] +} + diff --git a/ChatView/qml/parts/TopBar.qml b/ChatView/qml/parts/TopBar.qml index 72e506b..81abbad 100644 --- a/ChatView/qml/parts/TopBar.qml +++ b/ChatView/qml/parts/TopBar.qml @@ -35,6 +35,7 @@ Rectangle { property alias pinButton: pinButtonId property alias rulesButton: rulesButtonId property alias agentModeSwitch: agentModeSwitchId + property alias thinkingMode: thinkingModeId property alias activeRulesCount: activeRulesCountId.text color: palette.window.hslLightness > 0.5 ? @@ -50,39 +51,70 @@ Rectangle { } spacing: 10 - QoAButton { - id: pinButtonId + Row { + height: agentModeSwitchId.height + spacing: 10 - checkable: true + QoAButton { + id: pinButtonId - icon { - source: checked ? "qrc:/qt/qml/ChatView/icons/window-lock.svg" - : "qrc:/qt/qml/ChatView/icons/window-unlock.svg" - color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF" - height: 15 - width: 15 - } - ToolTip.visible: hovered - ToolTip.delay: 250 - ToolTip.text: checked ? qsTr("Unpin chat window") - : qsTr("Pin chat window to the top") - } + anchors.verticalCenter: parent.verticalCenter + checkable: true - QoATextSlider { - id: agentModeSwitchId - - leftText: "chat" - rightText: "AI Agent" - - ToolTip.visible: hovered - ToolTip.delay: 250 - ToolTip.text: { - if (!agentModeSwitchId.enabled) { - return qsTr("Tools are disabled in General Settings") + icon { + source: checked ? "qrc:/qt/qml/ChatView/icons/window-lock.svg" + : "qrc:/qt/qml/ChatView/icons/window-unlock.svg" + color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF" + height: 15 + width: 15 } - return checked - ? qsTr("Agent Mode: AI can use tools to read files, search project, and build code") - : qsTr("Chat Mode: Simple conversation without tool access") + ToolTip.visible: hovered + ToolTip.delay: 250 + ToolTip.text: checked ? qsTr("Unpin chat window") + : qsTr("Pin chat window to the top") + } + + QoATextSlider { + id: agentModeSwitchId + + anchors.verticalCenter: parent.verticalCenter + + leftText: "chat" + rightText: "AI Agent" + + ToolTip.visible: hovered + ToolTip.delay: 250 + ToolTip.text: { + if (!agentModeSwitchId.enabled) { + return qsTr("Tools are disabled in General Settings") + } + return checked + ? qsTr("Agent Mode: AI can use tools to read files, search project, and build code") + : qsTr("Chat Mode: Simple conversation without tool access") + } + } + + QoAButton { + id: thinkingModeId + + anchors.verticalCenter: parent.verticalCenter + + checkable: true + opacity: enabled ? 1.0 : 0.2 + + icon { + source: checked ? "qrc:/qt/qml/ChatView/icons/thinking-icon-on.svg" + : "qrc:/qt/qml/ChatView/icons/thinking-icon-off.svg" + color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF" + height: 15 + width: 15 + } + + ToolTip.visible: hovered + ToolTip.delay: 250 + ToolTip.text: enabled ? (checked ? qsTr("Thinking Mode enabled (Check model list support it)") + : qsTr("Thinking Mode disabled")) + : qsTr("Thinking Mode is not available for this provider") } } diff --git a/llmcore/ContentBlocks.hpp b/llmcore/ContentBlocks.hpp index e8ab71b..f9434b2 100644 --- a/llmcore/ContentBlocks.hpp +++ b/llmcore/ContentBlocks.hpp @@ -137,4 +137,68 @@ private: QString m_result; }; +class ThinkingContent : public ContentBlock +{ + Q_OBJECT +public: + explicit ThinkingContent(const QString &thinking = QString(), const QString &signature = QString()) + : ContentBlock() + , m_thinking(thinking) + , m_signature(signature) + {} + + QString type() const override { return "thinking"; } + QString thinking() const { return m_thinking; } + QString signature() const { return m_signature; } + void appendThinking(const QString &text) { m_thinking += text; } + void setThinking(const QString &text) { m_thinking = text; } + void setSignature(const QString &signature) { m_signature = signature; } + + QJsonValue toJson(ProviderFormat format) const override + { + Q_UNUSED(format); + // Only include signature field if it's not empty + // Empty signature is rejected by API with "Invalid signature" error + // In streaming mode, signature is not provided, so we omit the field entirely + QJsonObject obj{{"type", "thinking"}, {"thinking", m_thinking}}; + if (!m_signature.isEmpty()) { + obj["signature"] = m_signature; + } + return obj; + } + +private: + QString m_thinking; + QString m_signature; +}; + +class RedactedThinkingContent : public ContentBlock +{ + Q_OBJECT +public: + explicit RedactedThinkingContent(const QString &signature = QString()) + : ContentBlock() + , m_signature(signature) + {} + + QString type() const override { return "redacted_thinking"; } + QString signature() const { return m_signature; } + void setSignature(const QString &signature) { m_signature = signature; } + + QJsonValue toJson(ProviderFormat format) const override + { + Q_UNUSED(format); + // Only include signature field if it's not empty + // Empty signature is rejected by API with "Invalid signature" error + QJsonObject obj{{"type", "redacted_thinking"}}; + if (!m_signature.isEmpty()) { + obj["signature"] = m_signature; + } + return obj; + } + +private: + QString m_signature; +}; + } // namespace QodeAssist::LLMCore diff --git a/llmcore/ContextData.hpp b/llmcore/ContextData.hpp index fdc2e7e..b174452 100644 --- a/llmcore/ContextData.hpp +++ b/llmcore/ContextData.hpp @@ -28,6 +28,9 @@ struct Message { QString role; QString content; + QString signature; + bool isThinking = false; + bool isRedacted = false; // clang-format off bool operator==(const Message&) const = default; diff --git a/llmcore/Provider.hpp b/llmcore/Provider.hpp index 797772f..9d47ad9 100644 --- a/llmcore/Provider.hpp +++ b/llmcore/Provider.hpp @@ -65,6 +65,7 @@ public: = 0; virtual bool supportsTools() const { return false; }; + virtual bool supportThinking() const { return false; }; virtual void cancelRequest(const RequestID &requestId); @@ -92,6 +93,9 @@ signals: const QString &toolName, const QString &result); void continuationStarted(const QodeAssist::LLMCore::RequestID &requestId); + void thinkingBlockReceived( + const QString &requestId, const QString &thinking, const QString &signature); + void redactedThinkingBlockReceived(const QString &requestId, const QString &signature); protected: QJsonObject parseEventLine(const QString &line); diff --git a/providers/ClaudeMessage.cpp b/providers/ClaudeMessage.cpp index 0f338b0..c2fde1b 100644 --- a/providers/ClaudeMessage.cpp +++ b/providers/ClaudeMessage.cpp @@ -46,6 +46,19 @@ void ClaudeMessage::handleContentBlockStart( addCurrentContent(toolId, toolName, toolInput); m_pendingToolInputs[index] = ""; + + } else if (blockType == "thinking") { + QString thinking = data["thinking"].toString(); + QString signature = data["signature"].toString(); + LOG_MESSAGE(QString("ClaudeMessage: Creating thinking block with signature length=%1") + .arg(signature.length())); + addCurrentContent(thinking, signature); + + } else if (blockType == "redacted_thinking") { + QString signature = data["signature"].toString(); + LOG_MESSAGE(QString("ClaudeMessage: Creating redacted_thinking block with signature length=%1") + .arg(signature.length())); + addCurrentContent(signature); } } @@ -66,6 +79,24 @@ void ClaudeMessage::handleContentBlockDelta( if (m_pendingToolInputs.contains(index)) { m_pendingToolInputs[index] += partialJson; } + + } else if (deltaType == "thinking_delta") { + if (auto thinkingContent = qobject_cast(m_currentBlocks[index])) { + thinkingContent->appendThinking(delta["thinking"].toString()); + } + + } else if (deltaType == "signature_delta") { + if (auto thinkingContent = qobject_cast(m_currentBlocks[index])) { + QString signature = delta["signature"].toString(); + thinkingContent->setSignature(signature); + LOG_MESSAGE(QString("Set signature for thinking block %1: length=%2") + .arg(index).arg(signature.length())); + } else if (auto redactedContent = qobject_cast(m_currentBlocks[index])) { + QString signature = delta["signature"].toString(); + redactedContent->setSignature(signature); + LOG_MESSAGE(QString("Set signature for redacted_thinking block %1: length=%2") + .arg(index).arg(signature.length())); + } } } @@ -104,11 +135,17 @@ QJsonObject ClaudeMessage::toProviderFormat() const message["role"] = "assistant"; QJsonArray content; + for (auto block : m_currentBlocks) { - content.append(block->toJson(LLMCore::ProviderFormat::Claude)); + QJsonValue blockJson = block->toJson(LLMCore::ProviderFormat::Claude); + content.append(blockJson); } message["content"] = content; + + LOG_MESSAGE(QString("ClaudeMessage::toProviderFormat - message with %1 content block(s)") + .arg(m_currentBlocks.size())); + return message; } @@ -138,6 +175,28 @@ QList ClaudeMessage::getCurrentToolUseContent() const return toolBlocks; } +QList ClaudeMessage::getCurrentThinkingContent() const +{ + QList thinkingBlocks; + for (auto block : m_currentBlocks) { + if (auto thinkingContent = qobject_cast(block)) { + thinkingBlocks.append(thinkingContent); + } + } + return thinkingBlocks; +} + +QList ClaudeMessage::getCurrentRedactedThinkingContent() const +{ + QList redactedBlocks; + for (auto block : m_currentBlocks) { + if (auto redactedContent = qobject_cast(block)) { + redactedBlocks.append(redactedContent); + } + } + return redactedBlocks; +} + void ClaudeMessage::startNewContinuation() { LOG_MESSAGE(QString("ClaudeMessage: Starting new continuation")); diff --git a/providers/ClaudeMessage.hpp b/providers/ClaudeMessage.hpp index 4cf84e7..5c6b623 100644 --- a/providers/ClaudeMessage.hpp +++ b/providers/ClaudeMessage.hpp @@ -39,6 +39,9 @@ public: LLMCore::MessageState state() const { return m_state; } QList getCurrentToolUseContent() const; + QList getCurrentThinkingContent() const; + QList getCurrentRedactedThinkingContent() const; + const QList &getCurrentBlocks() const { return m_currentBlocks; } void startNewContinuation(); diff --git a/providers/ClaudeProvider.cpp b/providers/ClaudeProvider.cpp index b9f4159..fcf3434 100644 --- a/providers/ClaudeProvider.cpp +++ b/providers/ClaudeProvider.cpp @@ -86,7 +86,6 @@ void ClaudeProvider::prepareRequest( auto applyModelParams = [&request](const auto &settings) { request["max_tokens"] = settings.maxTokens(); - request["temperature"] = settings.temperature(); if (settings.useTopP()) request["top_p"] = settings.topP(); if (settings.useTopK()) @@ -96,8 +95,21 @@ void ClaudeProvider::prepareRequest( if (type == LLMCore::RequestType::CodeCompletion) { applyModelParams(Settings::codeCompletionSettings()); + request["temperature"] = Settings::codeCompletionSettings().temperature(); } else { - applyModelParams(Settings::chatAssistantSettings()); + const auto &chatSettings = Settings::chatAssistantSettings(); + applyModelParams(chatSettings); + + if (chatSettings.enableThinkingMode()) { + QJsonObject thinkingObj; + thinkingObj["type"] = "enabled"; + thinkingObj["budget_tokens"] = chatSettings.thinkingBudgetTokens(); + request["thinking"] = thinkingObj; + request["max_tokens"] = chatSettings.thinkingMaxTokens(); + request["temperature"] = 1.0; + } else { + request["temperature"] = chatSettings.temperature(); + } } if (isToolsEnabled) { @@ -169,7 +181,8 @@ QList ClaudeProvider::validateRequest(const QJsonObject &request, LLMCo {"top_k", {}}, {"stop", QJsonArray{}}, {"stream", {}}, - {"tools", {}}}; + {"tools", {}}, + {"thinking", QJsonObject{{"type", {}}, {"budget_tokens", {}}}}}; return LLMCore::ValidationUtils::validateRequestFields(request, templateReq); } @@ -220,6 +233,10 @@ bool ClaudeProvider::supportsTools() const return true; } +bool ClaudeProvider::supportThinking() const { + return true; +}; + void ClaudeProvider::cancelRequest(const LLMCore::RequestID &requestId) { LOG_MESSAGE(QString("ClaudeProvider: Cancelling request %1").arg(requestId)); @@ -308,7 +325,14 @@ void ClaudeProvider::onToolExecutionComplete( messages.append(userMessage); continuationRequest["messages"] = messages; - + + if (continuationRequest.contains("thinking")) { + QJsonObject thinkingObj = continuationRequest["thinking"].toObject(); + LOG_MESSAGE(QString("Thinking mode preserved for continuation: type=%1, budget=%2 tokens") + .arg(thinkingObj["type"].toString()) + .arg(thinkingObj["budget_tokens"].toInt())); + } + LOG_MESSAGE(QString("Sending continuation request for %1 with %2 tool results") .arg(requestId) .arg(toolResults.size())); @@ -347,6 +371,13 @@ void ClaudeProvider::processStreamEvent(const QString &requestId, const QJsonObj LOG_MESSAGE( QString("Adding new content block: type=%1, index=%2").arg(blockType).arg(index)); + + if (blockType == "thinking" || blockType == "redacted_thinking") { + QJsonDocument eventDoc(event); + LOG_MESSAGE(QString("content_block_start event for %1: %2") + .arg(blockType) + .arg(QString::fromUtf8(eventDoc.toJson(QJsonDocument::Compact)))); + } message->handleContentBlockStart(index, blockType, contentBlock); @@ -362,12 +393,90 @@ void ClaudeProvider::processStreamEvent(const QString &requestId, const QJsonObj LLMCore::DataBuffers &buffers = m_dataBuffers[requestId]; buffers.responseContent += text; emit partialResponseReceived(requestId, text); + } else if (deltaType == "signature_delta") { + QString signature = delta["signature"].toString(); } } else if (eventType == "content_block_stop") { int index = event["index"].toInt(); + + auto allBlocks = message->getCurrentBlocks(); + if (index < allBlocks.size()) { + QString blockType = allBlocks[index]->type(); + if (blockType == "thinking" || blockType == "redacted_thinking") { + QJsonDocument eventDoc(event); + LOG_MESSAGE(QString("content_block_stop event for %1 at index %2: %3") + .arg(blockType) + .arg(index) + .arg(QString::fromUtf8(eventDoc.toJson(QJsonDocument::Compact)))); + } + } + + if (event.contains("content_block")) { + QJsonObject contentBlock = event["content_block"].toObject(); + QString blockType = contentBlock["type"].toString(); + + if (blockType == "thinking") { + QString signature = contentBlock["signature"].toString(); + if (!signature.isEmpty()) { + auto allBlocks = message->getCurrentBlocks(); + if (index < allBlocks.size()) { + if (auto thinkingContent = qobject_cast(allBlocks[index])) { + thinkingContent->setSignature(signature); + LOG_MESSAGE( + QString("Updated thinking block signature from content_block_stop, " + "signature length=%1") + .arg(signature.length())); + } + } + } + } else if (blockType == "redacted_thinking") { + QString signature = contentBlock["signature"].toString(); + if (!signature.isEmpty()) { + auto allBlocks = message->getCurrentBlocks(); + if (index < allBlocks.size()) { + if (auto redactedContent = qobject_cast(allBlocks[index])) { + redactedContent->setSignature(signature); + LOG_MESSAGE( + QString("Updated redacted_thinking block signature from content_block_stop, " + "signature length=%1") + .arg(signature.length())); + } + } + } + } + } + message->handleContentBlockStop(index); + auto thinkingBlocks = message->getCurrentThinkingContent(); + for (auto thinkingContent : thinkingBlocks) { + auto allBlocks = message->getCurrentBlocks(); + if (index < allBlocks.size() && allBlocks[index] == thinkingContent) { + emit thinkingBlockReceived( + requestId, thinkingContent->thinking(), thinkingContent->signature()); + LOG_MESSAGE( + QString("Emitted thinking block for request %1, thinking length=%2, signature length=%3") + .arg(requestId) + .arg(thinkingContent->thinking().length()) + .arg(thinkingContent->signature().length())); + break; + } + } + + auto redactedBlocks = message->getCurrentRedactedThinkingContent(); + for (auto redactedContent : redactedBlocks) { + auto allBlocks = message->getCurrentBlocks(); + if (index < allBlocks.size() && allBlocks[index] == redactedContent) { + emit redactedThinkingBlockReceived(requestId, redactedContent->signature()); + LOG_MESSAGE( + QString("Emitted redacted thinking block for request %1, signature length=%2") + .arg(requestId) + .arg(redactedContent->signature().length())); + break; + } + } + } else if (eventType == "message_delta") { QJsonObject delta = event["delta"].toObject(); if (delta.contains("stop_reason")) { diff --git a/providers/ClaudeProvider.hpp b/providers/ClaudeProvider.hpp index 9f49957..61e25b7 100644 --- a/providers/ClaudeProvider.hpp +++ b/providers/ClaudeProvider.hpp @@ -53,6 +53,7 @@ public: const LLMCore::RequestID &requestId, const QUrl &url, const QJsonObject &payload) override; bool supportsTools() const override; + bool supportThinking() const override; void cancelRequest(const LLMCore::RequestID &requestId) override; public slots: diff --git a/settings/ChatAssistantSettings.cpp b/settings/ChatAssistantSettings.cpp index 910bfa8..d35bb33 100644 --- a/settings/ChatAssistantSettings.cpp +++ b/settings/ChatAssistantSettings.cpp @@ -78,7 +78,7 @@ ChatAssistantSettings::ChatAssistantSettings() maxTokens.setSettingsKey(Constants::CA_MAX_TOKENS); maxTokens.setLabelText(Tr::tr("Max Tokens:")); - maxTokens.setRange(-1, 10000); + maxTokens.setRange(-1, 200000); // -1 for unlimited, 200k max for extended output maxTokens.setDefaultValue(2000); // Advanced Parameters @@ -144,6 +144,30 @@ ChatAssistantSettings::ChatAssistantSettings() contextWindow.setRange(-1, 10000); contextWindow.setDefaultValue(2048); + // Extended Thinking Settings + enableThinkingMode.setSettingsKey(Constants::CA_ENABLE_THINKING_MODE); + enableThinkingMode.setLabelText(Tr::tr("Enable extended thinking mode (Claude only).\n Temperature is 1.0 accordingly API requerement")); + enableThinkingMode.setToolTip( + Tr::tr("Enable Claude's extended thinking mode for complex reasoning tasks. " + "This provides step-by-step reasoning before the final answer.")); + enableThinkingMode.setDefaultValue(false); + + thinkingBudgetTokens.setSettingsKey(Constants::CA_THINKING_BUDGET_TOKENS); + thinkingBudgetTokens.setLabelText(Tr::tr("Thinking budget tokens:")); + thinkingBudgetTokens.setToolTip( + Tr::tr("Maximum number of tokens Claude can use for internal reasoning. " + "Larger budgets improve quality but increase latency. Minimum: 1024, Recommended: 10000-16000.")); + thinkingBudgetTokens.setRange(1024, 100000); + thinkingBudgetTokens.setDefaultValue(10000); + + thinkingMaxTokens.setSettingsKey(Constants::CA_THINKING_MAX_TOKENS); + thinkingMaxTokens.setLabelText(Tr::tr("Thinking mode max output tokens:")); + thinkingMaxTokens.setToolTip( + Tr::tr("Maximum number of tokens for the final response when thinking mode is enabled. " + "Set to -1 to use the default max tokens setting. Recommended: 4096-16000.")); + thinkingMaxTokens.setRange(-1, 200000); + thinkingMaxTokens.setDefaultValue(16000); + autosave.setDefaultValue(true); autosave.setLabelText(Tr::tr("Enable autosave when message received")); @@ -237,6 +261,10 @@ ChatAssistantSettings::ChatAssistantSettings() ollamaGrid.addRow({ollamaLivetime}); ollamaGrid.addRow({contextWindow}); + auto thinkingGrid = Grid{}; + thinkingGrid.addRow({thinkingBudgetTokens}); + thinkingGrid.addRow({thinkingMaxTokens}); + auto chatViewSettingsGrid = Grid{}; chatViewSettingsGrid.addRow({textFontFamily, textFontSize}); chatViewSettingsGrid.addRow({codeFontFamily, codeFontSize}); @@ -269,6 +297,9 @@ ChatAssistantSettings::ChatAssistantSettings() systemPrompt, }}, Group{title(Tr::tr("Ollama Settings")), Column{Row{ollamaGrid, Stretch{1}}}}, + Group{ + title(Tr::tr("Extended Thinking (Claude Only)")), + Column{enableThinkingMode, Row{thinkingGrid, Stretch{1}}}}, Group{title(Tr::tr("Chat Settings")), Row{chatViewSettingsGrid, Stretch{1}}}, Stretch{1}}; }); @@ -308,6 +339,9 @@ void ChatAssistantSettings::resetSettingsToDefaults() resetAspect(systemPrompt); resetAspect(ollamaLivetime); resetAspect(contextWindow); + resetAspect(enableThinkingMode); + resetAspect(thinkingBudgetTokens); + resetAspect(thinkingMaxTokens); resetAspect(linkOpenFiles); resetAspect(textFontFamily); resetAspect(codeFontFamily); diff --git a/settings/ChatAssistantSettings.hpp b/settings/ChatAssistantSettings.hpp index 984cf8d..e7e9283 100644 --- a/settings/ChatAssistantSettings.hpp +++ b/settings/ChatAssistantSettings.hpp @@ -64,6 +64,11 @@ public: Utils::StringAspect ollamaLivetime{this}; Utils::IntegerAspect contextWindow{this}; + // Extended Thinking Settings (Claude only) + Utils::BoolAspect enableThinkingMode{this}; + Utils::IntegerAspect thinkingBudgetTokens{this}; + Utils::IntegerAspect thinkingMaxTokens{this}; + // Visuals settings Utils::SelectionAspect textFontFamily{this}; Utils::IntegerAspect textFontSize{this}; diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index 3958c54..5c80f46 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -168,6 +168,9 @@ const char CA_USE_FREQUENCY_PENALTY[] = "QodeAssist.chatUseFrequencyPenalty"; const char CA_FREQUENCY_PENALTY[] = "QodeAssist.chatFrequencyPenalty"; const char CA_OLLAMA_LIVETIME[] = "QodeAssist.chatOllamaLivetime"; const char CA_OLLAMA_CONTEXT_WINDOW[] = "QodeAssist.caOllamaContextWindow"; +const char CA_ENABLE_THINKING_MODE[] = "QodeAssist.caEnableThinkingMode"; +const char CA_THINKING_BUDGET_TOKENS[] = "QodeAssist.caThinkingBudgetTokens"; +const char CA_THINKING_MAX_TOKENS[] = "QodeAssist.caThinkingMaxTokens"; const char CA_TEXT_FONT_FAMILY[] = "QodeAssist.caTextFontFamily"; const char CA_TEXT_FONT_SIZE[] = "QodeAssist.caTextFontSize"; const char CA_CODE_FONT_FAMILY[] = "QodeAssist.caCodeFontFamily"; diff --git a/templates/Claude.hpp b/templates/Claude.hpp index b93e985..fb08e13 100644 --- a/templates/Claude.hpp +++ b/templates/Claude.hpp @@ -42,7 +42,33 @@ public: if (context.history) { for (const auto &msg : context.history.value()) { if (msg.role != "system") { - messages.append(QJsonObject{{"role", msg.role}, {"content", msg.content}}); + // Handle thinking blocks with structured content + if (msg.isThinking) { + // Create content array with thinking block + QJsonArray content; + QJsonObject thinkingBlock; + thinkingBlock["type"] = msg.isRedacted ? "redacted_thinking" : "thinking"; + + // Extract actual thinking text (remove display signature) + QString thinkingText = msg.content; + int signaturePos = thinkingText.indexOf("\n[Signature: "); + if (signaturePos != -1) { + thinkingText = thinkingText.left(signaturePos); + } + + if (!msg.isRedacted) { + thinkingBlock["thinking"] = thinkingText; + } + if (!msg.signature.isEmpty()) { + thinkingBlock["signature"] = msg.signature; + } + content.append(thinkingBlock); + + messages.append(QJsonObject{{"role", "assistant"}, {"content", content}}); + } else { + // Normal message + messages.append(QJsonObject{{"role", msg.role}, {"content", msg.content}}); + } } } }