From fc33bb60d0c4fdd746e2eb96690644f2b7f2984b Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Thu, 4 Dec 2025 19:41:30 +0100 Subject: [PATCH] feat: Add agent roles (#287) * feat: Add agent roles * doc: Add agent roles to docs --- ChatView/CMakeLists.txt | 3 +- ChatView/ChatRootView.cpp | 104 +++++ ChatView/ChatRootView.hpp | 20 + ChatView/ClientInterface.cpp | 7 + ChatView/icons/context-icon.svg | 5 + ChatView/qml/RootItem.qml | 39 +- ChatView/qml/controls/ContextViewer.qml | 558 ++++++++++++++++++++++++ ChatView/qml/controls/RulesViewer.qml | 251 ----------- ChatView/qml/controls/TopBar.qml | 42 +- README.md | 19 +- UIControls/qml/QoAComboBox.qml | 25 +- docs/agent-roles.md | 171 ++++++++ settings/AgentRole.cpp | 159 +++++++ settings/AgentRole.hpp | 81 ++++ settings/AgentRoleDialog.cpp | 122 ++++++ settings/AgentRoleDialog.hpp | 55 +++ settings/AgentRolesWidget.cpp | 264 +++++++++++ settings/AgentRolesWidget.hpp | 54 +++ settings/CMakeLists.txt | 4 + settings/ChatAssistantSettings.cpp | 18 + settings/ChatAssistantSettings.hpp | 3 + settings/SettingsConstants.hpp | 2 + 22 files changed, 1713 insertions(+), 293 deletions(-) create mode 100644 ChatView/icons/context-icon.svg create mode 100644 ChatView/qml/controls/ContextViewer.qml delete mode 100644 ChatView/qml/controls/RulesViewer.qml create mode 100644 docs/agent-roles.md create mode 100644 settings/AgentRole.cpp create mode 100644 settings/AgentRole.hpp create mode 100644 settings/AgentRoleDialog.cpp create mode 100644 settings/AgentRoleDialog.hpp create mode 100644 settings/AgentRolesWidget.cpp create mode 100644 settings/AgentRolesWidget.hpp diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index 2cee930..5fa10e9 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -21,7 +21,7 @@ qt_add_qml_module(QodeAssistChatView qml/controls/AttachedFilesPlace.qml qml/controls/BottomBar.qml qml/controls/FileEditsActionBar.qml - qml/controls/RulesViewer.qml + qml/controls/ContextViewer.qml qml/controls/Toast.qml qml/controls/TopBar.qml qml/controls/SplitDropZone.qml @@ -43,6 +43,7 @@ qt_add_qml_module(QodeAssistChatView icons/chat-icon.svg icons/chat-pause-icon.svg icons/rules-icon.svg + icons/context-icon.svg icons/open-in-editor.svg icons/apply-changes-button.svg icons/undo-changes-button.svg diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index ad04317..7ecfe0f 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -34,6 +34,7 @@ #include #include +#include "AgentRole.hpp" #include "ChatAssistantSettings.hpp" #include "ChatSerializer.hpp" #include "ConfigurationManager.hpp" @@ -117,6 +118,11 @@ ChatRootView::ChatRootView(QQuickItem *parent) &Utils::BaseAspect::changed, this, &ChatRootView::updateInputTokensCount); + connect( + &Settings::chatAssistantSettings().systemPrompt, + &Utils::BaseAspect::changed, + this, + &ChatRootView::baseSystemPromptChanged); auto editors = Core::EditorManager::instance(); @@ -209,6 +215,7 @@ ChatRootView::ChatRootView(QQuickItem *parent) updateInputTokensCount(); refreshRules(); loadAvailableConfigurations(); + loadAvailableAgentRoles(); connect( ProjectExplorer::ProjectManager::instance(), @@ -1366,4 +1373,101 @@ QString ChatRootView::currentConfiguration() const return m_currentConfiguration; } +void ChatRootView::loadAvailableAgentRoles() +{ + const QList roles = Settings::AgentRolesManager::loadAllRoles(); + + m_availableAgentRoles.clear(); + m_availableAgentRoles.append(Settings::AgentRolesManager::getNoRole().name); + + for (const auto &role : roles) + m_availableAgentRoles.append(role.name); + + const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId(); + m_currentAgentRole = Settings::AgentRolesManager::getNoRole().name; + + if (!lastRoleId.isEmpty()) { + for (const auto &role : roles) { + if (role.id == lastRoleId) { + m_currentAgentRole = role.name; + break; + } + } + } + + emit availableAgentRolesChanged(); + emit currentAgentRoleChanged(); +} + +void ChatRootView::applyAgentRole(const QString &roleName) +{ + auto &settings = Settings::chatAssistantSettings(); + + if (roleName == Settings::AgentRolesManager::getNoRole().name) { + settings.lastUsedRoleId.setValue(""); + settings.writeSettings(); + m_currentAgentRole = roleName; + emit currentAgentRoleChanged(); + return; + } + + const QList roles = Settings::AgentRolesManager::loadAllRoles(); + + for (const auto &role : roles) { + if (role.name == roleName) { + settings.lastUsedRoleId.setValue(role.id); + settings.writeSettings(); + m_currentAgentRole = role.name; + emit currentAgentRoleChanged(); + break; + } + } +} + +QStringList ChatRootView::availableAgentRoles() const +{ + return m_availableAgentRoles; +} + +QString ChatRootView::currentAgentRole() const +{ + return m_currentAgentRole; +} + +QString ChatRootView::baseSystemPrompt() const +{ + return Settings::chatAssistantSettings().systemPrompt(); +} + +QString ChatRootView::currentAgentRoleDescription() const +{ + const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId(); + if (lastRoleId.isEmpty()) + return Settings::AgentRolesManager::getNoRole().description; + + const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId); + if (role.id.isEmpty()) + return Settings::AgentRolesManager::getNoRole().description; + + return role.description; +} + +QString ChatRootView::currentAgentRoleSystemPrompt() const +{ + const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId(); + if (lastRoleId.isEmpty()) + return QString(); + + const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId); + if (role.id.isEmpty()) + return QString(); + + return role.systemPrompt; +} + +void ChatRootView::openAgentRolesSettings() +{ + Core::ICore::showOptionsDialog(Utils::Id("QodeAssist.AgentRoles")); +} + } // namespace QodeAssist::Chat diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index a254fbe..5c018b9 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -59,6 +59,11 @@ class ChatRootView : public QQuickItem Q_PROPERTY(bool isThinkingSupport READ isThinkingSupport NOTIFY isThinkingSupportChanged FINAL) Q_PROPERTY(QStringList availableConfigurations READ availableConfigurations NOTIFY availableConfigurationsChanged FINAL) Q_PROPERTY(QString currentConfiguration READ currentConfiguration NOTIFY currentConfigurationChanged FINAL) + Q_PROPERTY(QStringList availableAgentRoles READ availableAgentRoles NOTIFY availableAgentRolesChanged FINAL) + Q_PROPERTY(QString currentAgentRole READ currentAgentRole NOTIFY currentAgentRoleChanged FINAL) + Q_PROPERTY(QString baseSystemPrompt READ baseSystemPrompt NOTIFY baseSystemPromptChanged FINAL) + Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL) + Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL) QML_ELEMENT @@ -146,6 +151,15 @@ public: QStringList availableConfigurations() const; QString currentConfiguration() const; + Q_INVOKABLE void loadAvailableAgentRoles(); + Q_INVOKABLE void applyAgentRole(const QString &roleId); + Q_INVOKABLE void openAgentRolesSettings(); + QStringList availableAgentRoles() const; + QString currentAgentRole() const; + QString baseSystemPrompt() const; + QString currentAgentRoleDescription() const; + QString currentAgentRoleSystemPrompt() const; + int currentMessageTotalEdits() const; int currentMessageAppliedEdits() const; int currentMessagePendingEdits() const; @@ -191,6 +205,9 @@ signals: void isThinkingSupportChanged(); void availableConfigurationsChanged(); void currentConfigurationChanged(); + void availableAgentRolesChanged(); + void currentAgentRoleChanged(); + void baseSystemPromptChanged(); private: void updateFileEditStatus(const QString &editId, const QString &status); @@ -224,6 +241,9 @@ private: QStringList m_availableConfigurations; QString m_currentConfiguration; + + QStringList m_availableAgentRoles; + QString m_currentAgentRole; }; } // namespace QodeAssist::Chat diff --git a/ChatView/ClientInterface.cpp b/ChatView/ClientInterface.cpp index 2ae8731..d9f59fc 100644 --- a/ChatView/ClientInterface.cpp +++ b/ChatView/ClientInterface.cpp @@ -160,6 +160,13 @@ void ClientInterface::sendMessage( if (chatAssistantSettings.useSystemPrompt()) { QString systemPrompt = chatAssistantSettings.systemPrompt(); + const QString lastRoleId = chatAssistantSettings.lastUsedRoleId(); + if (!lastRoleId.isEmpty()) { + const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId); + if (!role.id.isEmpty()) + systemPrompt = systemPrompt + "\n\n" + role.systemPrompt; + } + auto project = LLMCore::RulesLoader::getActiveProject(); if (project) { diff --git a/ChatView/icons/context-icon.svg b/ChatView/icons/context-icon.svg new file mode 100644 index 0000000..4aee188 --- /dev/null +++ b/ChatView/icons/context-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 7faab15..27bc114 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -26,6 +26,7 @@ import UIControls import Qt.labs.platform as Platform import "./chatparts" +import "./controls" ChatRootView { id: root @@ -97,8 +98,7 @@ ChatRootView { text: qsTr("Сhat name: %1").arg(root.chatFileName.length > 0 ? root.chatFileName : "Unsaved") } openChatHistory.onClicked: root.openChatHistoryFolder() - rulesButton.onClicked: rulesViewer.open() - activeRulesCount: root.activeRulesCount + contextButton.onClicked: contextViewer.open() pinButton { visible: typeof _chatview !== 'undefined' checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false @@ -131,6 +131,18 @@ ChatRootView { root.loadAvailableConfigurations() } } + + roleSelector { + model: root.availableAgentRoles + displayText: root.currentAgentRole + onActivated: function(index) { + root.applyAgentRole(root.availableAgentRoles[index]) + } + + popup.onAboutToShow: { + root.loadAvailableAgentRoles() + } + } } ListView { @@ -479,19 +491,28 @@ ChatRootView { toastTextColor: "#FFFFFF" } - RulesViewer { - id: rulesViewer + ContextViewer { + id: contextViewer - width: parent.width * 0.8 - height: parent.height * 0.8 + width: Math.min(parent.width * 0.85, 800) + height: Math.min(parent.height * 0.85, 700) x: (parent.width - width) / 2 y: (parent.height - height) / 2 + baseSystemPrompt: root.baseSystemPrompt + currentAgentRole: root.currentAgentRole + currentAgentRoleDescription: root.currentAgentRoleDescription + currentAgentRoleSystemPrompt: root.currentAgentRoleSystemPrompt activeRules: root.activeRules - ruleContentAreaText: root.getRuleContent(rulesViewer.rulesCurrentIndex) - - onRefreshRules: root.refreshRules() + activeRulesCount: root.activeRulesCount + + onOpenSettings: root.openSettings() + onOpenAgentRolesSettings: root.openAgentRolesSettings() onOpenRulesFolder: root.openRulesFolder() + onRefreshRules: root.refreshRules() + onRuleSelected: function(index) { + contextViewer.selectedRuleContent = root.getRuleContent(index) + } } Connections { diff --git a/ChatView/qml/controls/ContextViewer.qml b/ChatView/qml/controls/ContextViewer.qml new file mode 100644 index 0000000..4b6f12e --- /dev/null +++ b/ChatView/qml/controls/ContextViewer.qml @@ -0,0 +1,558 @@ +/* + * 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 QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Controls.Basic as QQC + +import UIControls +import ChatView + +Popup { + id: root + + property string baseSystemPrompt + property string currentAgentRole + property string currentAgentRoleDescription + property string currentAgentRoleSystemPrompt + property var activeRules + property int activeRulesCount + property string selectedRuleContent + + signal openSettings() + signal openAgentRolesSettings() + signal openRulesFolder() + signal refreshRules() + signal ruleSelected(int index) + + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: palette.window + border.color: palette.mid + border.width: 1 + radius: 4 + } + + ChatUtils { + id: utils + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + text: qsTr("Chat Context") + font.pixelSize: 16 + font.bold: true + color: palette.text + Layout.fillWidth: true + } + + QoAButton { + text: qsTr("Refresh") + onClicked: root.refreshRules() + } + + QoAButton { + text: qsTr("Close") + onClicked: root.close() + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: palette.mid + } + + Flickable { + id: mainFlickable + + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: sectionsColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: sectionsColumn + + width: mainFlickable.width + spacing: 8 + + CollapsibleSection { + id: systemPromptSection + + Layout.fillWidth: true + title: qsTr("Base System Prompt") + badge: root.baseSystemPrompt.length > 0 ? qsTr("Active") : qsTr("Empty") + badgeColor: root.baseSystemPrompt.length > 0 ? Qt.rgba(0.2, 0.6, 0.3, 1.0) : palette.mid + + sectionContent: ColumnLayout { + spacing: 5 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(Math.max(systemPromptText.implicitHeight + 16, 50), 200) + color: palette.base + border.color: palette.mid + border.width: 1 + radius: 2 + + Flickable { + id: systemPromptFlickable + + anchors.fill: parent + anchors.margins: 8 + contentHeight: systemPromptText.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + TextEdit { + id: systemPromptText + + width: systemPromptFlickable.width + text: root.baseSystemPrompt.length > 0 ? root.baseSystemPrompt : qsTr("No system prompt configured") + readOnly: true + selectByMouse: true + wrapMode: Text.WordWrap + color: root.baseSystemPrompt.length > 0 ? palette.text : palette.mid + font.family: "monospace" + font.pixelSize: 11 + } + + QQC.ScrollBar.vertical: QQC.ScrollBar { + policy: systemPromptFlickable.contentHeight > systemPromptFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff + } + } + } + + RowLayout { + Layout.fillWidth: true + + Item { Layout.fillWidth: true } + + QoAButton { + text: qsTr("Copy") + enabled: root.baseSystemPrompt.length > 0 + onClicked: utils.copyToClipboard(root.baseSystemPrompt) + } + + QoAButton { + text: qsTr("Edit in Settings") + onClicked: { + root.openSettings() + root.close() + } + } + } + } + } + + CollapsibleSection { + id: agentRoleSection + + Layout.fillWidth: true + title: qsTr("Agent Role") + badge: root.currentAgentRole + badgeColor: root.currentAgentRoleSystemPrompt.length > 0 ? Qt.rgba(0.3, 0.4, 0.7, 1.0) : palette.mid + + sectionContent: ColumnLayout { + spacing: 8 + + Text { + text: root.currentAgentRoleDescription + font.pixelSize: 11 + font.italic: true + color: palette.mid + wrapMode: Text.WordWrap + Layout.fillWidth: true + visible: root.currentAgentRoleDescription.length > 0 + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(Math.max(agentPromptText.implicitHeight + 16, 50), 200) + color: palette.base + border.color: palette.mid + border.width: 1 + radius: 2 + visible: root.currentAgentRoleSystemPrompt.length > 0 + + Flickable { + id: agentPromptFlickable + + anchors.fill: parent + anchors.margins: 8 + contentHeight: agentPromptText.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + TextEdit { + id: agentPromptText + + width: agentPromptFlickable.width + text: root.currentAgentRoleSystemPrompt + readOnly: true + selectByMouse: true + wrapMode: Text.WordWrap + color: palette.text + font.family: "monospace" + font.pixelSize: 11 + } + + QQC.ScrollBar.vertical: QQC.ScrollBar { + policy: agentPromptFlickable.contentHeight > agentPromptFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff + } + } + } + + Text { + text: qsTr("No role selected. Using base system prompt only.") + font.pixelSize: 11 + color: palette.mid + wrapMode: Text.WordWrap + Layout.fillWidth: true + visible: root.currentAgentRoleSystemPrompt.length === 0 + } + + RowLayout { + Layout.fillWidth: true + + Item { Layout.fillWidth: true } + + QoAButton { + text: qsTr("Copy") + enabled: root.currentAgentRoleSystemPrompt.length > 0 + onClicked: utils.copyToClipboard(root.currentAgentRoleSystemPrompt) + } + + QoAButton { + text: qsTr("Manage Roles") + onClicked: { + root.openAgentRolesSettings() + root.close() + } + } + } + } + } + + CollapsibleSection { + id: projectRulesSection + + Layout.fillWidth: true + title: qsTr("Project Rules") + badge: root.activeRulesCount > 0 ? qsTr("%1 active").arg(root.activeRulesCount) : qsTr("None") + badgeColor: root.activeRulesCount > 0 ? Qt.rgba(0.6, 0.5, 0.2, 1.0) : palette.mid + + sectionContent: ColumnLayout { + spacing: 8 + + SplitView { + Layout.fillWidth: true + Layout.preferredHeight: 220 + orientation: Qt.Horizontal + visible: root.activeRulesCount > 0 + + Rectangle { + SplitView.minimumWidth: 120 + SplitView.preferredWidth: 180 + color: palette.base + border.color: palette.mid + border.width: 1 + radius: 2 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 5 + + Text { + text: qsTr("Rules (%1)").arg(rulesList.count) + font.pixelSize: 11 + font.bold: true + color: palette.text + Layout.fillWidth: true + } + + ListView { + id: rulesList + + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.activeRules + currentIndex: 0 + boundsBehavior: Flickable.StopAtBounds + + delegate: ItemDelegate { + required property var modelData + required property int index + + width: ListView.view.width + height: ruleItemContent.implicitHeight + 8 + highlighted: ListView.isCurrentItem + + background: Rectangle { + color: { + if (parent.highlighted) + return palette.highlight + if (parent.hovered) + return Qt.tint(palette.base, Qt.rgba(0, 0, 0, 0.05)) + return "transparent" + } + radius: 2 + } + + contentItem: ColumnLayout { + id: ruleItemContent + spacing: 2 + + Text { + text: modelData.fileName + font.pixelSize: 10 + color: parent.parent.highlighted ? palette.highlightedText : palette.text + elide: Text.ElideMiddle + Layout.fillWidth: true + } + + Text { + text: modelData.category + font.pixelSize: 9 + color: parent.parent.highlighted ? palette.highlightedText : palette.mid + Layout.fillWidth: true + } + } + + onClicked: { + rulesList.currentIndex = index + root.ruleSelected(index) + } + } + + QQC.ScrollBar.vertical: QQC.ScrollBar { + policy: rulesList.contentHeight > rulesList.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff + } + } + } + } + + Rectangle { + SplitView.fillWidth: true + SplitView.minimumWidth: 200 + color: palette.base + border.color: palette.mid + border.width: 1 + radius: 2 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 5 + + RowLayout { + Layout.fillWidth: true + spacing: 5 + + Text { + text: qsTr("Content") + font.pixelSize: 11 + font.bold: true + color: palette.text + Layout.fillWidth: true + } + + QoAButton { + text: qsTr("Copy") + enabled: root.selectedRuleContent.length > 0 + onClicked: utils.copyToClipboard(root.selectedRuleContent) + } + } + + Flickable { + id: ruleContentFlickable + + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: ruleContentArea.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + TextEdit { + id: ruleContentArea + + width: ruleContentFlickable.width + text: root.selectedRuleContent + readOnly: true + selectByMouse: true + wrapMode: Text.WordWrap + selectionColor: palette.highlight + color: palette.text + font.family: "monospace" + font.pixelSize: 11 + } + + QQC.ScrollBar.vertical: QQC.ScrollBar { + policy: ruleContentFlickable.contentHeight > ruleContentFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff + } + } + } + } + } + + Text { + text: qsTr("No project rules found.\nCreate .md files in .qodeassist/rules/common/ or .qodeassist/rules/chat/") + font.pixelSize: 11 + color: palette.mid + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + visible: root.activeRulesCount === 0 + } + + RowLayout { + Layout.fillWidth: true + + Item { Layout.fillWidth: true } + + QoAButton { + text: qsTr("Open Rules Folder") + onClicked: root.openRulesFolder() + } + } + } + } + } + + QQC.ScrollBar.vertical: QQC.ScrollBar { + policy: mainFlickable.contentHeight > mainFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: palette.mid + } + + Text { + text: qsTr("Final prompt: Base System Prompt + Agent Role + Project Info + Project Rules + Linked Files") + font.pixelSize: 9 + color: palette.mid + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + component CollapsibleSection: ColumnLayout { + id: sectionRoot + + property string title + property string badge + property color badgeColor: palette.mid + property Component sectionContent: null + property bool expanded: false + + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 32 + color: sectionMouseArea.containsMouse ? Qt.tint(palette.button, Qt.rgba(0, 0, 0, 0.05)) : palette.button + border.color: palette.mid + border.width: 1 + radius: 2 + + MouseArea { + id: sectionMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: sectionRoot.expanded = !sectionRoot.expanded + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + + Text { + text: sectionRoot.expanded ? "▼" : "▶" + font.pixelSize: 10 + color: palette.text + } + + Text { + text: sectionRoot.title + font.pixelSize: 12 + font.bold: true + color: palette.text + Layout.fillWidth: true + } + + Rectangle { + implicitWidth: badgeText.implicitWidth + 12 + implicitHeight: 18 + color: sectionRoot.badgeColor + radius: 3 + + Text { + id: badgeText + + anchors.centerIn: parent + text: sectionRoot.badge + font.pixelSize: 10 + color: "#FFFFFF" + } + } + } + } + + Loader { + id: contentLoader + + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.topMargin: 8 + Layout.bottomMargin: 4 + sourceComponent: sectionRoot.sectionContent + visible: sectionRoot.expanded + active: sectionRoot.expanded + } + } + + onOpened: { + if (root.activeRulesCount > 0) { + root.ruleSelected(0) + } + } +} diff --git a/ChatView/qml/controls/RulesViewer.qml b/ChatView/qml/controls/RulesViewer.qml deleted file mode 100644 index ca0c338..0000000 --- a/ChatView/qml/controls/RulesViewer.qml +++ /dev/null @@ -1,251 +0,0 @@ -/* - * 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 QtQuick.Controls -import QtQuick.Layouts -import QtQuick.Controls.Basic as QQC - -import UIControls -import ChatView - -Popup { - id: root - - property var activeRules - - property alias rulesCurrentIndex: rulesList.currentIndex - property alias ruleContentAreaText: ruleContentArea.text - - signal refreshRules() - signal openRulesFolder() - - modal: true - focus: true - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - - background: Rectangle { - color: palette.window - border.color: palette.mid - border.width: 1 - radius: 4 - } - - ChatUtils { - id: utils - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 10 - - RowLayout { - Layout.fillWidth: true - spacing: 10 - - Text { - text: qsTr("Active Project Rules") - font.pixelSize: 16 - font.bold: true - color: palette.text - Layout.fillWidth: true - } - - QoAButton { - text: qsTr("Open Folder") - onClicked: root.openRulesFolder() - } - - QoAButton { - text: qsTr("Refresh") - onClicked: root.refreshRules() - } - - QoAButton { - text: qsTr("Close") - onClicked: root.close() - } - } - - Rectangle { - Layout.fillWidth: true - height: 1 - color: palette.mid - } - - SplitView { - Layout.fillWidth: true - Layout.fillHeight: true - orientation: Qt.Horizontal - - Rectangle { - SplitView.minimumWidth: 200 - SplitView.preferredWidth: parent.width * 0.3 - color: palette.base - border.color: palette.mid - border.width: 1 - radius: 2 - - ColumnLayout { - anchors.fill: parent - anchors.margins: 5 - spacing: 5 - - Text { - text: qsTr("Rules Files (%1)").arg(rulesList.count) - font.pixelSize: 12 - font.bold: true - color: palette.text - Layout.fillWidth: true - } - - ListView { - id: rulesList - - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - model: root.activeRules - currentIndex: 0 - - delegate: ItemDelegate { - required property var modelData - required property int index - - width: ListView.view.width - highlighted: ListView.isCurrentItem - - background: Rectangle { - color: { - if (parent.highlighted) { - return palette.highlight - } else if (parent.hovered) { - return Qt.tint(palette.base, Qt.rgba(0, 0, 0, 0.05)) - } - return "transparent" - } - radius: 2 - } - - contentItem: ColumnLayout { - spacing: 2 - - Text { - text: modelData.fileName - font.pixelSize: 11 - color: parent.parent.highlighted ? palette.highlightedText : palette.text - elide: Text.ElideMiddle - Layout.fillWidth: true - } - - Text { - text: qsTr("Category: %1").arg(modelData.category) - font.pixelSize: 9 - color: parent.parent.highlighted ? palette.highlightedText : palette.mid - Layout.fillWidth: true - } - } - - onClicked: { - rulesList.currentIndex = index - } - } - - ScrollBar.vertical: QQC.ScrollBar { - id: scroll - } - } - - Text { - visible: rulesList.count === 0 - text: qsTr("No rules found.\nCreate .md files in:\n.qodeassist/rules/common/\n.qodeassist/rules/chat/") - font.pixelSize: 10 - color: palette.mid - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - Layout.fillWidth: true - Layout.fillHeight: true - Layout.alignment: Qt.AlignCenter - } - } - } - - Rectangle { - SplitView.fillWidth: true - color: palette.base - border.color: palette.mid - border.width: 1 - radius: 2 - - ColumnLayout { - anchors.fill: parent - anchors.margins: 5 - spacing: 5 - - RowLayout { - Layout.fillWidth: true - spacing: 5 - - Text { - text: qsTr("Content") - font.pixelSize: 12 - font.bold: true - color: palette.text - Layout.fillWidth: true - } - - QoAButton { - text: qsTr("Copy") - enabled: ruleContentArea.text.length > 0 - onClicked: utils.copyToClipboard(ruleContentArea.text) - } - } - - ScrollView { - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - - TextEdit { - id: ruleContentArea - - readOnly: true - selectByMouse: true - wrapMode: Text.WordWrap - selectionColor: palette.highlight - color: palette.text - font.family: "monospace" - font.pixelSize: 11 - } - } - } - } - } - - Text { - text: qsTr("Rules are loaded from .qodeassist/rules/ directory in your project.\n" + - "Common rules apply to all contexts, chat rules apply only to chat assistant.") - font.pixelSize: 9 - color: palette.mid - wrapMode: Text.WordWrap - Layout.fillWidth: true - } - } -} diff --git a/ChatView/qml/controls/TopBar.qml b/ChatView/qml/controls/TopBar.qml index 9d6b458..33649e7 100644 --- a/ChatView/qml/controls/TopBar.qml +++ b/ChatView/qml/controls/TopBar.qml @@ -33,12 +33,12 @@ Rectangle { property alias recentPath: recentPathId property alias openChatHistory: openChatHistoryId property alias pinButton: pinButtonId - property alias rulesButton: rulesButtonId + property alias contextButton: contextButtonId property alias toolsButton: toolsButtonId property alias thinkingMode: thinkingModeId property alias settingsButton: settingsButtonId - property alias activeRulesCount: activeRulesCountId.text property alias configSelector: configSelectorId + property alias roleSelector: roleSelector color: palette.window.hslLightness > 0.5 ? Qt.darker(palette.window, 1.1) : @@ -90,6 +90,19 @@ Rectangle { ToolTip.text: qsTr("Switch AI configuration") } + QoAComboBox { + id: roleSelector + + implicitHeight: 25 + + model: [] + currentIndex: 0 + + ToolTip.visible: hovered + ToolTip.delay: 250 + ToolTip.text: qsTr("Switch agent role (different system prompts)") + } + QoAButton { id: toolsButtonId @@ -244,35 +257,18 @@ Rectangle { } QoAButton { - id: rulesButtonId + id: contextButtonId icon { - source: "qrc:/qt/qml/ChatView/icons/rules-icon.svg" + source: "qrc:/qt/qml/ChatView/icons/context-icon.svg" + color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF" height: 15 width: 15 } - text: " " ToolTip.visible: hovered ToolTip.delay: 250 - ToolTip.text: root.activeRulesCount > 0 - ? qsTr("View active project rules (%1)").arg(root.activeRulesCount) - : qsTr("View active project rules (no rules found)") - - Text { - id: activeRulesCountId - - anchors { - bottom: parent.bottom - bottomMargin: 2 - right: parent.right - rightMargin: 4 - } - - color: palette.text - font.pixelSize: 10 - font.bold: true - } + ToolTip.text: qsTr("View chat context (system prompt, role, rules)") } Badge { diff --git a/README.md b/README.md index bcd1553..9c79797 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ For optimal coding assistance, we recommend using these top-tier models: ### Additional Configuration +- **[Agent Roles](docs/agent-roles.md)** - Create AI personas with specialized system prompts - **[Project Rules](docs/project-rules.md)** - Customize AI behavior for your project - **[Ignoring Files](docs/ignoring-files.md)** - Exclude files from context using `.qodeassistignore` @@ -194,6 +195,7 @@ Configure in: `Tools → Options → QodeAssist → Code Completion → General - Multiple chat panels: side panel, bottom panel, and popup window - Chat history with auto-save and restore - Token usage monitoring +- **[Agent Roles](docs/agent-roles.md)** - Switch between AI personas (Developer, Reviewer, custom roles) - **[File Context](docs/file-context.md)** - Attach or link files for better context - Automatic syncing with open editor files (optional) - Extended thinking mode (Claude, other providers in plan) - Enable deeper reasoning for complex tasks @@ -278,22 +280,24 @@ QodeAssist uses a flexible prompt composition system that adapts to different co │ CHAT ASSISTANT │ ├─────────────────────────────────────────────────────────────────────────────┤ │ 1. System Prompt (from Chat Assistant Settings) │ -│ 2. Project Rules: │ +│ 2. Agent Role (optional, from role selector): │ +│ └─ Role-specific system prompt (Developer, Reviewer, custom) │ +│ 3. Project Rules: │ │ ├─ .qodeassist/rules/common/*.md │ │ └─ .qodeassist/rules/chat/*.md │ -│ 3. File Context (optional): │ +│ 4. File Context (optional): │ │ ├─ Attached files (manual) │ │ ├─ Linked files (persistent) │ │ └─ Open editor files (if auto-sync enabled) │ -│ 4. Tool Definitions (if enabled): │ +│ 5. Tool Definitions (if enabled): │ │ ├─ ReadProjectFileByName │ │ ├─ ListProjectFiles │ │ ├─ SearchInProject │ │ └─ GetIssuesList │ -│ 5. Conversation History │ -│ 6. User Message │ +│ 6. Conversation History │ +│ 7. User Message │ │ │ -│ Final Prompt: [System: SystemPrompt + Rules + Tools] │ +│ Final Prompt: [System: SystemPrompt + AgentRole + Rules + Tools] │ │ [History: Previous messages] │ │ [User: FileContext + UserMessage] │ └─────────────────────────────────────────────────────────────────────────────┘ @@ -339,6 +343,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co - **Project Rules** are automatically loaded from `.qodeassist/rules/` directory structure - **System Prompts** are configured independently for each feature in Settings +- **Agent Roles** add role-specific prompts on top of the base system prompt (Chat only) - **FIM vs Non-FIM models** for code completion use different System Prompts: - FIM models: Direct completion prompt - Non-FIM models: Prompt includes response formatting instructions @@ -346,7 +351,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co - **Custom Instructions** provide reusable templates that can be augmented with specific details - **Tool Calling** is available for Chat and Quick Refactor when enabled -See [Project Rules Documentation](docs/project-rules.md) and [Quick Refactoring Guide](docs/quick-refactoring.md) for more details. +See [Project Rules Documentation](docs/project-rules.md), [Agent Roles Guide](docs/agent-roles.md), and [Quick Refactoring Guide](docs/quick-refactoring.md) for more details. ## QtCreator Version Compatibility diff --git a/UIControls/qml/QoAComboBox.qml b/UIControls/qml/QoAComboBox.qml index eca178c..e363a02 100644 --- a/UIControls/qml/QoAComboBox.qml +++ b/UIControls/qml/QoAComboBox.qml @@ -27,6 +27,27 @@ Basic.ComboBox { implicitWidth: Math.min(contentItem.implicitWidth + 8, 300) implicitHeight: 30 + property real popupContentWidth: 100 + + TextMetrics { + id: textMetrics + font.pixelSize: 12 + } + + function updatePopupWidth() { + var maxWidth = 100; + if (model) { + for (var i = 0; i < model.length; i++) { + textMetrics.text = model[i]; + maxWidth = Math.max(maxWidth, textMetrics.width + 40); + } + } + popupContentWidth = Math.min(maxWidth, 350); + } + + onModelChanged: updatePopupWidth() + Component.onCompleted: updatePopupWidth() + indicator: Image { id: dropdownIcon @@ -94,7 +115,7 @@ Basic.ComboBox { popup: Popup { y: control.height + 2 - width: control.width + width: Math.max(control.width, control.popupContentWidth) implicitHeight: Math.min(contentItem.implicitHeight, 300) padding: 4 @@ -128,7 +149,7 @@ Basic.ComboBox { } delegate: ItemDelegate { - width: control.width - 8 + width: control.popup.width - 8 height: 32 contentItem: Text { diff --git a/docs/agent-roles.md b/docs/agent-roles.md new file mode 100644 index 0000000..9150c35 --- /dev/null +++ b/docs/agent-roles.md @@ -0,0 +1,171 @@ +# Agent Roles + +Agent Roles allow you to define different AI personas with specialized system prompts for various tasks. Switch between roles instantly in the chat interface to adapt the AI's behavior to your current needs. + +## Overview + +Agent Roles are reusable system prompt configurations that modify how the AI assistant responds. Instead of manually changing system prompts, you can create roles like "Developer", "Code Reviewer", or "Documentation Writer" and switch between them with a single click. + +**Key Features:** +- **Quick Switching**: Change roles from the chat toolbar dropdown +- **Custom Prompts**: Each role has its own specialized system prompt +- **Built-in Roles**: Pre-configured Developer and Code Reviewer roles +- **Persistent**: Roles are saved locally and loaded on startup +- **Extensible**: Create unlimited custom roles for different tasks + +## Default Roles + +QodeAssist comes with two built-in roles: + +### Developer +General coding assistance focused on writing clean, maintainable code following industry standards. Best for implementation tasks, debugging, and code explanations. + +### Code Reviewer +Expert code review persona that identifies bugs, performance issues, and adherence to best practices. Provides constructive feedback with specific suggestions for improvement. + +## Using Agent Roles + +### Switching Roles in Chat + +1. Open the Chat Assistant (side panel, bottom panel, or popup window) +2. Locate the **Role selector** dropdown in the top toolbar (next to the configuration selector) +3. Select a role from the dropdown +4. The AI will now use the selected role's system prompt + +**Note**: Selecting "No Role" uses only the base system prompt without role specialization. + +### Viewing Active Role + +Click the **Context** button (📋) in the chat toolbar to view: +- Base system prompt +- Current agent role and its system prompt +- Active project rules + +## Managing Agent Roles + +### Opening the Role Manager + +Navigate to: `Qt Creator → Preferences → QodeAssist → Chat Assistant` + +Scroll down to the **Agent Roles** section where you can manage all your roles. + +### Creating a New Role + +1. Click **Add...** button +2. Fill in the role details: + - **Name**: Display name shown in the dropdown (e.g., "Documentation Writer") + - **ID**: Unique identifier for the role file (e.g., "doc_writer") + - **Description**: Brief explanation of the role's purpose + - **System Prompt**: The specialized instructions for this role +3. Click **OK** to save + +### Editing a Role + +1. Select a role from the list +2. Click **Edit...** or double-click the role +3. Modify the fields as needed +4. Click **OK** to save changes + +**Note**: Built-in roles cannot be edited directly. Duplicate them to create a modifiable copy. + +### Duplicating a Role + +1. Select a role to duplicate +2. Click **Duplicate...** +3. Modify the copy as needed +4. Click **OK** to save as a new role + +### Deleting a Role + +1. Select a custom role (built-in roles cannot be deleted) +2. Click **Delete** +3. Confirm deletion + +## Creating Effective Roles + +### System Prompt Tips + +- **Be specific**: Clearly define the role's expertise and focus areas +- **Set expectations**: Describe the desired response format and style +- **Include guidelines**: Add specific rules or constraints for responses +- **Use structured prompts**: Break down complex roles into bullet points + +## Storage Location + +Agent roles are stored as JSON files in: + +``` +~/.config/QtProject/qtcreator/qodeassist/agent_roles/ +``` + +**On different platforms:** +- **Linux**: `~/.config/QtProject/qtcreator/qodeassist/agent_roles/` +- **macOS**: `~/Library/Application Support/QtProject/Qt Creator/qodeassist/agent_roles/` +- **Windows**: `%APPDATA%\QtProject\qtcreator\qodeassist\agent_roles\` + +### File Format + +Each role is stored as a JSON file named `{id}.json`: + +```json +{ + "id": "doc_writer", + "name": "Documentation Writer", + "description": "Technical documentation and code comments", + "systemPrompt": "You are a technical documentation specialist...", + "isBuiltin": false +} +``` + +### Manual Editing + +You can: +- Edit JSON files directly in any text editor +- Copy role files between machines +- Share roles with team members +- Version control your roles +- Click **Open Roles Folder...** to quickly access the directory + +## How Roles Work + +When a role is selected, the final system prompt is composed as: + +``` +┌─────────────────────────────────────────────────┐ +│ Final System Prompt = Base Prompt + Role Prompt │ +├─────────────────────────────────────────────────┤ +│ 1. Base System Prompt (from Chat Settings) │ +│ 2. Agent Role System Prompt │ +│ 3. Project Rules (common/ + chat/) │ +│ 4. Linked Files Context │ +└─────────────────────────────────────────────────┘ +``` + +This allows roles to augment rather than replace your base configuration. + +## Best Practices + +1. **Keep roles focused**: Each role should have a clear, specific purpose +2. **Use descriptive names**: Make it easy to identify roles at a glance +3. **Test your prompts**: Verify roles produce the expected behavior +4. **Iterate and improve**: Refine prompts based on AI responses +5. **Share with team**: Export and share useful roles with colleagues + +## Troubleshooting + +### Role Not Appearing in Dropdown +- Restart Qt Creator after adding roles manually +- Check JSON file format validity +- Verify file is in the correct directory + +### Role Behavior Not as Expected +- Review the system prompt for clarity +- Check if base system prompt conflicts with role prompt +- Try a more specific or detailed prompt + +## Related Documentation + +- [Project Rules](project-rules.md) - Project-specific AI behavior customization +- [Chat Assistant Features](../README.md#chat-assistant) - Overview of chat functionality +- [File Context](file-context.md) - Attaching files to chat context + diff --git a/settings/AgentRole.cpp b/settings/AgentRole.cpp new file mode 100644 index 0000000..09ce9ff --- /dev/null +++ b/settings/AgentRole.cpp @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2024-2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#include "AgentRole.hpp" + +#include + +#include +#include +#include +#include + +namespace QodeAssist::Settings { + +QString AgentRolesManager::getConfigurationDirectory() +{ + QString path = QString("%1/qodeassist/agent_roles") + .arg(Core::ICore::userResourcePath().toFSPathString()); + QDir().mkpath(path); + return path; +} + +QList AgentRolesManager::loadAllRoles() +{ + QList roles; + QString configDir = getConfigurationDirectory(); + QDir dir(configDir); + + ensureDefaultRoles(); + + const QStringList jsonFiles = dir.entryList({"*.json"}, QDir::Files); + for (const QString &fileName : jsonFiles) { + AgentRole role = loadRoleFromFile(dir.absoluteFilePath(fileName)); + if (!role.id.isEmpty()) { + roles.append(role); + } + } + + return roles; +} + +AgentRole AgentRolesManager::loadRole(const QString &roleId) +{ + if (roleId.isEmpty()) + return {}; + + QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(roleId + ".json"); + if (!QFile::exists(filePath)) + return {}; + + return loadRoleFromFile(filePath); +} + +AgentRole AgentRolesManager::loadRoleFromFile(const QString &filePath) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) + return {}; + + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (doc.isNull() || !doc.isObject()) + return {}; + + return AgentRole::fromJson(doc.object()); +} + +bool AgentRolesManager::saveRole(const AgentRole &role) +{ + if (role.id.isEmpty()) + return false; + + QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(role.id + ".json"); + QFile file(filePath); + + if (!file.open(QIODevice::WriteOnly)) + return false; + + QJsonDocument doc(role.toJson()); + file.write(doc.toJson(QJsonDocument::Indented)); + + return true; +} + +bool AgentRolesManager::deleteRole(const QString &roleId) +{ + if (roleId.isEmpty()) + return false; + + QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(roleId + ".json"); + return QFile::remove(filePath); +} + +bool AgentRolesManager::roleExists(const QString &roleId) +{ + if (roleId.isEmpty()) + return false; + + QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(roleId + ".json"); + return QFile::exists(filePath); +} + +void AgentRolesManager::ensureDefaultRoles() +{ + QDir dir(getConfigurationDirectory()); + + if (!dir.exists("developer.json")) + saveRole(getDefaultDeveloperRole()); + + if (!dir.exists("reviewer.json")) + saveRole(getDefaultReviewerRole()); +} + +AgentRole AgentRolesManager::getDefaultDeveloperRole() +{ + return AgentRole{ + "developer", + "Developer", + "General coding assistance and implementation", + "You are an advanced AI assistant specializing in C++, Qt, and QML development. " + "Your role is to provide helpful, accurate, and detailed responses to questions " + "about coding, debugging, and best practices in these technologies. " + "Focus on writing clean, maintainable code following industry standards.", + false}; +} + +AgentRole AgentRolesManager::getDefaultReviewerRole() +{ + return AgentRole{ + "reviewer", + "Code Reviewer", + "Code review, quality assurance, and best practices", + "You are an expert code reviewer specializing in C++, Qt, and QML. " + "Your role is to:\n" + "- Identify potential bugs, memory leaks, and performance issues\n" + "- Check adherence to coding standards and best practices\n" + "- Suggest improvements for readability and maintainability\n" + "- Verify proper error handling and edge cases\n" + "- Ensure thread safety and proper Qt object lifetime management\n" + "Provide constructive, specific feedback with examples.", + false}; +} + +} // namespace QodeAssist::Settings diff --git a/settings/AgentRole.hpp b/settings/AgentRole.hpp new file mode 100644 index 0000000..9908a9f --- /dev/null +++ b/settings/AgentRole.hpp @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024-2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#pragma once + +#include +#include +#include + +namespace QodeAssist::Settings { + +struct AgentRole +{ + QString id; + QString name; + QString description; + QString systemPrompt; + bool isBuiltin = false; + + QJsonObject toJson() const + { + return QJsonObject{ + {"id", id}, + {"name", name}, + {"description", description}, + {"systemPrompt", systemPrompt}, + {"isBuiltin", isBuiltin}}; + } + + static AgentRole fromJson(const QJsonObject &json) + { + return AgentRole{ + json["id"].toString(), + json["name"].toString(), + json["description"].toString(), + json["systemPrompt"].toString(), + json["isBuiltin"].toBool(false)}; + } + + bool operator==(const AgentRole &other) const { return id == other.id; } +}; + +class AgentRolesManager +{ +public: + static QString getConfigurationDirectory(); + static QList loadAllRoles(); + static AgentRole loadRole(const QString &roleId); + static AgentRole loadRoleFromFile(const QString &filePath); + static bool saveRole(const AgentRole &role); + static bool deleteRole(const QString &roleId); + static bool roleExists(const QString &roleId); + static void ensureDefaultRoles(); + + static AgentRole getNoRole() + { + return AgentRole{"", "No Role", "Use base system prompt without role specialization", "", false}; + } + +private: + static AgentRole getDefaultDeveloperRole(); + static AgentRole getDefaultReviewerRole(); +}; + +} // namespace QodeAssist::Settings diff --git a/settings/AgentRoleDialog.cpp b/settings/AgentRoleDialog.cpp new file mode 100644 index 0000000..7294772 --- /dev/null +++ b/settings/AgentRoleDialog.cpp @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2024-2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#include "AgentRoleDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace QodeAssist::Settings { + +AgentRoleDialog::AgentRoleDialog(QWidget *parent) + : QDialog(parent) + , m_editMode(false) +{ + setWindowTitle(tr("Add Agent Role")); + setupUI(); +} + +AgentRoleDialog::AgentRoleDialog(const AgentRole &role, bool editMode, QWidget *parent) + : QDialog(parent) + , m_editMode(editMode) +{ + setWindowTitle(editMode ? tr("Edit Agent Role") : tr("Duplicate Agent Role")); + setupUI(); + setRole(role); +} + +void AgentRoleDialog::setupUI() +{ + auto *mainLayout = new QVBoxLayout(this); + auto *formLayout = new QFormLayout(); + + m_nameEdit = new QLineEdit(this); + m_nameEdit->setPlaceholderText(tr("e.g., Developer, Code Reviewer")); + formLayout->addRow(tr("Name:"), m_nameEdit); + + m_idEdit = new QLineEdit(this); + m_idEdit->setPlaceholderText(tr("e.g., developer, code_reviewer")); + formLayout->addRow(tr("ID:"), m_idEdit); + + m_descriptionEdit = new QTextEdit(this); + m_descriptionEdit->setPlaceholderText(tr("Brief description of this role...")); + m_descriptionEdit->setMaximumHeight(80); + formLayout->addRow(tr("Description:"), m_descriptionEdit); + + mainLayout->addLayout(formLayout); + + auto *promptLabel = new QLabel(tr("System Prompt:"), this); + mainLayout->addWidget(promptLabel); + + m_systemPromptEdit = new QTextEdit(this); + m_systemPromptEdit->setPlaceholderText( + tr("You are an expert in...\n\nYour role is to:\n- Task 1\n- Task 2\n- Task 3")); + mainLayout->addWidget(m_systemPromptEdit); + + m_buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + mainLayout->addWidget(m_buttonBox); + + connect(m_buttonBox, &QDialogButtonBox::accepted, this, &AgentRoleDialog::accept); + connect(m_buttonBox, &QDialogButtonBox::rejected, this, &AgentRoleDialog::reject); + connect(m_nameEdit, &QLineEdit::textChanged, this, &AgentRoleDialog::validateInput); + connect(m_idEdit, &QLineEdit::textChanged, this, &AgentRoleDialog::validateInput); + connect(m_systemPromptEdit, &QTextEdit::textChanged, this, &AgentRoleDialog::validateInput); + + if (m_editMode) { + m_idEdit->setEnabled(false); + m_idEdit->setToolTip(tr("ID cannot be changed for existing roles")); + } + + setMinimumSize(600, 500); + validateInput(); +} + +void AgentRoleDialog::validateInput() +{ + bool valid = !m_nameEdit->text().trimmed().isEmpty() + && !m_idEdit->text().trimmed().isEmpty() + && !m_systemPromptEdit->toPlainText().trimmed().isEmpty(); + + m_buttonBox->button(QDialogButtonBox::Ok)->setEnabled(valid); +} + +AgentRole AgentRoleDialog::getRole() const +{ + return AgentRole{ + m_idEdit->text().trimmed(), + m_nameEdit->text().trimmed(), + m_descriptionEdit->toPlainText().trimmed(), + m_systemPromptEdit->toPlainText().trimmed(), + false}; +} + +void AgentRoleDialog::setRole(const AgentRole &role) +{ + m_idEdit->setText(role.id); + m_nameEdit->setText(role.name); + m_descriptionEdit->setPlainText(role.description); + m_systemPromptEdit->setPlainText(role.systemPrompt); +} + +} // namespace QodeAssist::Settings diff --git a/settings/AgentRoleDialog.hpp b/settings/AgentRoleDialog.hpp new file mode 100644 index 0000000..95a6f97 --- /dev/null +++ b/settings/AgentRoleDialog.hpp @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024-2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#pragma once + +#include + +#include "AgentRole.hpp" + +class QLineEdit; +class QTextEdit; +class QDialogButtonBox; + +namespace QodeAssist::Settings { + +class AgentRoleDialog : public QDialog +{ + Q_OBJECT + +public: + explicit AgentRoleDialog(QWidget *parent = nullptr); + explicit AgentRoleDialog(const AgentRole &role, bool editMode = true, QWidget *parent = nullptr); + + AgentRole getRole() const; + void setRole(const AgentRole &role); + +private: + void setupUI(); + void validateInput(); + + QLineEdit *m_nameEdit = nullptr; + QLineEdit *m_idEdit = nullptr; + QTextEdit *m_descriptionEdit = nullptr; + QTextEdit *m_systemPromptEdit = nullptr; + QDialogButtonBox *m_buttonBox = nullptr; + bool m_editMode = false; +}; + +} // namespace QodeAssist::Settings diff --git a/settings/AgentRolesWidget.cpp b/settings/AgentRolesWidget.cpp new file mode 100644 index 0000000..ecdb9b3 --- /dev/null +++ b/settings/AgentRolesWidget.cpp @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2024-2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#include "AgentRolesWidget.hpp" + +#include "AgentRole.hpp" +#include "AgentRoleDialog.hpp" +#include "SettingsTr.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QodeAssist::Settings { + +AgentRolesWidget::AgentRolesWidget(QWidget *parent) + : QWidget(parent) +{ + setupUI(); + loadRoles(); +} + +void AgentRolesWidget::setupUI() +{ + auto *mainLayout = new QVBoxLayout(this); + + auto *headerLayout = new QHBoxLayout(); + + auto *infoLabel = new QLabel( + Tr::tr("Agent roles define different system prompts for specific tasks."), this); + infoLabel->setWordWrap(true); + headerLayout->addWidget(infoLabel, 1); + + auto *openFolderButton = new QPushButton(Tr::tr("Open Roles Folder..."), this); + connect(openFolderButton, &QPushButton::clicked, this, &AgentRolesWidget::onOpenRolesFolder); + headerLayout->addWidget(openFolderButton); + + mainLayout->addLayout(headerLayout); + + auto *contentLayout = new QHBoxLayout(); + + m_rolesList = new QListWidget(this); + m_rolesList->setSelectionMode(QAbstractItemView::SingleSelection); + connect(m_rolesList, &QListWidget::itemSelectionChanged, this, &AgentRolesWidget::updateButtons); + connect(m_rolesList, &QListWidget::itemDoubleClicked, this, &AgentRolesWidget::onEditRole); + contentLayout->addWidget(m_rolesList, 1); + + auto *buttonsLayout = new QVBoxLayout(); + + m_addButton = new QPushButton(Tr::tr("Add..."), this); + connect(m_addButton, &QPushButton::clicked, this, &AgentRolesWidget::onAddRole); + buttonsLayout->addWidget(m_addButton); + + m_editButton = new QPushButton(Tr::tr("Edit..."), this); + connect(m_editButton, &QPushButton::clicked, this, &AgentRolesWidget::onEditRole); + buttonsLayout->addWidget(m_editButton); + + m_duplicateButton = new QPushButton(Tr::tr("Duplicate..."), this); + connect(m_duplicateButton, &QPushButton::clicked, this, &AgentRolesWidget::onDuplicateRole); + buttonsLayout->addWidget(m_duplicateButton); + + m_deleteButton = new QPushButton(Tr::tr("Delete"), this); + connect(m_deleteButton, &QPushButton::clicked, this, &AgentRolesWidget::onDeleteRole); + buttonsLayout->addWidget(m_deleteButton); + + buttonsLayout->addStretch(); + + contentLayout->addLayout(buttonsLayout); + mainLayout->addLayout(contentLayout); + + updateButtons(); +} + +void AgentRolesWidget::loadRoles() +{ + m_rolesList->clear(); + + const QList roles = AgentRolesManager::loadAllRoles(); + for (const AgentRole &role : roles) { + auto *item = new QListWidgetItem(role.name, m_rolesList); + item->setData(Qt::UserRole, role.id); + + QString tooltip = role.description; + if (role.isBuiltin) { + tooltip += "\n\n" + Tr::tr("(Built-in role)"); + item->setForeground(Qt::darkGray); + } + item->setToolTip(tooltip); + } +} + +void AgentRolesWidget::updateButtons() +{ + QListWidgetItem *selectedItem = m_rolesList->currentItem(); + bool hasSelection = selectedItem != nullptr; + bool isBuiltin = false; + + if (hasSelection) { + QString roleId = selectedItem->data(Qt::UserRole).toString(); + AgentRole role = AgentRolesManager::loadRole(roleId); + isBuiltin = role.isBuiltin; + } + + m_editButton->setEnabled(hasSelection); + m_duplicateButton->setEnabled(hasSelection); + m_deleteButton->setEnabled(hasSelection && !isBuiltin); +} + +void AgentRolesWidget::onAddRole() +{ + AgentRoleDialog dialog(this); + if (dialog.exec() != QDialog::Accepted) + return; + + AgentRole newRole = dialog.getRole(); + + if (AgentRolesManager::roleExists(newRole.id)) { + QMessageBox::warning( + this, + Tr::tr("Role Already Exists"), + Tr::tr("A role with ID '%1' already exists. Please use a different ID.") + .arg(newRole.id)); + return; + } + + if (AgentRolesManager::saveRole(newRole)) { + loadRoles(); + } else { + QMessageBox::critical( + this, Tr::tr("Error"), Tr::tr("Failed to save role '%1'.").arg(newRole.name)); + } +} + +void AgentRolesWidget::onEditRole() +{ + QListWidgetItem *selectedItem = m_rolesList->currentItem(); + if (!selectedItem) + return; + + QString roleId = selectedItem->data(Qt::UserRole).toString(); + AgentRole role = AgentRolesManager::loadRole(roleId); + + if (role.isBuiltin) { + QMessageBox::information( + this, + Tr::tr("Cannot Edit Built-in Role"), + Tr::tr( + "Built-in roles cannot be edited. You can duplicate this role and modify the copy.")); + return; + } + + AgentRoleDialog dialog(role, this); + if (dialog.exec() != QDialog::Accepted) + return; + + AgentRole updatedRole = dialog.getRole(); + + if (AgentRolesManager::saveRole(updatedRole)) { + loadRoles(); + } else { + QMessageBox::critical( + this, Tr::tr("Error"), Tr::tr("Failed to update role '%1'.").arg(updatedRole.name)); + } +} + +void AgentRolesWidget::onDuplicateRole() +{ + QListWidgetItem *selectedItem = m_rolesList->currentItem(); + if (!selectedItem) + return; + + QString roleId = selectedItem->data(Qt::UserRole).toString(); + AgentRole role = AgentRolesManager::loadRole(roleId); + + role.name += " (Copy)"; + role.id += "_copy"; + role.isBuiltin = false; + + int counter = 1; + QString baseId = role.id; + while (AgentRolesManager::roleExists(role.id)) { + role.id = baseId + QString::number(counter++); + } + + AgentRoleDialog dialog(role, false, this); + if (dialog.exec() != QDialog::Accepted) + return; + + AgentRole newRole = dialog.getRole(); + + if (AgentRolesManager::roleExists(newRole.id)) { + QMessageBox::warning( + this, + Tr::tr("Role Already Exists"), + Tr::tr("A role with ID '%1' already exists. Please use a different ID.") + .arg(newRole.id)); + return; + } + + if (AgentRolesManager::saveRole(newRole)) { + loadRoles(); + } else { + QMessageBox::critical(this, Tr::tr("Error"), Tr::tr("Failed to duplicate role.")); + } +} + +void AgentRolesWidget::onDeleteRole() +{ + QListWidgetItem *selectedItem = m_rolesList->currentItem(); + if (!selectedItem) + return; + + QString roleId = selectedItem->data(Qt::UserRole).toString(); + AgentRole role = AgentRolesManager::loadRole(roleId); + + if (role.isBuiltin) { + QMessageBox::information( + this, Tr::tr("Cannot Delete Built-in Role"), Tr::tr("Built-in roles cannot be deleted.")); + return; + } + + QMessageBox::StandardButton reply = QMessageBox::question( + this, + Tr::tr("Delete Role"), + Tr::tr("Are you sure you want to delete the role '%1'?").arg(role.name), + QMessageBox::Yes | QMessageBox::No); + + if (reply == QMessageBox::Yes) { + if (AgentRolesManager::deleteRole(roleId)) { + loadRoles(); + } else { + QMessageBox::critical( + this, Tr::tr("Error"), Tr::tr("Failed to delete role '%1'.").arg(role.name)); + } + } +} + +void AgentRolesWidget::onOpenRolesFolder() +{ + QDesktopServices::openUrl(QUrl::fromLocalFile(AgentRolesManager::getConfigurationDirectory())); +} + +} // namespace QodeAssist::Settings diff --git a/settings/AgentRolesWidget.hpp b/settings/AgentRolesWidget.hpp new file mode 100644 index 0000000..1df709d --- /dev/null +++ b/settings/AgentRolesWidget.hpp @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024-2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#pragma once + +#include + +class QListWidget; +class QPushButton; + +namespace QodeAssist::Settings { + +class AgentRolesWidget : public QWidget +{ + Q_OBJECT + +public: + explicit AgentRolesWidget(QWidget *parent = nullptr); + +private: + void setupUI(); + void loadRoles(); + void updateButtons(); + + void onAddRole(); + void onEditRole(); + void onDuplicateRole(); + void onDeleteRole(); + void onOpenRolesFolder(); + + QListWidget *m_rolesList = nullptr; + QPushButton *m_addButton = nullptr; + QPushButton *m_editButton = nullptr; + QPushButton *m_duplicateButton = nullptr; + QPushButton *m_deleteButton = nullptr; +}; + +} // namespace QodeAssist::Settings diff --git a/settings/CMakeLists.txt b/settings/CMakeLists.txt index 7a53058..fce22de 100644 --- a/settings/CMakeLists.txt +++ b/settings/CMakeLists.txt @@ -16,11 +16,15 @@ add_library(QodeAssistSettings STATIC ProviderSettings.hpp ProviderSettings.cpp PluginUpdater.hpp PluginUpdater.cpp UpdateDialog.hpp UpdateDialog.cpp + AgentRole.hpp AgentRole.cpp + AgentRoleDialog.hpp AgentRoleDialog.cpp + AgentRolesWidget.hpp AgentRolesWidget.cpp ) target_link_libraries(QodeAssistSettings PUBLIC Qt::Core + Qt::Widgets Qt::Network QtCreator::Core QtCreator::Utils diff --git a/settings/ChatAssistantSettings.cpp b/settings/ChatAssistantSettings.cpp index 654c32d..5869a0b 100644 --- a/settings/ChatAssistantSettings.cpp +++ b/settings/ChatAssistantSettings.cpp @@ -29,6 +29,7 @@ #include "SettingsConstants.hpp" #include "SettingsTr.hpp" #include "SettingsUtils.hpp" +#include "AgentRolesWidget.hpp" namespace QodeAssist::Settings { @@ -262,6 +263,9 @@ ChatAssistantSettings::ChatAssistantSettings() chatRenderer.setDefaultValue("rhi"); #endif + lastUsedRoleId.setSettingsKey(Constants::CA_LAST_USED_ROLE); + lastUsedRoleId.setDefaultValue(""); + resetToDefaults.m_buttonText = TrConstants::RESET_TO_DEFAULTS; readSettings(); @@ -405,4 +409,18 @@ public: const ChatAssistantSettingsPage chatAssistantSettingsPage; +class AgentRolesSettingsPage : public Core::IOptionsPage +{ +public: + AgentRolesSettingsPage() + { + setId("QodeAssist.AgentRoles"); + setDisplayName(Tr::tr("Agent Roles")); + setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY); + setWidgetCreator([]() { return new AgentRolesWidget(); }); + } +}; + +const AgentRolesSettingsPage agentRolesSettingsPage; + } // namespace QodeAssist::Settings diff --git a/settings/ChatAssistantSettings.hpp b/settings/ChatAssistantSettings.hpp index c062556..cd40f65 100644 --- a/settings/ChatAssistantSettings.hpp +++ b/settings/ChatAssistantSettings.hpp @@ -21,6 +21,7 @@ #include +#include "AgentRole.hpp" #include "ButtonAspect.hpp" namespace QodeAssist::Settings { @@ -82,6 +83,8 @@ public: Utils::SelectionAspect chatRenderer{this}; + Utils::StringAspect lastUsedRoleId{this}; + private: void setupConnections(); void resetSettingsToDefaults(); diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index d51c25a..9c5bf79 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -209,6 +209,8 @@ const char CA_CODE_FONT_SIZE[] = "QodeAssist.caCodeFontSize"; const char CA_TEXT_FORMAT[] = "QodeAssist.caTextFormat"; const char CA_CHAT_RENDERER[] = "QodeAssist.caChatRenderer"; +const char CA_LAST_USED_ROLE[] = "QodeAssist.caLastUsedRole"; + // quick refactor preset prompt settings const char QR_TEMPERATURE[] = "QodeAssist.qrTemperature"; const char QR_MAX_TOKENS[] = "QodeAssist.qrMaxTokens";