Compare commits

...

15 Commits

Author SHA1 Message Date
Petr Mironychev
9a2ba08538 chore: Upgrade plugin to 0.9.11 2026-03-13 00:56:25 +01:00
Petr Mironychev
37084bec59 feat: Improve execute terminal command tool 2026-03-13 00:34:20 +01:00
Petr Mironychev
6910037e97 feat: Update models configuration 2026-03-12 23:58:06 +01:00
Petr Mironychev
a72cdd85a4 feat: Add support QtC 19
remove support QtC 17
2026-03-12 23:31:35 +01:00
lebedeviv1988
31b4e73af5 fix: Qt Creator 19 API breaking changes (#328)
* Inherits `QodeAssist::Settings::AgentRolesWidget` from `Core::IOptionsPageWidget`

* Adds `QodeAssist::Settings::showSettings` function and use it instead `Core::ICore::showOptionsDialog`

---------

Co-authored-by: Ivan Lebedev <ilebedev@flightpath3d.com>
2026-03-05 16:00:51 +01:00
lebedeviv1988
088887c802 fix: enables the send message shortcut only for active chat (#322)
fix: Disables sending message shortcut instead of filtering in `Shortcut::activated` signal handler

Co-authored-by: Ivan Lebedev <ilebedev@flightpath3d.com>
2026-03-05 12:01:14 +01:00
lebedeviv1988
b7a9787cc3 refactor: Refactors AgentRoleDialog's modes handling (#325)
* fix: Fixes `undefined-bool-conversion` compilation warning.

* refactor: Replaces `AgentRoleDialog::m_editMode` with `AgentRoleDialog::m_action`

---------

Co-authored-by: Ivan Lebedev <ilebedev@flightpath3d.com>
2026-03-05 10:48:01 +01:00
Petr Mironychev
e2e13f0f38 refactor: Improve http client (#319) 2026-02-25 15:13:05 +01:00
Petr Mironychev
49ae335d7d chore: Update plugin to 0.9.10 2026-02-25 12:33:14 +01:00
Petr Mironychev
2ba58a403f refactor: UI for opening content from chat (#318)
* refactor: Changed options to opening images from chat
* refactor: Add customizable tooltip
2026-02-25 07:49:37 +01:00
Petr Mironychev
3de1619bf0 feat: Add file search to chat (#317) 2026-02-22 13:53:44 +01:00
Petr Mironychev
ec45067336 chore: Upgrade plugin to 0.9.9 version 2026-01-27 22:41:57 +01:00
Petr Mironychev
52fb65c5b1 feat: Add support QtCreator 18.0.2 2026-01-27 22:41:20 +01:00
Petr Mironychev
478f369ad2 feat: Add codestral and mistral quick setup 2026-01-27 22:41:02 +01:00
Petr Mironychev
762c965377 fix: Add preconditions for windows chat 2026-01-27 22:35:02 +01:00
56 changed files with 1536 additions and 849 deletions

View File

@@ -46,12 +46,12 @@ jobs:
}
qt_config:
- {
qt_version: "6.9.2",
qt_creator_version: "17.0.2"
qt_version: "6.10.1",
qt_creator_version: "18.0.2"
}
- {
qt_version: "6.10.1",
qt_creator_version: "18.0.1"
qt_version: "6.10.2",
qt_creator_version: "19.0.0"
}
steps:

View File

@@ -142,7 +142,6 @@ add_qtc_plugin(QodeAssist
QuickRefactorHandler.hpp QuickRefactorHandler.cpp
tools/ToolsFactory.hpp tools/ToolsFactory.cpp
tools/ReadVisibleFilesTool.hpp tools/ReadVisibleFilesTool.cpp
tools/ToolHandler.hpp tools/ToolHandler.cpp
tools/ListProjectFilesTool.hpp tools/ListProjectFilesTool.cpp
tools/ToolsManager.hpp tools/ToolsManager.cpp

View File

@@ -20,6 +20,7 @@ qt_add_qml_module(QodeAssistChatView
qml/controls/AttachedFilesPlace.qml
qml/controls/BottomBar.qml
qml/controls/FileMentionPopup.qml
qml/controls/FileEditsActionBar.qml
qml/controls/ContextViewer.qml
qml/controls/Toast.qml
@@ -68,6 +69,7 @@ qt_add_qml_module(QodeAssistChatView
FileItem.hpp FileItem.cpp
ChatFileManager.hpp ChatFileManager.cpp
ChatCompressor.hpp ChatCompressor.cpp
FileMentionItem.hpp FileMentionItem.cpp
)
target_link_libraries(QodeAssistChatView

View File

@@ -117,8 +117,10 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
QString contentFolder = QDir(dirPath).filePath(baseName + "_content");
QString fullPath = QDir(contentFolder).filePath(image.storedPath);
imageMap["imageUrl"] = QUrl::fromLocalFile(fullPath).toString();
imageMap["filePath"] = fullPath;
} else {
imageMap["imageUrl"] = QString();
imageMap["filePath"] = QString();
}
imagesList.append(imageMap);

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024-2025 Petr Mironychev
* Copyright (C) 2024-2026 Petr Mironychev
*
* This file is part of QodeAssist.
*
@@ -21,9 +21,12 @@
#include <QClipboard>
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QFileDialog>
#include <QFileInfo>
#include <QMessageBox>
#include <QTextStream>
#include <coreplugin/editormanager/editormanager.h>
#include <coreplugin/icore.h>
@@ -225,6 +228,18 @@ ChatRootView::ChatRootView(QQuickItem *parent)
this,
&ChatRootView::refreshRules);
connect(
ProjectExplorer::ProjectManager::instance(),
&ProjectExplorer::ProjectManager::projectAdded,
this,
&ChatRootView::openFilesChanged);
connect(
ProjectExplorer::ProjectManager::instance(),
&ProjectExplorer::ProjectManager::projectRemoved,
this,
&ChatRootView::openFilesChanged);
connect(
&Settings::chatAssistantSettings().enableChatTools,
&Utils::BaseAspect::changed,
@@ -735,7 +750,14 @@ void ChatRootView::openRulesFolder()
void ChatRootView::openSettings()
{
Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_CHAT_ASSISTANT_SETTINGS_PAGE_ID);
Settings::showSettings(Constants::QODE_ASSIST_CHAT_ASSISTANT_SETTINGS_PAGE_ID);
}
void ChatRootView::openFileInEditor(const QString &filePath)
{
if (filePath.isEmpty())
return;
Core::EditorManager::openEditor(Utils::FilePath::fromString(filePath));
}
void ChatRootView::updateInputTokensCount()
@@ -788,6 +810,8 @@ void ChatRootView::onEditorAboutToClose(Core::IEditor *editor)
if (editor) {
m_currentEditors.removeOne(editor);
}
emit openFilesChanged();
}
void ChatRootView::onAppendLinkFileFromEditor(Core::IEditor *editor)
@@ -805,6 +829,7 @@ void ChatRootView::onEditorCreated(Core::IEditor *editor, const Utils::FilePath
{
if (editor && editor->document()) {
m_currentEditors.append(editor);
emit openFilesChanged();
}
}
@@ -1490,7 +1515,7 @@ QString ChatRootView::currentAgentRoleSystemPrompt() const
void ChatRootView::openAgentRolesSettings()
{
Core::ICore::showOptionsDialog(Utils::Id("QodeAssist.AgentRoles"));
Settings::showSettings(Utils::Id("QodeAssist.AgentRoles"));
}
void ChatRootView::compressCurrentChat()

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024-2025 Petr Mironychev
* Copyright (C) 2024-2026 Petr Mironychev
*
* This file is part of QodeAssist.
*
@@ -20,6 +20,7 @@
#pragma once
#include <QQuickItem>
#include <QVariantList>
#include "ChatFileManager.hpp"
#include "ChatModel.hpp"
@@ -104,6 +105,8 @@ public:
Q_INVOKABLE void openRulesFolder();
Q_INVOKABLE void openSettings();
Q_INVOKABLE void openFileInEditor(const QString &filePath);
Q_INVOKABLE void updateInputTokensCount();
int inputTokensCount() const;
@@ -222,6 +225,8 @@ signals:
void compressionCompleted(const QString &compressedChatPath);
void compressionFailed(const QString &error);
void openFilesChanged();
private:
void updateFileEditStatus(const QString &editId, const QString &status);
QString getChatsHistoryDir() const;

View File

@@ -0,0 +1,442 @@
/*
* Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
*/
#include "FileMentionItem.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QTextStream>
#include <coreplugin/editormanager/documentmodel.h>
#include <coreplugin/editormanager/editormanager.h>
#include <projectexplorer/project.h>
#include <projectexplorer/projectmanager.h>
namespace QodeAssist::Chat {
FileMentionItem::FileMentionItem(QQuickItem *parent)
: QQuickItem(parent)
{}
QVariantList FileMentionItem::searchResults() const
{
return m_searchResults;
}
int FileMentionItem::currentIndex() const
{
return m_currentIndex;
}
void FileMentionItem::setCurrentIndex(int index)
{
if (m_currentIndex == index)
return;
m_currentIndex = index;
emit currentIndexChanged();
}
void FileMentionItem::updateSearch(const QString &query)
{
m_lastQuery = query;
QVariantList openFiles = getOpenFiles(query);
QVariantList projectResults = searchProjectFiles(query);
QSet<QString> openPaths;
for (const QVariant &item : std::as_const(openFiles)) {
const QVariantMap map = item.toMap();
openPaths.insert(map.value("absolutePath").toString());
}
QVariantList combined = openFiles;
for (const QVariant &item : std::as_const(projectResults)) {
const QVariantMap map = item.toMap();
if (!map.value("isProject").toBool()
&& openPaths.contains(map.value("absolutePath").toString()))
continue;
combined.append(item);
}
m_searchResults = combined;
m_currentIndex = 0;
emit searchResultsChanged();
emit currentIndexChanged();
}
void FileMentionItem::refreshSearch()
{
if (!m_lastQuery.isNull())
updateSearch(m_lastQuery);
}
void FileMentionItem::moveUp()
{
if (m_currentIndex > 0) {
m_currentIndex--;
emit currentIndexChanged();
}
}
void FileMentionItem::moveDown()
{
if (m_currentIndex < m_searchResults.size() - 1) {
m_currentIndex++;
emit currentIndexChanged();
}
}
void FileMentionItem::selectCurrent()
{
if (m_currentIndex < 0 || m_currentIndex >= m_searchResults.size())
return;
const QVariantMap item = m_searchResults[m_currentIndex].toMap();
if (item.value("isProject").toBool()) {
emit projectSelected(item.value("projectName").toString());
} else {
emit fileSelected(
item.value("absolutePath").toString(),
item.value("relativePath").toString(),
item.value("projectName").toString());
}
}
void FileMentionItem::dismiss()
{
m_searchResults.clear();
m_currentIndex = 0;
emit searchResultsChanged();
emit currentIndexChanged();
emit dismissed();
}
QVariantMap FileMentionItem::applyCurrentSelection(
const QString &text, int cursorPosition, bool useTools)
{
if (m_currentIndex < 0 || m_currentIndex >= m_searchResults.size()) {
dismiss();
return {};
}
const QString textBefore = text.left(cursorPosition);
const int atIndex = textBefore.lastIndexOf('@');
if (atIndex < 0) {
dismiss();
return {};
}
const QVariantMap item = m_searchResults[m_currentIndex].toMap();
QString replacement;
if (item.value("isProject").toBool()) {
replacement = QStringLiteral("@") + item.value("projectName").toString() + ":";
} else {
const QString currentQuery = textBefore.mid(atIndex + 1);
const QVariantMap result = handleFileSelection(
item.value("absolutePath").toString(),
item.value("relativePath").toString(),
item.value("projectName").toString(),
currentQuery,
useTools);
if (result.value("mode").toString() == "mention")
replacement = result.value("mentionText").toString();
}
const QString newText = text.left(atIndex) + replacement + text.mid(cursorPosition);
const int newCursorPosition = atIndex + replacement.length();
dismiss();
return {{"text", newText}, {"cursorPosition", newCursorPosition}};
}
QVariantMap FileMentionItem::handleFileSelection(
const QString &absolutePath,
const QString &relativePath,
const QString &projectName,
const QString &currentQuery,
bool useTools)
{
QVariantMap result;
const QString fileName = relativePath.section('/', -1);
QString mentionKey = fileName;
const int colonIdx = currentQuery.indexOf(':');
if (colonIdx > 0) {
const QString projPrefix = currentQuery.left(colonIdx);
if (projPrefix.compare(projectName, Qt::CaseInsensitive) == 0)
mentionKey = projPrefix + ":" + fileName;
}
if (useTools) {
registerMention(mentionKey, absolutePath);
result["mode"] = QStringLiteral("mention");
result["mentionText"] = "@" + mentionKey + " ";
} else {
emit fileAttachRequested({absolutePath});
result["mode"] = QStringLiteral("attach");
}
return result;
}
void FileMentionItem::registerMention(const QString &mentionKey, const QString &absolutePath)
{
m_atMentionMap[mentionKey] = absolutePath;
}
void FileMentionItem::clearMentions()
{
m_atMentionMap.clear();
}
QString FileMentionItem::expandMentions(const QString &text)
{
QString result = text;
for (auto it = m_atMentionMap.constBegin(); it != m_atMentionMap.constEnd(); ++it) {
const QString &mentionKey = it.key();
const QString &absPath = it.value();
const QString displayName = mentionKey.section(':', -1);
const QString escaped = QRegularExpression::escape(mentionKey);
// @key:N-M -> hyperlink + inline code block
const QRegularExpression rangeRe("@" + escaped + ":(\\d+)-(\\d+)(?=\\s|$)");
QRegularExpressionMatchIterator matchIt = rangeRe.globalMatch(result);
QList<QRegularExpressionMatch> matches;
while (matchIt.hasNext())
matches.append(matchIt.next());
for (int i = matches.size() - 1; i >= 0; --i) {
const auto &m = matches[i];
const int startLine = m.captured(1).toInt();
const int endLine = m.captured(2).toInt();
const QString ext = fileExtension(absPath);
const QString snippet = readFileLines(absPath, startLine, endLine);
const QString replacement
= QString("[@%1:%2-%3](file://%4)\n```%5\n%6```")
.arg(displayName)
.arg(startLine)
.arg(endLine)
.arg(absPath, ext, snippet);
result.replace(m.capturedStart(), m.capturedLength(), replacement);
}
// @key -> hyperlink only
const QRegularExpression simpleRe("@" + escaped + "(?=\\s|$)");
result.replace(simpleRe, QString("[@%1](file://%2)").arg(displayName, absPath));
}
return result;
}
QVariantList FileMentionItem::searchProjectFiles(const QString &query)
{
QVariantList results;
struct FileResult
{
QString absolutePath;
QString relativePath;
QString projectName;
int priority;
};
const auto allProjects = ProjectExplorer::ProjectManager::projects();
QString projectFilter;
QString fileQuery = query;
const int colonIdx = query.indexOf(':');
if (colonIdx > 0) {
const QString prefix = query.left(colonIdx);
for (auto project : allProjects) {
if (project && project->displayName().compare(prefix, Qt::CaseInsensitive) == 0) {
projectFilter = project->displayName();
fileQuery = query.mid(colonIdx + 1);
break;
}
}
}
if (projectFilter.isEmpty() && colonIdx < 0) {
const QString lowerQ = query.toLower();
for (auto project : allProjects) {
if (!project)
continue;
const QString name = project->displayName();
if (query.isEmpty() || name.toLower().startsWith(lowerQ)) {
QVariantMap item;
item["absolutePath"] = QString();
item["relativePath"] = name;
item["projectName"] = name;
item["isProject"] = true;
results.append(item);
}
}
}
QList<FileResult> candidates;
const QString lowerFileQuery = fileQuery.toLower();
const bool emptyFileQuery = fileQuery.isEmpty();
for (auto project : allProjects) {
if (!project)
continue;
if (!projectFilter.isEmpty() && project->displayName() != projectFilter)
continue;
const auto projectFiles = project->files(ProjectExplorer::Project::SourceFiles);
const QString projectDir = project->projectDirectory().path();
const QString projectName = project->displayName();
for (const auto &filePath : projectFiles) {
const QString absolutePath = filePath.path();
const QFileInfo fileInfo(absolutePath);
const QString fileName = fileInfo.fileName();
const QString relativePath = QDir(projectDir).relativeFilePath(absolutePath);
const QString lowerFileName = fileName.toLower();
const QString lowerRelativePath = relativePath.toLower();
int priority = -1;
if (emptyFileQuery) {
priority = 3;
} else if (lowerFileName == lowerFileQuery) {
priority = 0;
} else if (lowerFileName.startsWith(lowerFileQuery)) {
priority = 1;
} else if (lowerFileName.contains(lowerFileQuery)) {
priority = 2;
} else if (lowerRelativePath.contains(lowerFileQuery)) {
priority = 3;
}
if (priority >= 0)
candidates.append({absolutePath, relativePath, projectName, priority});
}
}
std::sort(candidates.begin(), candidates.end(), [](const FileResult &a, const FileResult &b) {
if (a.priority != b.priority)
return a.priority < b.priority;
return a.relativePath < b.relativePath;
});
const int maxFiles = qMax(0, 10 - results.size());
const int count = qMin(candidates.size(), maxFiles);
for (int i = 0; i < count; i++) {
QVariantMap item;
item["absolutePath"] = candidates[i].absolutePath;
item["relativePath"] = candidates[i].relativePath;
item["projectName"] = candidates[i].projectName;
item["isProject"] = false;
results.append(item);
}
return results;
}
QVariantList FileMentionItem::getOpenFiles(const QString &query)
{
QVariantList results;
const QString lowerQuery = query.toLower();
const bool emptyQuery = query.isEmpty();
QSet<QString> addedPaths;
auto tryAddDocument = [&](Core::IDocument *document) {
if (!document)
return;
const QString absolutePath = document->filePath().toFSPathString();
if (absolutePath.isEmpty() || addedPaths.contains(absolutePath))
return;
const QFileInfo fileInfo(absolutePath);
const QString fileName = fileInfo.fileName();
if (fileName.isEmpty())
return;
QString relativePath = absolutePath;
QString projectName;
auto project = ProjectExplorer::ProjectManager::projectForFile(document->filePath());
if (project) {
projectName = project->displayName();
relativePath = QDir(project->projectDirectory().path()).relativeFilePath(absolutePath);
}
if (!emptyQuery) {
const QString lowerFileName = fileName.toLower();
const QString lowerRelativePath = relativePath.toLower();
if (!lowerFileName.contains(lowerQuery) && !lowerRelativePath.contains(lowerQuery))
return;
}
addedPaths.insert(absolutePath);
QVariantMap item;
item["absolutePath"] = absolutePath;
item["relativePath"] = relativePath;
item["projectName"] = projectName;
item["isProject"] = false;
item["isOpen"] = true;
results.append(item);
};
if (auto current = Core::EditorManager::currentEditor())
tryAddDocument(current->document());
for (auto editor : Core::EditorManager::visibleEditors())
if (editor)
tryAddDocument(editor->document());
for (auto document : Core::DocumentModel::openedDocuments())
tryAddDocument(document);
return results;
}
QString FileMentionItem::readFileLines(const QString &filePath, int startLine, int endLine)
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return {};
QTextStream stream(&file);
QString result;
int lineNum = 1;
while (!stream.atEnd()) {
const QString line = stream.readLine();
if (lineNum >= startLine)
result += line + '\n';
if (lineNum >= endLine)
break;
++lineNum;
}
return result;
}
QString FileMentionItem::fileExtension(const QString &filePath)
{
const int dot = filePath.lastIndexOf('.');
return dot >= 0 ? filePath.mid(dot + 1) : QString();
}
} // namespace QodeAssist::Chat

View File

@@ -0,0 +1,86 @@
/*
* Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <QHash>
#include <QQuickItem>
#include <QRegularExpression>
#include <QVariantList>
namespace QodeAssist::Chat {
class FileMentionItem : public QQuickItem
{
Q_OBJECT
Q_PROPERTY(QVariantList searchResults READ searchResults NOTIFY searchResultsChanged FINAL)
Q_PROPERTY(int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged FINAL)
QML_ELEMENT
public:
explicit FileMentionItem(QQuickItem *parent = nullptr);
QVariantList searchResults() const;
int currentIndex() const;
void setCurrentIndex(int index);
Q_INVOKABLE void updateSearch(const QString &query);
Q_INVOKABLE void refreshSearch();
Q_INVOKABLE void moveUp();
Q_INVOKABLE void moveDown();
Q_INVOKABLE void selectCurrent();
Q_INVOKABLE void dismiss();
Q_INVOKABLE QVariantMap handleFileSelection(
const QString &absolutePath,
const QString &relativePath,
const QString &projectName,
const QString &currentQuery,
bool useTools);
Q_INVOKABLE QVariantMap applyCurrentSelection(
const QString &text, int cursorPosition, bool useTools);
Q_INVOKABLE void registerMention(const QString &mentionKey, const QString &absolutePath);
Q_INVOKABLE void clearMentions();
Q_INVOKABLE QString expandMentions(const QString &text);
signals:
void searchResultsChanged();
void currentIndexChanged();
void fileSelected(const QString &absolutePath,
const QString &relativePath,
const QString &projectName);
void projectSelected(const QString &projectName);
void dismissed();
void fileAttachRequested(const QStringList &filePaths);
private:
QVariantList searchProjectFiles(const QString &query);
QVariantList getOpenFiles(const QString &query);
QString readFileLines(const QString &filePath, int startLine, int endLine);
static QString fileExtension(const QString &filePath);
QVariantList m_searchResults;
int m_currentIndex = 0;
QString m_lastQuery;
QHash<QString, QString> m_atMentionMap;
};
} // namespace QodeAssist::Chat

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024-2025 Petr Mironychev
* Copyright (C) 2024-2026 Petr Mironychev
*
* This file is part of QodeAssist.
*
@@ -280,6 +280,10 @@ ChatRootView {
messageInput.cursorPosition = model.content.length
root.chatModel.resetModelTo(idx)
}
onOpenFileRequested: function(filePath) {
root.openFileInEditor(filePath)
}
}
}
@@ -368,7 +372,38 @@ ChatRootView {
}
}
onTextChanged: root.calculateMessageTokensCount(messageInput.text)
onTextChanged: {
root.calculateMessageTokensCount(messageInput.text)
var cursorPos = messageInput.cursorPosition
var textBefore = messageInput.text.substring(0, cursorPos)
var atIndex = textBefore.lastIndexOf('@')
if (atIndex >= 0) {
var query = textBefore.substring(atIndex + 1)
if (query.indexOf(' ') === -1 && query.indexOf('\n') === -1) {
fileMentionPopup.updateSearch(query)
return
}
}
fileMentionPopup.dismiss()
}
Keys.onPressed: function(event) {
if (fileMentionPopup.visible) {
if (event.key === Qt.Key_Down) {
fileMentionPopup.moveDown()
event.accepted = true
} else if (event.key === Qt.Key_Up) {
fileMentionPopup.moveUp()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.applyMentionSelection()
event.accepted = true
} else if (event.key === Qt.Key_Escape) {
fileMentionPopup.dismiss()
event.accepted = true
}
}
}
MouseArea {
anchors.fill: parent
@@ -479,11 +514,8 @@ ChatRootView {
sequences: ["Ctrl+Return", "Ctrl+Enter"]
context: Qt.WindowShortcut
onActivated: {
if (messageInput.activeFocus && !Qt.inputMethod.visible) {
root.sendChatMessage()
}
}
enabled: messageInput.activeFocus && !Qt.inputMethod.visible && !fileMentionPopup.visible
onActivated: root.sendChatMessage()
}
function clearChat() {
@@ -496,9 +528,19 @@ ChatRootView {
Qt.callLater(chatListView.positionViewAtEnd)
}
function applyMentionSelection() {
var result = fileMentionPopup.applyCurrentSelection(
messageInput.text, messageInput.cursorPosition, root.useTools)
if (result.text !== undefined) {
messageInput.text = result.text
messageInput.cursorPosition = result.cursorPosition
}
}
function sendChatMessage() {
root.sendMessage(messageInput.text)
root.sendMessage(fileMentionPopup.expandMentions(messageInput.text))
messageInput.text = ""
fileMentionPopup.clearMentions()
scrollToBottom()
}
@@ -572,6 +614,26 @@ ChatRootView {
infoToast.show(root.lastInfoMessage)
}
}
function onOpenFilesChanged() {
if (fileMentionPopup.visible)
Qt.callLater(fileMentionPopup.refreshSearch)
}
}
FileMentionPopup {
id: fileMentionPopup
z: 999
width: Math.min(480, root.width - 20)
x: Math.max(5, Math.min(view.x + 5, root.width - width - 5))
y: view.y - height - 4
onSelectionRequested: root.applyMentionSelection()
onFileAttachRequested: function(filePaths) {
root.addFilesToAttachList(filePaths)
}
}
Component.onCompleted: {

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024-2025 Petr Mironychev
* Copyright (C) 2024-2026 Petr Mironychev
*
* This file is part of QodeAssist.
*
@@ -51,6 +51,7 @@ Rectangle {
property int messageIndex: -1
signal resetChatToMessage(int index)
signal openFileRequested(string filePath)
height: msgColumn.implicitHeight + 10
radius: 8
@@ -180,9 +181,12 @@ Rectangle {
onClicked: function() {
root.resetChatToMessage(root.messageIndex)
}
ToolTip.visible: hovered
ToolTip.text: qsTr("Reset chat to this message and edit")
ToolTip.delay: 500
QoAToolTip {
visible: stopButtonId.hovered
text: qsTr("Reset chat to this message and edit")
delay: 500
}
}
component TextComponent : TextBlock {
@@ -204,6 +208,15 @@ Rectangle {
}
}
onLinkActivated: function(link) {
if (link.startsWith("file://")) {
var filePath = link.replace(/^file:\/\//, "")
root.openFileRequested(filePath)
} else {
Qt.openUrlExternally(link)
}
}
ChatUtils {
id: utils
}
@@ -257,33 +270,21 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
acceptedButtons: Qt.LeftButton
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (mouse.button === Qt.LeftButton) {
if (mouse.modifiers & Qt.ShiftModifier) {
fileItem.openFileInExternalEditor()
} else {
fileItem.openFileInEditor()
} else if (mouse.button === Qt.RightButton) {
attachmentContextMenu.popup()
}
}
ToolTip.visible: containsMouse
ToolTip.text: qsTr("Left click: Open in Qt Creator\nRight click: More options")
ToolTip.delay: 500
}
Menu {
id: attachmentContextMenu
MenuItem {
text: qsTr("Open in Qt Creator")
onTriggered: fileItem.openFileInEditor()
}
MenuItem {
text: qsTr("Open in System Editor")
onTriggered: fileItem.openFileInExternalEditor()
QoAToolTip {
visible: attachFileMouseArea.containsMouse
text: qsTr("Click: Open in Qt Creator\nShift+Click: Open in System Editor")
delay: 500
}
}
}
@@ -305,7 +306,7 @@ Rectangle {
FileItem {
id: imageFileItem
filePath: itemData.imageUrl ? itemData.imageUrl.toString().replace("file://", "") : ""
filePath: itemData.filePath || ""
}
ColumnLayout {
@@ -361,33 +362,21 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
acceptedButtons: Qt.LeftButton
cursorShape: Qt.PointingHandCursor
onClicked: (mouse) => {
if (mouse.button === Qt.LeftButton) {
if (mouse.modifiers & Qt.ShiftModifier) {
imageFileItem.openFileInExternalEditor()
} else {
imageFileItem.openFileInEditor()
} else if (mouse.button === Qt.RightButton) {
imageContextMenu.popup()
}
}
ToolTip.visible: containsMouse
ToolTip.text: qsTr("Left click: Open in System\nRight click: More options")
ToolTip.delay: 500
}
Menu {
id: imageContextMenu
MenuItem {
text: qsTr("Open in Qt Creator")
onTriggered: imageFileItem.openFileInEditor()
}
MenuItem {
text: qsTr("Open in System Viewer")
onTriggered: imageFileItem.openFileInExternalEditor()
QoAToolTip {
visible: imageMouseArea.containsMouse
text: qsTr("Click: Open in Qt Creator\nShift+Click: Open in System Editor")
delay: 500
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024-2025 Petr Mironychev
* Copyright (C) 2024-2026 Petr Mironychev
*
* This file is part of QodeAssist.
*
@@ -29,8 +29,6 @@ TextEdit {
selectionColor: palette.highlight
color: palette.text
onLinkActivated: (link) => Qt.openUrlExternally(link)
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024-2025 Petr Mironychev
* Copyright (C) 2024-2026 Petr Mironychev
*
* This file is part of QodeAssist.
*
@@ -21,6 +21,7 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ChatView
import UIControls
Flow {
id: root
@@ -78,9 +79,11 @@ Flow {
}
}
ToolTip.visible: containsMouse
ToolTip.delay: 500
ToolTip.text: "Click: Open in Qt Creator\nShift+Click: Open in external editor\nCtrl+Click / Middle Click: Remove"
QoAToolTip {
visible: mouse.containsMouse
delay: 500
text: "Click: Open in Qt Creator\nShift+Click: Open in external editor\nCtrl+Click / Middle Click: Remove"
}
}
Menu {

View File

@@ -0,0 +1,167 @@
/*
* Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
*/
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
FileMentionItem {
id: root
signal selectionRequested()
visible: searchResults.length > 0
height: Math.min(searchResults.length * 36, 36 * 6) + 2
onCurrentIndexChanged: {
listView.positionViewAtIndex(root.currentIndex, ListView.Contain)
}
Rectangle {
id: background
anchors.fill: parent
color: palette.window
border.color: palette.mid
border.width: 1
radius: 4
}
ListView {
id: listView
anchors.fill: parent
anchors.margins: 1
model: root.searchResults
currentIndex: root.currentIndex
clip: true
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
}
delegate: Rectangle {
id: delegateItem
required property int index
required property var modelData
readonly property bool isProject: modelData.isProject === true
readonly property bool isOpen: modelData.isOpen === true
readonly property string fileName: {
if (isProject)
return modelData.projectName
const parts = modelData.relativePath.split('/')
return parts[parts.length - 1]
}
width: listView.width
height: 36
color: index === root.currentIndex
? palette.highlight
: (hoverArea.containsMouse
? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.25)
: "transparent")
RowLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 8
Item {
Layout.preferredWidth: 18
Layout.preferredHeight: 18
Rectangle {
anchors.fill: parent
radius: 3
visible: delegateItem.isProject || delegateItem.isOpen
color: {
if (delegateItem.index === root.currentIndex)
return Qt.rgba(palette.highlightedText.r,
palette.highlightedText.g,
palette.highlightedText.b, 0.2)
if (delegateItem.isProject)
return Qt.rgba(palette.highlight.r,
palette.highlight.g,
palette.highlight.b, 0.3)
return Qt.rgba(0.2, 0.7, 0.4, 0.3)
}
Text {
anchors.centerIn: parent
text: delegateItem.isProject ? "P" : "O"
font.bold: true
font.pixelSize: 10
color: {
if (delegateItem.index === root.currentIndex)
return palette.highlightedText
if (delegateItem.isProject)
return palette.highlight
return Qt.rgba(0.1, 0.6, 0.3, 1.0)
}
}
}
}
Text {
Layout.preferredWidth: 160
text: delegateItem.fileName
color: delegateItem.index === root.currentIndex
? palette.highlightedText
: (delegateItem.isProject ? palette.highlight : palette.text)
font.bold: true
font.italic: delegateItem.isProject
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: delegateItem.isProject
? "→"
: (delegateItem.modelData.projectName + " / " + delegateItem.modelData.relativePath)
color: delegateItem.index === root.currentIndex
? (delegateItem.isProject
? palette.highlightedText
: Qt.rgba(palette.highlightedText.r,
palette.highlightedText.g,
palette.highlightedText.b, 0.7))
: palette.mid
font.pixelSize: delegateItem.isProject ? 12 : 11
elide: Text.ElideLeft
horizontalAlignment: delegateItem.isProject ? Text.AlignLeft : Text.AlignRight
}
}
MouseArea {
id: hoverArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
root.currentIndex = delegateItem.index
root.selectionRequested()
}
onEntered: root.currentIndex = delegateItem.index
}
}
}
}

View File

@@ -170,28 +170,26 @@ void ConfigurationManager::selectModel()
: isQuickRefactor ? m_generalSettings.qrUrl.volatileValue()
: m_generalSettings.caUrl.volatileValue();
auto &targetSettings = isCodeCompletion ? m_generalSettings.ccModel
: isPreset1 ? m_generalSettings.ccPreset1Model
: isQuickRefactor ? m_generalSettings.qrModel
: m_generalSettings.caModel;
auto *targetSettings = &(isCodeCompletion ? m_generalSettings.ccModel
: isPreset1 ? m_generalSettings.ccPreset1Model
: isQuickRefactor ? m_generalSettings.qrModel
: m_generalSettings.caModel);
if (auto provider = m_providersManager.getProviderByName(providerName)) {
if (!provider->supportsModelListing()) {
m_generalSettings.showModelsNotSupportedDialog(targetSettings);
m_generalSettings.showModelsNotSupportedDialog(*targetSettings);
return;
}
const auto modelList = provider->getInstalledModels(providerUrl);
if (modelList.isEmpty()) {
m_generalSettings.showModelsNotFoundDialog(targetSettings);
return;
}
QTimer::singleShot(0, &m_generalSettings, [this, modelList, &targetSettings]() {
m_generalSettings.showSelectionDialog(
modelList, targetSettings, Tr::tr("Select LLM Model"), Tr::tr("Models:"));
});
provider->getInstalledModels(providerUrl)
.then(this, [this, targetSettings](const QList<QString> &modelList) {
if (modelList.isEmpty()) {
m_generalSettings.showModelsNotFoundDialog(*targetSettings);
return;
}
m_generalSettings.showSelectionDialog(
modelList, *targetSettings, Tr::tr("Select LLM Model"), Tr::tr("Models:"));
});
}
}

View File

@@ -1,7 +1,7 @@
{
"Id" : "qodeassist",
"Name" : "QodeAssist",
"Version" : "0.9.8",
"Version" : "0.9.11",
"CompatVersion" : "${IDE_VERSION}",
"Vendor" : "Petr Mironychev",
"VendorId" : "petrmironychev",

View File

@@ -13,11 +13,12 @@ qt_add_qml_module(QodeAssistUIControls
qml/QoATextSlider.qml
qml/QoAComboBox.qml
qml/FadeListItemAnimation.qml
qml/QoASeparator.qml
qml/QoAToolTip.qml
RESOURCES
icons/dropdown-arrow-light.svg
icons/dropdown-arrow-dark.svg
QML_FILES qml/QoASeparator.qml
)
target_link_libraries(QodeAssistUIControls

View File

@@ -0,0 +1,84 @@
/*
* Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
*/
import QtQuick
import QtQuick.Controls
ToolTip {
id: root
padding: 8
contentItem: Text {
text: root.text
font: root.font
color: palette.toolTipText
wrapMode: Text.Wrap
}
background: Item {
implicitWidth: bg.implicitWidth
implicitHeight: bg.implicitHeight
Rectangle {
anchors.fill: bg
anchors.margins: -2
color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.12)
radius: 8
z: -2
}
Rectangle {
anchors.fill: bg
anchors.margins: -1
color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.08)
radius: 7
z: -1
}
Rectangle {
id: bg
anchors.fill: parent
color: palette.toolTipBase
border.color: Qt.darker(palette.toolTipBase, 1.2)
border.width: 1
radius: 6
}
}
enter: Transition {
NumberAnimation {
property: "opacity"
from: 0.0
to: 1.0
duration: 150
easing.type: Easing.OutQuad
}
}
exit: Transition {
NumberAnimation {
property: "opacity"
from: 1.0
to: 0.0
duration: 100
easing.type: Easing.InQuad
}
}
}

View File

@@ -21,7 +21,6 @@
#include <QJsonDocument>
#include <QMutexLocker>
#include <QUuid>
#include <Logger.hpp>
@@ -30,9 +29,7 @@ namespace QodeAssist::LLMCore {
HttpClient::HttpClient(QObject *parent)
: QObject(parent)
, m_manager(new QNetworkAccessManager(this))
{
connect(this, &HttpClient::sendRequest, this, &HttpClient::onSendRequest);
}
{}
HttpClient::~HttpClient()
{
@@ -44,156 +41,96 @@ HttpClient::~HttpClient()
m_activeRequests.clear();
}
void HttpClient::onSendRequest(const HttpRequest &request)
QFuture<QByteArray> HttpClient::get(const QNetworkRequest &request)
{
QJsonDocument doc(request.payload);
LOG_MESSAGE(QString("HttpClient: data: %1").arg(doc.toJson(QJsonDocument::Indented)));
LOG_MESSAGE(QString("HttpClient: GET %1").arg(request.url().toString()));
QNetworkReply *reply
= m_manager->post(request.networkRequest, doc.toJson(QJsonDocument::Compact));
addActiveRequest(reply, request.requestId);
auto promise = std::make_shared<QPromise<QByteArray>>();
promise->start();
QNetworkReply *reply = m_manager->get(request);
setupNonStreamingReply(reply, promise);
return promise->future();
}
QFuture<QByteArray> HttpClient::post(const QNetworkRequest &request, const QJsonObject &payload)
{
QJsonDocument doc(payload);
LOG_MESSAGE(QString("HttpClient: POST %1, data: %2")
.arg(request.url().toString(), doc.toJson(QJsonDocument::Indented)));
auto promise = std::make_shared<QPromise<QByteArray>>();
promise->start();
QNetworkReply *reply = m_manager->post(request, doc.toJson(QJsonDocument::Compact));
setupNonStreamingReply(reply, promise);
return promise->future();
}
QFuture<QByteArray> HttpClient::del(const QNetworkRequest &request,
std::optional<QJsonObject> payload)
{
auto promise = std::make_shared<QPromise<QByteArray>>();
promise->start();
QNetworkReply *reply;
if (payload) {
QJsonDocument doc(*payload);
LOG_MESSAGE(QString("HttpClient: DELETE %1, data: %2")
.arg(request.url().toString(), doc.toJson(QJsonDocument::Indented)));
reply = m_manager->sendCustomRequest(request, "DELETE", doc.toJson(QJsonDocument::Compact));
} else {
LOG_MESSAGE(QString("HttpClient: DELETE %1").arg(request.url().toString()));
reply = m_manager->deleteResource(request);
}
setupNonStreamingReply(reply, promise);
return promise->future();
}
void HttpClient::setupNonStreamingReply(QNetworkReply *reply,
std::shared_ptr<QPromise<QByteArray>> promise)
{
connect(reply, &QNetworkReply::finished, this, [this, reply, promise]() {
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
QByteArray responseBody = reply->readAll();
QNetworkReply::NetworkError networkError = reply->error();
QString networkErrorString = reply->errorString();
reply->disconnect();
reply->deleteLater();
LOG_MESSAGE(
QString("HttpClient: Non-streaming request - HTTP Status: %1").arg(statusCode));
bool hasError = (networkError != QNetworkReply::NoError) || (statusCode >= 400);
if (hasError) {
QString errorMsg = parseErrorFromResponse(statusCode, responseBody, networkErrorString);
LOG_MESSAGE(QString("HttpClient: Non-streaming request - Error: %1").arg(errorMsg));
promise->setException(
std::make_exception_ptr(std::runtime_error(errorMsg.toStdString())));
} else {
promise->addResult(responseBody);
}
promise->finish();
});
}
void HttpClient::postStreaming(const QString &requestId, const QNetworkRequest &request,
const QJsonObject &payload)
{
QJsonDocument doc(payload);
LOG_MESSAGE(QString("HttpClient: POST streaming %1, data: %2")
.arg(request.url().toString(), doc.toJson(QJsonDocument::Indented)));
QNetworkReply *reply = m_manager->post(request, doc.toJson(QJsonDocument::Compact));
addActiveRequest(reply, requestId);
connect(reply, &QNetworkReply::readyRead, this, &HttpClient::onReadyRead);
connect(reply, &QNetworkReply::finished, this, &HttpClient::onFinished);
}
void HttpClient::onReadyRead()
{
QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply || reply->isFinished())
return;
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (statusCode >= 400) {
return;
}
QString requestId;
{
QMutexLocker locker(&m_mutex);
bool found = false;
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
if (it.value() == reply) {
requestId = it.key();
found = true;
break;
}
}
if (!found)
return;
}
if (requestId.isEmpty())
return;
QByteArray data = reply->readAll();
if (!data.isEmpty()) {
emit dataReceived(requestId, data);
}
}
void HttpClient::onFinished()
{
QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply)
return;
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
QByteArray responseBody = reply->readAll();
QNetworkReply::NetworkError networkError = reply->error();
QString networkErrorString = reply->errorString();
reply->disconnect();
QString requestId;
bool hasError = false;
QString errorMsg;
{
QMutexLocker locker(&m_mutex);
bool found = false;
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
if (it.value() == reply) {
requestId = it.key();
m_activeRequests.erase(it);
found = true;
break;
}
}
if (!found) {
reply->deleteLater();
return;
}
hasError = (networkError != QNetworkReply::NoError) || (statusCode >= 400);
if (hasError) {
errorMsg = parseErrorFromResponse(statusCode, responseBody, networkErrorString);
}
LOG_MESSAGE(QString("HttpClient: Request %1 - HTTP Status: %2").arg(requestId).arg(statusCode));
if (!responseBody.isEmpty()) {
LOG_MESSAGE(QString("HttpClient: Request %1 - Response body (%2 bytes): %3")
.arg(requestId)
.arg(responseBody.size())
.arg(QString::fromUtf8(responseBody)));
}
if (hasError) {
LOG_MESSAGE(QString("HttpClient: Request %1 - Error: %2").arg(requestId, errorMsg));
}
}
reply->deleteLater();
if (!requestId.isEmpty()) {
emit requestFinished(requestId, !hasError, errorMsg);
}
}
QString HttpClient::addActiveRequest(QNetworkReply *reply, const QString &requestId)
{
QMutexLocker locker(&m_mutex);
m_activeRequests[requestId] = reply;
LOG_MESSAGE(QString("HttpClient: Added active request: %1").arg(requestId));
return requestId;
}
QString HttpClient::parseErrorFromResponse(
int statusCode, const QByteArray &responseBody, const QString &networkErrorString)
{
QString errorMsg;
if (!responseBody.isEmpty()) {
QJsonDocument errorDoc = QJsonDocument::fromJson(responseBody);
if (!errorDoc.isNull() && errorDoc.isObject()) {
QJsonObject errorObj = errorDoc.object();
if (errorObj.contains("error")) {
QJsonObject error = errorObj["error"].toObject();
QString message = error["message"].toString();
QString type = error["type"].toString();
QString code = error["code"].toString();
errorMsg = QString("HTTP %1: %2").arg(statusCode).arg(message);
if (!type.isEmpty())
errorMsg += QString(" (type: %1)").arg(type);
if (!code.isEmpty())
errorMsg += QString(" (code: %1)").arg(code);
} else {
errorMsg = QString("HTTP %1: %2").arg(statusCode).arg(QString::fromUtf8(responseBody));
}
} else {
errorMsg = QString("HTTP %1: %2").arg(statusCode).arg(QString::fromUtf8(responseBody));
}
} else {
errorMsg = QString("HTTP %1: %2").arg(statusCode).arg(networkErrorString);
}
return errorMsg;
connect(reply, &QNetworkReply::finished, this, &HttpClient::onStreamingFinished);
}
void HttpClient::cancelRequest(const QString &requestId)
@@ -212,4 +149,128 @@ void HttpClient::cancelRequest(const QString &requestId)
}
}
void HttpClient::onReadyRead()
{
QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply || reply->isFinished())
return;
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (statusCode >= 400)
return;
QString requestId = findRequestId(reply);
if (requestId.isEmpty())
return;
QByteArray data = reply->readAll();
if (!data.isEmpty()) {
emit dataReceived(requestId, data);
}
}
void HttpClient::onStreamingFinished()
{
QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply)
return;
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
QByteArray responseBody = reply->readAll();
QNetworkReply::NetworkError networkError = reply->error();
QString networkErrorString = reply->errorString();
reply->disconnect();
QString requestId;
std::optional<QString> error;
{
QMutexLocker locker(&m_mutex);
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
if (it.value() == reply) {
requestId = it.key();
m_activeRequests.erase(it);
break;
}
}
if (requestId.isEmpty()) {
reply->deleteLater();
return;
}
bool hasError = (networkError != QNetworkReply::NoError) || (statusCode >= 400);
if (hasError) {
error = parseErrorFromResponse(statusCode, responseBody, networkErrorString);
}
LOG_MESSAGE(
QString("HttpClient: Request %1 - HTTP Status: %2").arg(requestId).arg(statusCode));
if (!responseBody.isEmpty()) {
LOG_MESSAGE(QString("HttpClient: Request %1 - Response body (%2 bytes): %3")
.arg(requestId)
.arg(responseBody.size())
.arg(QString::fromUtf8(responseBody)));
}
if (error) {
LOG_MESSAGE(QString("HttpClient: Request %1 - Error: %2").arg(requestId, *error));
}
}
reply->deleteLater();
if (!requestId.isEmpty()) {
emit requestFinished(requestId, error);
}
}
QString HttpClient::findRequestId(QNetworkReply *reply)
{
QMutexLocker locker(&m_mutex);
for (auto it = m_activeRequests.begin(); it != m_activeRequests.end(); ++it) {
if (it.value() == reply)
return it.key();
}
return {};
}
void HttpClient::addActiveRequest(QNetworkReply *reply, const QString &requestId)
{
QMutexLocker locker(&m_mutex);
m_activeRequests[requestId] = reply;
LOG_MESSAGE(QString("HttpClient: Added active request: %1").arg(requestId));
}
QString HttpClient::parseErrorFromResponse(
int statusCode, const QByteArray &responseBody, const QString &networkErrorString)
{
if (!responseBody.isEmpty()) {
QJsonDocument errorDoc = QJsonDocument::fromJson(responseBody);
if (!errorDoc.isNull() && errorDoc.isObject()) {
QJsonObject errorObj = errorDoc.object();
if (errorObj.contains("error")) {
QJsonObject error = errorObj["error"].toObject();
QString message = error["message"].toString();
QString type = error["type"].toString();
QString code = error["code"].toString();
QString errorMsg = QString("HTTP %1: %2").arg(statusCode).arg(message);
if (!type.isEmpty())
errorMsg += QString(" (type: %1)").arg(type);
if (!code.isEmpty())
errorMsg += QString(" (code: %1)").arg(code);
return errorMsg;
}
return QString("HTTP %1: %2")
.arg(statusCode)
.arg(QString::fromUtf8(responseBody));
}
return QString("HTTP %1: %2").arg(statusCode).arg(QString::fromUtf8(responseBody));
}
return QString("HTTP %1: %2").arg(statusCode).arg(networkErrorString);
}
} // namespace QodeAssist::LLMCore

View File

@@ -1,4 +1,4 @@
/*
/*
* Copyright (C) 2025 Petr Mironychev
*
* This file is part of QodeAssist.
@@ -19,24 +19,19 @@
#pragma once
#include <optional>
#include <QFuture>
#include <QHash>
#include <QJsonObject>
#include <QMap>
#include <QMutex>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QObject>
#include <QUrl>
#include <QPromise>
namespace QodeAssist::LLMCore {
struct HttpRequest
{
QNetworkRequest networkRequest;
QString requestId;
QJsonObject payload;
};
class HttpClient : public QObject
{
Q_OBJECT
@@ -45,21 +40,33 @@ public:
HttpClient(QObject *parent = nullptr);
~HttpClient();
// Non-streaming — return QFuture with full response
QFuture<QByteArray> get(const QNetworkRequest &request);
QFuture<QByteArray> post(const QNetworkRequest &request, const QJsonObject &payload);
QFuture<QByteArray> del(const QNetworkRequest &request,
std::optional<QJsonObject> payload = std::nullopt);
// Streaming — signal-based with requestId
void postStreaming(const QString &requestId, const QNetworkRequest &request,
const QJsonObject &payload);
void cancelRequest(const QString &requestId);
signals:
void sendRequest(const QodeAssist::LLMCore::HttpRequest &request);
void dataReceived(const QString &requestId, const QByteArray &data);
void requestFinished(const QString &requestId, bool success, const QString &error);
void requestFinished(const QString &requestId, std::optional<QString> error);
private slots:
void onSendRequest(const QodeAssist::LLMCore::HttpRequest &request);
void onReadyRead();
void onFinished();
void onStreamingFinished();
private:
QString addActiveRequest(QNetworkReply *reply, const QString &requestId);
QString parseErrorFromResponse(int statusCode, const QByteArray &responseBody, const QString &networkErrorString);
void setupNonStreamingReply(QNetworkReply *reply, std::shared_ptr<QPromise<QByteArray>> promise);
QString findRequestId(QNetworkReply *reply);
void addActiveRequest(QNetworkReply *reply, const QString &requestId);
QString parseErrorFromResponse(int statusCode, const QByteArray &responseBody,
const QString &networkErrorString);
QNetworkAccessManager *m_manager;
QHash<QString, QNetworkReply *> m_activeRequests;

View File

@@ -19,6 +19,9 @@
#pragma once
#include <optional>
#include <QFuture>
#include <utils/environment.h>
#include <QNetworkRequest>
#include <QObject>
@@ -57,7 +60,7 @@ public:
bool isToolsEnabled,
bool isThinkingEnabled)
= 0;
virtual QList<QString> getInstalledModels(const QString &url) = 0;
virtual QFuture<QList<QString>> getInstalledModels(const QString &url) = 0;
virtual QList<QString> validateRequest(const QJsonObject &request, TemplateType type) = 0;
virtual QString apiKey() const = 0;
virtual void prepareNetworkRequest(QNetworkRequest &networkRequest) const = 0;
@@ -81,7 +84,7 @@ public slots:
const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data)
= 0;
virtual void onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId, bool success, const QString &error)
const QodeAssist::LLMCore::RequestID &requestId, std::optional<QString> error)
= 0;
signals:

View File

@@ -19,11 +19,9 @@
#include "ClaudeProvider.hpp"
#include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QUrlQuery>
#include "llmcore/ValidationUtils.hpp"
@@ -142,11 +140,8 @@ void ClaudeProvider::prepareRequest(
}
}
QList<QString> ClaudeProvider::getInstalledModels(const QString &baseUrl)
QFuture<QList<QString>> ClaudeProvider::getInstalledModels(const QString &baseUrl)
{
QList<QString> models;
QNetworkAccessManager manager;
QUrl url(baseUrl + "/v1/models");
QUrlQuery query;
query.addQueryItem("limit", "1000");
@@ -160,32 +155,24 @@ QList<QString> ClaudeProvider::getInstalledModels(const QString &baseUrl)
request.setRawHeader("x-api-key", apiKey().toUtf8());
}
QNetworkReply *reply = manager.get(request);
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
if (reply->error() == QNetworkReply::NoError) {
QByteArray responseData = reply->readAll();
QJsonDocument jsonResponse = QJsonDocument::fromJson(responseData);
QJsonObject jsonObject = jsonResponse.object();
return httpClient()->get(request).then([](const QByteArray &data) {
QList<QString> models;
QJsonObject jsonObject = QJsonDocument::fromJson(data).object();
if (jsonObject.contains("data")) {
QJsonArray modelArray = jsonObject["data"].toArray();
for (const QJsonValue &value : modelArray) {
QJsonObject modelObject = value.toObject();
if (modelObject.contains("id")) {
QString modelId = modelObject["id"].toString();
models.append(modelId);
models.append(modelObject["id"].toString());
}
}
}
} else {
LOG_MESSAGE(QString("Error fetching Claude models: %1").arg(reply->errorString()));
}
reply->deleteLater();
return models;
return models;
}).onFailed([](const std::exception &e) {
LOG_MESSAGE(QString("Error fetching Claude models: %1").arg(e.what()));
return QList<QString>{};
});
}
QList<QString> ClaudeProvider::validateRequest(const QJsonObject &request, LLMCore::TemplateType type)
@@ -240,12 +227,9 @@ void ClaudeProvider::sendRequest(
QNetworkRequest networkRequest(url);
prepareNetworkRequest(networkRequest);
LLMCore::HttpRequest
request{.networkRequest = networkRequest, .requestId = requestId, .payload = payload};
LOG_MESSAGE(QString("ClaudeProvider: Sending request %1 to %2").arg(requestId, url.toString()));
emit httpClient()->sendRequest(request);
httpClient()->postStreaming(requestId, networkRequest, payload);
}
bool ClaudeProvider::supportsTools() const
@@ -289,11 +273,11 @@ void ClaudeProvider::onDataReceived(
}
void ClaudeProvider::onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId, bool success, const QString &error)
const QodeAssist::LLMCore::RequestID &requestId, std::optional<QString> error)
{
if (!success) {
LOG_MESSAGE(QString("ClaudeProvider request %1 failed: %2").arg(requestId, error));
emit requestFailed(requestId, error);
if (error) {
LOG_MESSAGE(QString("ClaudeProvider request %1 failed: %2").arg(requestId, *error));
emit requestFailed(requestId, *error);
cleanupRequest(requestId);
return;
}

View File

@@ -44,7 +44,7 @@ public:
LLMCore::RequestType type,
bool isToolsEnabled,
bool isThinkingEnabled) override;
QList<QString> getInstalledModels(const QString &url) override;
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
QString apiKey() const override;
void prepareNetworkRequest(QNetworkRequest &networkRequest) const override;
@@ -65,8 +65,7 @@ public slots:
const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data) override;
void onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId,
bool success,
const QString &error) override;
std::optional<QString> error) override;
private slots:
void onToolExecutionComplete(

View File

@@ -19,11 +19,9 @@
#include "GoogleAIProvider.hpp"
#include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QtCore/qurlquery.h>
#include "llmcore/ValidationUtils.hpp"
@@ -156,29 +154,17 @@ void GoogleAIProvider::prepareRequest(
}
}
QList<QString> GoogleAIProvider::getInstalledModels(const QString &url)
QFuture<QList<QString>> GoogleAIProvider::getInstalledModels(const QString &url)
{
QList<QString> models;
QNetworkAccessManager manager;
QNetworkRequest request(QString("%1/models?key=%2").arg(url, apiKey()));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QNetworkReply *reply = manager.get(request);
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
if (reply->error() == QNetworkReply::NoError) {
QByteArray responseData = reply->readAll();
QJsonDocument jsonResponse = QJsonDocument::fromJson(responseData);
QJsonObject jsonObject = jsonResponse.object();
return httpClient()->get(request).then([](const QByteArray &data) {
QList<QString> models;
QJsonObject jsonObject = QJsonDocument::fromJson(data).object();
if (jsonObject.contains("models")) {
QJsonArray modelArray = jsonObject["models"].toArray();
models.clear();
for (const QJsonValue &value : modelArray) {
QJsonObject modelObject = value.toObject();
if (modelObject.contains("name")) {
@@ -190,12 +176,11 @@ QList<QString> GoogleAIProvider::getInstalledModels(const QString &url)
}
}
}
} else {
LOG_MESSAGE(QString("Error fetching Google AI models: %1").arg(reply->errorString()));
}
reply->deleteLater();
return models;
return models;
}).onFailed([](const std::exception &e) {
LOG_MESSAGE(QString("Error fetching Google AI models: %1").arg(e.what()));
return QList<QString>{};
});
}
QList<QString> GoogleAIProvider::validateRequest(
@@ -254,13 +239,10 @@ void GoogleAIProvider::sendRequest(
QNetworkRequest networkRequest(url);
prepareNetworkRequest(networkRequest);
LLMCore::HttpRequest
request{.networkRequest = networkRequest, .requestId = requestId, .payload = payload};
LOG_MESSAGE(
QString("GoogleAIProvider: Sending request %1 to %2").arg(requestId, url.toString()));
emit httpClient()->sendRequest(request);
httpClient()->postStreaming(requestId, networkRequest, payload);
}
bool GoogleAIProvider::supportsTools() const
@@ -327,11 +309,11 @@ void GoogleAIProvider::onDataReceived(
}
void GoogleAIProvider::onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId, bool success, const QString &error)
const QodeAssist::LLMCore::RequestID &requestId, std::optional<QString> error)
{
if (!success) {
LOG_MESSAGE(QString("GoogleAIProvider request %1 failed: %2").arg(requestId, error));
emit requestFailed(requestId, error);
if (error) {
LOG_MESSAGE(QString("GoogleAIProvider request %1 failed: %2").arg(requestId, *error));
emit requestFailed(requestId, *error);
cleanupRequest(requestId);
return;
}

View File

@@ -43,7 +43,7 @@ public:
LLMCore::RequestType type,
bool isToolsEnabled,
bool isThinkingEnabled) override;
QList<QString> getInstalledModels(const QString &url) override;
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
QString apiKey() const override;
void prepareNetworkRequest(QNetworkRequest &networkRequest) const override;
@@ -62,8 +62,7 @@ public slots:
const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data) override;
void onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId,
bool success,
const QString &error) override;
std::optional<QString> error) override;
private slots:
void onToolExecutionComplete(

View File

@@ -27,11 +27,9 @@
#include "settings/GeneralSettings.hpp"
#include "settings/ProviderSettings.hpp"
#include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
namespace QodeAssist::Providers {
@@ -71,35 +69,24 @@ bool LMStudioProvider::supportsModelListing() const
return true;
}
QList<QString> LMStudioProvider::getInstalledModels(const QString &url)
QFuture<QList<QString>> LMStudioProvider::getInstalledModels(const QString &url)
{
QList<QString> models;
QNetworkAccessManager manager;
QNetworkRequest request(QString("%1%2").arg(url, "/v1/models"));
QNetworkReply *reply = manager.get(request);
return httpClient()->get(request).then([](const QByteArray &data) {
QList<QString> models;
QJsonObject jsonObject = QJsonDocument::fromJson(data).object();
QJsonArray modelArray = jsonObject["data"].toArray();
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
if (reply->error() == QNetworkReply::NoError) {
QByteArray responseData = reply->readAll();
QJsonDocument jsonResponse = QJsonDocument::fromJson(responseData);
QJsonObject jsonObject = jsonResponse.object();
QJsonArray modelArray = jsonObject["data"].toArray();
for (const QJsonValue &value : modelArray) {
QJsonObject modelObject = value.toObject();
QString modelId = modelObject["id"].toString();
models.append(modelId);
for (const QJsonValue &value : modelArray) {
QJsonObject modelObject = value.toObject();
models.append(modelObject["id"].toString());
}
} else {
LOG_MESSAGE(QString("Error fetching LMStudio models: %1").arg(reply->errorString()));
}
reply->deleteLater();
return models;
return models;
}).onFailed([](const std::exception &e) {
LOG_MESSAGE(QString("Error fetching LMStudio models: %1").arg(e.what()));
return QList<QString>{};
});
}
QList<QString> LMStudioProvider::validateRequest(
@@ -149,13 +136,10 @@ void LMStudioProvider::sendRequest(
QNetworkRequest networkRequest(url);
prepareNetworkRequest(networkRequest);
LLMCore::HttpRequest
request{.networkRequest = networkRequest, .requestId = requestId, .payload = payload};
LOG_MESSAGE(
QString("LMStudioProvider: Sending request %1 to %2").arg(requestId, url.toString()));
emit httpClient()->sendRequest(request);
httpClient()->postStreaming(requestId, networkRequest, payload);
}
bool LMStudioProvider::supportsTools() const
@@ -195,11 +179,11 @@ void LMStudioProvider::onDataReceived(
}
void LMStudioProvider::onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId, bool success, const QString &error)
const QodeAssist::LLMCore::RequestID &requestId, std::optional<QString> error)
{
if (!success) {
LOG_MESSAGE(QString("LMStudioProvider request %1 failed: %2").arg(requestId, error));
emit requestFailed(requestId, error);
if (error) {
LOG_MESSAGE(QString("LMStudioProvider request %1 failed: %2").arg(requestId, *error));
emit requestFailed(requestId, *error);
cleanupRequest(requestId);
return;
}

View File

@@ -43,7 +43,7 @@ public:
LLMCore::RequestType type,
bool isToolsEnabled,
bool isThinkingEnabled) override;
QList<QString> getInstalledModels(const QString &url) override;
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
QString apiKey() const override;
void prepareNetworkRequest(QNetworkRequest &networkRequest) const override;
@@ -61,8 +61,7 @@ public slots:
const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data) override;
void onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId,
bool success,
const QString &error) override;
std::optional<QString> error) override;
private slots:
void onToolExecutionComplete(

View File

@@ -26,11 +26,9 @@
#include "settings/QuickRefactorSettings.hpp"
#include "settings/GeneralSettings.hpp"
#include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
namespace QodeAssist::Providers {
@@ -121,9 +119,9 @@ void LlamaCppProvider::prepareRequest(
}
}
QList<QString> LlamaCppProvider::getInstalledModels(const QString &url)
QFuture<QList<QString>> LlamaCppProvider::getInstalledModels(const QString &)
{
return {};
return QtFuture::makeReadyFuture(QList<QString>{});
}
QList<QString> LlamaCppProvider::validateRequest(
@@ -192,13 +190,10 @@ void LlamaCppProvider::sendRequest(
QNetworkRequest networkRequest(url);
prepareNetworkRequest(networkRequest);
LLMCore::HttpRequest
request{.networkRequest = networkRequest, .requestId = requestId, .payload = payload};
LOG_MESSAGE(
QString("LlamaCppProvider: Sending request %1 to %2").arg(requestId, url.toString()));
emit httpClient()->sendRequest(request);
httpClient()->postStreaming(requestId, networkRequest, payload);
}
bool LlamaCppProvider::supportsTools() const
@@ -250,11 +245,11 @@ void LlamaCppProvider::onDataReceived(
}
void LlamaCppProvider::onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId, bool success, const QString &error)
const QodeAssist::LLMCore::RequestID &requestId, std::optional<QString> error)
{
if (!success) {
LOG_MESSAGE(QString("LlamaCppProvider request %1 failed: %2").arg(requestId, error));
emit requestFailed(requestId, error);
if (error) {
LOG_MESSAGE(QString("LlamaCppProvider request %1 failed: %2").arg(requestId, *error));
emit requestFailed(requestId, *error);
cleanupRequest(requestId);
return;
}

View File

@@ -43,7 +43,7 @@ public:
LLMCore::RequestType type,
bool isToolsEnabled,
bool isThinkingEnabled) override;
QList<QString> getInstalledModels(const QString &url) override;
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
QString apiKey() const override;
void prepareNetworkRequest(QNetworkRequest &networkRequest) const override;
@@ -61,8 +61,7 @@ public slots:
const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data) override;
void onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId,
bool success,
const QString &error) override;
std::optional<QString> error) override;
private slots:
void onToolExecutionComplete(

View File

@@ -27,11 +27,9 @@
#include "settings/GeneralSettings.hpp"
#include "settings/ProviderSettings.hpp"
#include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
namespace QodeAssist::Providers {
@@ -71,43 +69,32 @@ bool MistralAIProvider::supportsModelListing() const
return true;
}
QList<QString> MistralAIProvider::getInstalledModels(const QString &url)
QFuture<QList<QString>> MistralAIProvider::getInstalledModels(const QString &url)
{
QList<QString> models;
QNetworkAccessManager manager;
QNetworkRequest request(QString("%1/v1/models").arg(url));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
if (!apiKey().isEmpty()) {
request.setRawHeader("Authorization", QString("Bearer %1").arg(apiKey()).toUtf8());
}
QNetworkReply *reply = manager.get(request);
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
if (reply->error() == QNetworkReply::NoError) {
QByteArray responseData = reply->readAll();
QJsonDocument jsonResponse = QJsonDocument::fromJson(responseData);
QJsonObject jsonObject = jsonResponse.object();
return httpClient()->get(request).then([](const QByteArray &data) {
QList<QString> models;
QJsonObject jsonObject = QJsonDocument::fromJson(data).object();
if (jsonObject.contains("data") && jsonObject["object"].toString() == "list") {
QJsonArray modelArray = jsonObject["data"].toArray();
for (const QJsonValue &value : modelArray) {
QJsonObject modelObject = value.toObject();
if (modelObject.contains("id")) {
QString modelId = modelObject["id"].toString();
models.append(modelId);
models.append(modelObject["id"].toString());
}
}
}
} else {
LOG_MESSAGE(QString("Error fetching Mistral AI models: %1").arg(reply->errorString()));
}
reply->deleteLater();
return models;
return models;
}).onFailed([](const std::exception &e) {
LOG_MESSAGE(QString("Error fetching Mistral AI models: %1").arg(e.what()));
return QList<QString>{};
});
}
QList<QString> MistralAIProvider::validateRequest(
@@ -170,13 +157,10 @@ void MistralAIProvider::sendRequest(
QNetworkRequest networkRequest(url);
prepareNetworkRequest(networkRequest);
LLMCore::HttpRequest
request{.networkRequest = networkRequest, .requestId = requestId, .payload = payload};
LOG_MESSAGE(
QString("MistralAIProvider: Sending request %1 to %2").arg(requestId, url.toString()));
emit httpClient()->sendRequest(request);
httpClient()->postStreaming(requestId, networkRequest, payload);
}
bool MistralAIProvider::supportsTools() const
@@ -216,11 +200,11 @@ void MistralAIProvider::onDataReceived(
}
void MistralAIProvider::onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId, bool success, const QString &error)
const QodeAssist::LLMCore::RequestID &requestId, std::optional<QString> error)
{
if (!success) {
LOG_MESSAGE(QString("MistralAIProvider request %1 failed: %2").arg(requestId, error));
emit requestFailed(requestId, error);
if (error) {
LOG_MESSAGE(QString("MistralAIProvider request %1 failed: %2").arg(requestId, *error));
emit requestFailed(requestId, *error);
cleanupRequest(requestId);
return;
}

View File

@@ -43,7 +43,7 @@ public:
LLMCore::RequestType type,
bool isToolsEnabled,
bool isThinkingEnabled) override;
QList<QString> getInstalledModels(const QString &url) override;
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
QString apiKey() const override;
void prepareNetworkRequest(QNetworkRequest &networkRequest) const override;
@@ -61,8 +61,7 @@ public slots:
const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data) override;
void onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId,
bool success,
const QString &error) override;
std::optional<QString> error) override;
private slots:
void onToolExecutionComplete(

View File

@@ -22,8 +22,6 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QtCore/qeventloop.h>
#include "llmcore/ValidationUtils.hpp"
#include "logger/Logger.hpp"
@@ -147,35 +145,25 @@ void OllamaProvider::prepareRequest(
}
}
QList<QString> OllamaProvider::getInstalledModels(const QString &url)
QFuture<QList<QString>> OllamaProvider::getInstalledModels(const QString &url)
{
QList<QString> models;
QNetworkAccessManager manager;
QNetworkRequest request(QString("%1%2").arg(url, "/api/tags"));
prepareNetworkRequest(request);
QNetworkReply *reply = manager.get(request);
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
if (reply->error() == QNetworkReply::NoError) {
QByteArray responseData = reply->readAll();
QJsonDocument jsonResponse = QJsonDocument::fromJson(responseData);
QJsonObject jsonObject = jsonResponse.object();
return httpClient()->get(request).then([](const QByteArray &data) {
QList<QString> models;
QJsonObject jsonObject = QJsonDocument::fromJson(data).object();
QJsonArray modelArray = jsonObject["models"].toArray();
for (const QJsonValue &value : modelArray) {
QJsonObject modelObject = value.toObject();
QString modelName = modelObject["name"].toString();
models.append(modelName);
models.append(modelObject["name"].toString());
}
} else {
LOG_MESSAGE(QString("Error fetching models: %1").arg(reply->errorString()));
}
reply->deleteLater();
return models;
return models;
}).onFailed([](const std::exception &e) {
LOG_MESSAGE(QString("Error fetching models: %1").arg(e.what()));
return QList<QString>{};
});
}
QList<QString> OllamaProvider::validateRequest(const QJsonObject &request, LLMCore::TemplateType type)
@@ -248,12 +236,9 @@ void OllamaProvider::sendRequest(
QNetworkRequest networkRequest(url);
prepareNetworkRequest(networkRequest);
LLMCore::HttpRequest
request{.networkRequest = networkRequest, .requestId = requestId, .payload = payload};
LOG_MESSAGE(QString("OllamaProvider: Sending request %1 to %2").arg(requestId, url.toString()));
emit httpClient()->sendRequest(request);
httpClient()->postStreaming(requestId, networkRequest, payload);
}
bool OllamaProvider::supportsTools() const
@@ -312,11 +297,11 @@ void OllamaProvider::onDataReceived(
}
void OllamaProvider::onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId, bool success, const QString &error)
const QodeAssist::LLMCore::RequestID &requestId, std::optional<QString> error)
{
if (!success) {
LOG_MESSAGE(QString("OllamaProvider request %1 failed: %2").arg(requestId, error));
emit requestFailed(requestId, error);
if (error) {
LOG_MESSAGE(QString("OllamaProvider request %1 failed: %2").arg(requestId, *error));
emit requestFailed(requestId, *error);
cleanupRequest(requestId);
return;
}

View File

@@ -44,7 +44,7 @@ public:
LLMCore::RequestType type,
bool isToolsEnabled,
bool isThinkingEnabled) override;
QList<QString> getInstalledModels(const QString &url) override;
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
QString apiKey() const override;
void prepareNetworkRequest(QNetworkRequest &networkRequest) const override;
@@ -63,8 +63,7 @@ public slots:
const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data) override;
void onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId,
bool success,
const QString &error) override;
std::optional<QString> error) override;
private slots:
void onToolExecutionComplete(

View File

@@ -122,9 +122,9 @@ void OpenAICompatProvider::prepareRequest(
}
}
QList<QString> OpenAICompatProvider::getInstalledModels(const QString &url)
QFuture<QList<QString>> OpenAICompatProvider::getInstalledModels(const QString &)
{
return QStringList();
return QtFuture::makeReadyFuture(QList<QString>{});
}
QList<QString> OpenAICompatProvider::validateRequest(
@@ -178,13 +178,10 @@ void OpenAICompatProvider::sendRequest(
QNetworkRequest networkRequest(url);
prepareNetworkRequest(networkRequest);
LLMCore::HttpRequest
request{.networkRequest = networkRequest, .requestId = requestId, .payload = payload};
LOG_MESSAGE(
QString("OpenAICompatProvider: Sending request %1 to %2").arg(requestId, url.toString()));
emit httpClient()->sendRequest(request);
httpClient()->postStreaming(requestId, networkRequest, payload);
}
bool OpenAICompatProvider::supportsTools() const
@@ -224,11 +221,11 @@ void OpenAICompatProvider::onDataReceived(
}
void OpenAICompatProvider::onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId, bool success, const QString &error)
const QodeAssist::LLMCore::RequestID &requestId, std::optional<QString> error)
{
if (!success) {
LOG_MESSAGE(QString("OpenAICompatProvider request %1 failed: %2").arg(requestId, error));
emit requestFailed(requestId, error);
if (error) {
LOG_MESSAGE(QString("OpenAICompatProvider request %1 failed: %2").arg(requestId, *error));
emit requestFailed(requestId, *error);
cleanupRequest(requestId);
return;
}

View File

@@ -43,7 +43,7 @@ public:
LLMCore::RequestType type,
bool isToolsEnabled,
bool isThinkingEnabled) override;
QList<QString> getInstalledModels(const QString &url) override;
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
QString apiKey() const override;
void prepareNetworkRequest(QNetworkRequest &networkRequest) const override;
@@ -61,8 +61,7 @@ public slots:
const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data) override;
void onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId,
bool success,
const QString &error) override;
std::optional<QString> error) override;
private slots:
void onToolExecutionComplete(

View File

@@ -27,11 +27,9 @@
#include "settings/GeneralSettings.hpp"
#include "settings/ProviderSettings.hpp"
#include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
namespace QodeAssist::Providers {
@@ -141,26 +139,17 @@ void OpenAIProvider::prepareRequest(
}
}
QList<QString> OpenAIProvider::getInstalledModels(const QString &url)
QFuture<QList<QString>> OpenAIProvider::getInstalledModels(const QString &url)
{
QList<QString> models;
QNetworkAccessManager manager;
QNetworkRequest request(QString("%1/v1/models").arg(url));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
if (!apiKey().isEmpty()) {
request.setRawHeader("Authorization", QString("Bearer %1").arg(apiKey()).toUtf8());
}
QNetworkReply *reply = manager.get(request);
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
if (reply->error() == QNetworkReply::NoError) {
QByteArray responseData = reply->readAll();
QJsonDocument jsonResponse = QJsonDocument::fromJson(responseData);
QJsonObject jsonObject = jsonResponse.object();
return httpClient()->get(request).then([](const QByteArray &data) {
QList<QString> models;
QJsonObject jsonObject = QJsonDocument::fromJson(data).object();
if (jsonObject.contains("data")) {
QJsonArray modelArray = jsonObject["data"].toArray();
@@ -176,12 +165,11 @@ QList<QString> OpenAIProvider::getInstalledModels(const QString &url)
}
}
}
} else {
LOG_MESSAGE(QString("Error fetching OpenAI models: %1").arg(reply->errorString()));
}
reply->deleteLater();
return models;
return models;
}).onFailed([](const std::exception &e) {
LOG_MESSAGE(QString("Error fetching OpenAI models: %1").arg(e.what()));
return QList<QString>{};
});
}
QList<QString> OpenAIProvider::validateRequest(const QJsonObject &request, LLMCore::TemplateType type)
@@ -235,12 +223,9 @@ void OpenAIProvider::sendRequest(
QNetworkRequest networkRequest(url);
prepareNetworkRequest(networkRequest);
LLMCore::HttpRequest
request{.networkRequest = networkRequest, .requestId = requestId, .payload = payload};
LOG_MESSAGE(QString("OpenAIProvider: Sending request %1 to %2").arg(requestId, url.toString()));
emit httpClient()->sendRequest(request);
httpClient()->postStreaming(requestId, networkRequest, payload);
}
bool OpenAIProvider::supportsTools() const
@@ -280,11 +265,11 @@ void OpenAIProvider::onDataReceived(
}
void OpenAIProvider::onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId, bool success, const QString &error)
const QodeAssist::LLMCore::RequestID &requestId, std::optional<QString> error)
{
if (!success) {
LOG_MESSAGE(QString("OpenAIProvider request %1 failed: %2").arg(requestId, error));
emit requestFailed(requestId, error);
if (error) {
LOG_MESSAGE(QString("OpenAIProvider request %1 failed: %2").arg(requestId, *error));
emit requestFailed(requestId, *error);
cleanupRequest(requestId);
return;
}

View File

@@ -43,7 +43,7 @@ public:
LLMCore::RequestType type,
bool isToolsEnabled,
bool isThinkingEnabled) override;
QList<QString> getInstalledModels(const QString &url) override;
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
QString apiKey() const override;
void prepareNetworkRequest(QNetworkRequest &networkRequest) const override;
@@ -61,8 +61,7 @@ public slots:
const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data) override;
void onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId,
bool success,
const QString &error) override;
std::optional<QString> error) override;
private slots:
void onToolExecutionComplete(

View File

@@ -28,11 +28,9 @@
#include "settings/ProviderSettings.hpp"
#include "settings/QuickRefactorSettings.hpp"
#include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
namespace QodeAssist::Providers {
@@ -158,26 +156,17 @@ void OpenAIResponsesProvider::prepareRequest(
request["stream"] = true;
}
QList<QString> OpenAIResponsesProvider::getInstalledModels(const QString &url)
QFuture<QList<QString>> OpenAIResponsesProvider::getInstalledModels(const QString &url)
{
QList<QString> models;
QNetworkAccessManager manager;
QNetworkRequest request(QString("%1/v1/models").arg(url));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
if (!apiKey().isEmpty()) {
request.setRawHeader("Authorization", QString("Bearer %1").arg(apiKey()).toUtf8());
}
QNetworkReply *reply = manager.get(request);
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
if (reply->error() == QNetworkReply::NoError) {
const QByteArray responseData = reply->readAll();
const QJsonDocument jsonResponse = QJsonDocument::fromJson(responseData);
const QJsonObject jsonObject = jsonResponse.object();
return httpClient()->get(request).then([](const QByteArray &data) {
QList<QString> models;
const QJsonObject jsonObject = QJsonDocument::fromJson(data).object();
if (jsonObject.contains("data")) {
const QJsonArray modelArray = jsonObject["data"].toArray();
@@ -200,12 +189,11 @@ QList<QString> OpenAIResponsesProvider::getInstalledModels(const QString &url)
}
}
}
} else {
LOG_MESSAGE(QString("Error fetching OpenAI models: %1").arg(reply->errorString()));
}
reply->deleteLater();
return models;
return models;
}).onFailed([](const std::exception &e) {
LOG_MESSAGE(QString("Error fetching OpenAI models: %1").arg(e.what()));
return QList<QString>{};
});
}
QList<QString> OpenAIResponsesProvider::validateRequest(
@@ -280,10 +268,7 @@ void OpenAIResponsesProvider::sendRequest(
QNetworkRequest networkRequest(url);
prepareNetworkRequest(networkRequest);
LLMCore::HttpRequest
request{.networkRequest = networkRequest, .requestId = requestId, .payload = payload};
emit httpClient()->sendRequest(request);
httpClient()->postStreaming(requestId, networkRequest, payload);
}
bool OpenAIResponsesProvider::supportsTools() const
@@ -344,11 +329,11 @@ void OpenAIResponsesProvider::onDataReceived(
}
void OpenAIResponsesProvider::onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId, bool success, const QString &error)
const QodeAssist::LLMCore::RequestID &requestId, std::optional<QString> error)
{
if (!success) {
LOG_MESSAGE(QString("OpenAIResponses request %1 failed: %2").arg(requestId, error));
emit requestFailed(requestId, error);
if (error) {
LOG_MESSAGE(QString("OpenAIResponses request %1 failed: %2").arg(requestId, *error));
emit requestFailed(requestId, *error);
cleanupRequest(requestId);
return;
}

View File

@@ -43,7 +43,7 @@ public:
LLMCore::RequestType type,
bool isToolsEnabled,
bool isThinkingEnabled) override;
QList<QString> getInstalledModels(const QString &url) override;
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
QList<QString> validateRequest(const QJsonObject &request, LLMCore::TemplateType type) override;
QString apiKey() const override;
void prepareNetworkRequest(QNetworkRequest &networkRequest) const override;
@@ -62,8 +62,7 @@ public slots:
const QodeAssist::LLMCore::RequestID &requestId, const QByteArray &data) override;
void onRequestFinished(
const QodeAssist::LLMCore::RequestID &requestId,
bool success,
const QString &error) override;
std::optional<QString> error) override;
private slots:
void onToolExecutionComplete(

View File

@@ -236,7 +236,7 @@ public:
closeChatViewAction.setText(Tr::tr("Close QodeAssist Chat"));
closeChatViewAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
closeChatViewAction.addOnTriggered(this, [this] {
if (m_chatView->isVisible()) {
if (m_chatView && m_chatView->isActive() && m_chatView->isVisible()) {
m_chatView->close();
}
});

View File

@@ -29,21 +29,24 @@
namespace QodeAssist::Settings {
AgentRoleDialog::AgentRoleDialog(QWidget *parent)
: QDialog(parent)
, m_editMode(false)
AgentRoleDialog::AgentRoleDialog(Action action, QWidget *parent)
: QDialog{parent}
, m_action{action}
{
setWindowTitle(tr("Add Agent Role"));
setupUI();
}
auto getTitle = [](Action action) {
switch(action)
{
case Action::Add:
return tr("Add Agent Role");
case Action::Duplicate:
return tr("Duplicate Agent Role");
case Action::Edit:
return tr("Edit Agent Role");
}
};
AgentRoleDialog::AgentRoleDialog(const AgentRole &role, bool editMode, QWidget *parent)
: QDialog(parent)
, m_editMode(editMode)
{
setWindowTitle(editMode ? tr("Edit Agent Role") : tr("Duplicate Agent Role"));
setWindowTitle(getTitle(action));
setupUI();
setRole(role);
}
void AgentRoleDialog::setupUI()
@@ -83,7 +86,7 @@ void AgentRoleDialog::setupUI()
connect(m_idEdit, &QLineEdit::textChanged, this, &AgentRoleDialog::validateInput);
connect(m_systemPromptEdit, &QTextEdit::textChanged, this, &AgentRoleDialog::validateInput);
if (m_editMode) {
if (m_action == Action::Edit) {
m_idEdit->setEnabled(false);
m_idEdit->setToolTip(tr("ID cannot be changed for existing roles"));
}

View File

@@ -34,8 +34,18 @@ class AgentRoleDialog : public QDialog
Q_OBJECT
public:
explicit AgentRoleDialog(QWidget *parent = nullptr);
explicit AgentRoleDialog(const AgentRole &role, bool editMode = true, QWidget *parent = nullptr);
enum class Action {
Add,
Duplicate,
Edit,
};
explicit AgentRoleDialog(Action action, QWidget *parent = nullptr);
explicit AgentRoleDialog(const AgentRole &role, Action action, QWidget *parent = nullptr)
: AgentRoleDialog{action, parent}
{
setRole(role);
}
AgentRole getRole() const;
void setRole(const AgentRole &role);
@@ -49,7 +59,7 @@ private:
QTextEdit *m_descriptionEdit = nullptr;
QTextEdit *m_systemPromptEdit = nullptr;
QDialogButtonBox *m_buttonBox = nullptr;
bool m_editMode = false;
Action m_action;
};
} // namespace QodeAssist::Settings

View File

@@ -34,13 +34,6 @@
namespace QodeAssist::Settings {
AgentRolesWidget::AgentRolesWidget(QWidget *parent)
: QWidget(parent)
{
setupUI();
loadRoles();
}
void AgentRolesWidget::setupUI()
{
auto *mainLayout = new QVBoxLayout(this);
@@ -129,7 +122,7 @@ void AgentRolesWidget::updateButtons()
void AgentRolesWidget::onAddRole()
{
AgentRoleDialog dialog(this);
AgentRoleDialog dialog{AgentRoleDialog::Action::Add, this};
if (dialog.exec() != QDialog::Accepted)
return;
@@ -170,7 +163,7 @@ void AgentRolesWidget::onEditRole()
return;
}
AgentRoleDialog dialog(role, this);
AgentRoleDialog dialog{role, AgentRoleDialog::Action::Edit, this};
if (dialog.exec() != QDialog::Accepted)
return;
@@ -203,7 +196,7 @@ void AgentRolesWidget::onDuplicateRole()
role.id = baseId + QString::number(counter++);
}
AgentRoleDialog dialog(role, false, this);
AgentRoleDialog dialog{role, AgentRoleDialog::Action::Duplicate, this};
if (dialog.exec() != QDialog::Accepted)
return;

View File

@@ -19,19 +19,23 @@
#pragma once
#include <QWidget>
#include <coreplugin/dialogs/ioptionspage.h>
class QListWidget;
class QPushButton;
namespace QodeAssist::Settings {
class AgentRolesWidget : public QWidget
class AgentRolesWidget : public Core::IOptionsPageWidget
{
Q_OBJECT
public:
explicit AgentRolesWidget(QWidget *parent = nullptr);
explicit AgentRolesWidget()
{
setupUI();
loadRoles();
}
private:
void setupUI();

View File

@@ -50,9 +50,9 @@ QVector<AIConfiguration> ConfigurationManager::getPredefinedConfigurations(
AIConfiguration claudeOpus;
claudeOpus.id = "preset_claude_opus";
claudeOpus.name = "Claude Opus 4.5";
claudeOpus.name = "Claude Opus 4.6";
claudeOpus.provider = "Claude";
claudeOpus.model = "claude-opus-4-5-20251101";
claudeOpus.model = "claude-opus-4-6";
claudeOpus.url = "https://api.anthropic.com";
claudeOpus.endpointMode = "Auto";
claudeOpus.customEndpoint = "";
@@ -62,9 +62,9 @@ QVector<AIConfiguration> ConfigurationManager::getPredefinedConfigurations(
AIConfiguration claudeSonnet;
claudeSonnet.id = "preset_claude_sonnet";
claudeSonnet.name = "Claude Sonnet 4.5";
claudeSonnet.name = "Claude Sonnet 4.6";
claudeSonnet.provider = "Claude";
claudeSonnet.model = "claude-sonnet-4-5-20250929";
claudeSonnet.model = "claude-sonnet-4-6";
claudeSonnet.url = "https://api.anthropic.com";
claudeSonnet.endpointMode = "Auto";
claudeSonnet.customEndpoint = "";
@@ -84,17 +84,29 @@ QVector<AIConfiguration> ConfigurationManager::getPredefinedConfigurations(
claudeHaiku.type = type;
claudeHaiku.isPredefined = true;
AIConfiguration mistralCodestral;
mistralCodestral.id = "preset_mistral_codestral";
mistralCodestral.name = "Mistral Codestral";
mistralCodestral.provider = "Mistral AI";
mistralCodestral.model = "codestral-2501";
mistralCodestral.url = "https://api.mistral.ai";
mistralCodestral.endpointMode = "Auto";
mistralCodestral.customEndpoint = "";
mistralCodestral.templateName = type == ConfigurationType::CodeCompletion ? "Mistral AI FIM" : "Mistral AI Chat";
mistralCodestral.type = type;
mistralCodestral.isPredefined = true;
AIConfiguration codestral;
codestral.id = "preset_codestral";
codestral.name = "Codestral";
codestral.provider = "Codestral";
codestral.model = "codestral-latest";
codestral.url = "https://codestral.mistral.ai";
codestral.endpointMode = "Auto";
codestral.customEndpoint = "";
codestral.templateName = type == ConfigurationType::CodeCompletion ? "Mistral AI FIM" : "Mistral AI Chat";
codestral.type = type;
codestral.isPredefined = true;
AIConfiguration mistral;
mistral.id = "preset_mistral";
mistral.name = "Mistral";
mistral.provider = "Mistral AI";
mistral.model = type == ConfigurationType::CodeCompletion ? "mistral-medium-latest" : "mistral-large-latest";
mistral.url = "https://api.mistral.ai";
mistral.endpointMode = "Auto";
mistral.customEndpoint = "";
mistral.templateName = type == ConfigurationType::CodeCompletion ? "Mistral AI FIM" : "Mistral AI Chat";
mistral.type = type;
mistral.isPredefined = true;
AIConfiguration geminiFlash;
geminiFlash.id = "preset_gemini_flash";
@@ -108,23 +120,24 @@ QVector<AIConfiguration> ConfigurationManager::getPredefinedConfigurations(
geminiFlash.type = type;
geminiFlash.isPredefined = true;
AIConfiguration gpt52codex;
gpt52codex.id = "preset_gpt52codex";
gpt52codex.name = "gpt-5.2-codex";
gpt52codex.provider = "OpenAI Responses";
gpt52codex.model = "gpt-5.2-codex";
gpt52codex.url = "https://api.openai.com";
gpt52codex.endpointMode = "Auto";
gpt52codex.customEndpoint = "";
gpt52codex.templateName = "OpenAI Responses";
gpt52codex.type = type;
gpt52codex.isPredefined = true;
AIConfiguration gpt;
gpt.id = "preset_gpt";
gpt.name = "gpt-5.4";
gpt.provider = "OpenAI Responses";
gpt.model = "gpt-5.4";
gpt.url = "https://api.openai.com";
gpt.endpointMode = "Auto";
gpt.customEndpoint = "";
gpt.templateName = "OpenAI Responses";
gpt.type = type;
gpt.isPredefined = true;
presets.append(claudeSonnet);
presets.append(claudeHaiku);
presets.append(claudeOpus);
presets.append(gpt52codex);
presets.append(mistralCodestral);
presets.append(gpt);
presets.append(codestral);
presets.append(mistral);
presets.append(geminiFlash);
return presets;

View File

@@ -452,7 +452,7 @@ void GeneralSettings::showModelsNotFoundDialog(Utils::StringAspect &aspect)
connect(configureApiKeyBtn, &QPushButton::clicked, &dialog, [&dialog]() {
dialog.close();
Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID);
Settings::showSettings(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID);
});
dialog.buttonLayout()->addWidget(selectProviderBtn);
@@ -609,7 +609,7 @@ void GeneralSettings::setupConnections()
});
connect(&ccConfigureApiKey, &ButtonAspect::clicked, this, []() {
Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID);
Settings::showSettings(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID);
});
connect(&caPresetConfig, &Utils::SelectionAspect::volatileValueChanged, this, [this]() {
@@ -618,7 +618,7 @@ void GeneralSettings::setupConnections()
});
connect(&caConfigureApiKey, &ButtonAspect::clicked, this, []() {
Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID);
Settings::showSettings(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID);
});
connect(&qrPresetConfig, &Utils::SelectionAspect::volatileValueChanged, this, [this]() {
@@ -627,7 +627,7 @@ void GeneralSettings::setupConnections()
});
connect(&qrConfigureApiKey, &ButtonAspect::clicked, this, []() {
Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID);
Settings::showSettings(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID);
});
connect(&specifyPreset1, &Utils::BoolAspect::volatileValueChanged, this, [this]() {
@@ -1046,5 +1046,29 @@ public:
};
const GeneralSettingsPage generalSettingsPage;
/*!
\sa {Core::ICore::showOptionsDialog()}, {Core::ICore::showSettings()}
\note This function was added to fix Qt Creator API broken changes in v19.0.0-beta2 version
*/
void showSettings(const Utils::Id page)
{
#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 83)
Core::ICore::showSettings(page);
#else
Core::ICore::showOptionsDialog(page);
#endif
}
/*!
\sa {Core::ICore::showOptionsDialog()}, {Core::ICore::showSettings()}
\note This function was added to fix Qt Creator API broken changes in v19.0.0-beta2 version
*/
void showSettings(const Utils::Id page, Utils::Id item)
{
#if QODEASSIST_QT_CREATOR_VERSION >= QT_VERSION_CHECK(18, 0, 83)
Core::ICore::showSettings(page, item);
#else
Core::ICore::showOptionsDialog(page, item);
#endif
}
} // namespace QodeAssist::Settings

View File

@@ -187,4 +187,7 @@ private:
GeneralSettings &generalSettings();
void showSettings(const Utils::Id page);
void showSettings(const Utils::Id page, Utils::Id item);
} // namespace QodeAssist::Settings

View File

@@ -116,6 +116,7 @@ const char CA_ALLOWED_TERMINAL_COMMANDS[] = "QodeAssist.caAllowedTerminalCommand
const char CA_ALLOWED_TERMINAL_COMMANDS_LINUX[] = "QodeAssist.caAllowedTerminalCommandsLinux";
const char CA_ALLOWED_TERMINAL_COMMANDS_MACOS[] = "QodeAssist.caAllowedTerminalCommandsMacOS";
const char CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS[] = "QodeAssist.caAllowedTerminalCommandsWindows";
const char CA_TERMINAL_COMMAND_TIMEOUT[] = "QodeAssist.caTerminalCommandTimeout";
const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions";
const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId";

View File

@@ -128,6 +128,14 @@ ToolsSettings::ToolsSettings()
allowedTerminalCommandsWindows.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
allowedTerminalCommandsWindows.setDefaultValue("git, dir, type, findstr, where");
terminalCommandTimeout.setSettingsKey(Constants::CA_TERMINAL_COMMAND_TIMEOUT);
terminalCommandTimeout.setLabelText(Tr::tr("Command Timeout (seconds)"));
terminalCommandTimeout.setToolTip(
Tr::tr("Maximum time in seconds to wait for a terminal command to complete. "
"Increase for long-running commands like builds."));
terminalCommandTimeout.setRange(5, 3600);
terminalCommandTimeout.setDefaultValue(30);
resetToDefaults.m_buttonText = Tr::tr("Reset Page to Defaults");
readSettings();
@@ -167,6 +175,7 @@ ToolsSettings::ToolsSettings()
enableTerminalCommandTool,
enableTodoTool,
currentOsCommands,
terminalCommandTimeout,
autoApplyFileEdits}},
Stretch{1}};
});
@@ -203,6 +212,7 @@ void ToolsSettings::resetSettingsToDefaults()
resetAspect(allowedTerminalCommandsLinux);
resetAspect(allowedTerminalCommandsMacOS);
resetAspect(allowedTerminalCommandsWindows);
resetAspect(terminalCommandTimeout);
writeSettings();
}
}

View File

@@ -45,6 +45,7 @@ public:
Utils::StringAspect allowedTerminalCommandsLinux{this};
Utils::StringAspect allowedTerminalCommandsMacOS{this};
Utils::StringAspect allowedTerminalCommandsWindows{this};
Utils::IntegerAspect terminalCommandTimeout{this};
Utils::BoolAspect autoApplyFileEdits{this};
private:

View File

@@ -80,7 +80,10 @@ public:
return true;
}
QList<QString> getInstalledModels(const QString &url) override { return {}; }
QFuture<QList<QString>> getInstalledModels(const QString &) override
{
return QtFuture::makeReadyFuture(QList<QString>{});
}
QStringList validateRequest(
const QJsonObject &request, LLMCore::TemplateType templateType) override

View File

@@ -32,6 +32,8 @@
#include <QSharedPointer>
#include <QTimer>
#include <atomic>
namespace QodeAssist::Tools {
ExecuteTerminalCommandTool::ExecuteTerminalCommandTool(QObject *parent)
@@ -188,54 +190,66 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
QFuture<QString> future = promise->future();
promise->start();
auto resolved = std::make_shared<std::atomic<bool>>(false);
QProcess *process = new QProcess();
process->setWorkingDirectory(workingDir);
process->setProcessChannelMode(QProcess::MergedChannels);
process->setReadChannel(QProcess::StandardOutput);
const int timeoutMs = commandTimeoutMs();
QTimer *timeoutTimer = new QTimer();
timeoutTimer->setSingleShot(true);
timeoutTimer->setInterval(COMMAND_TIMEOUT_MS);
auto outputSize = QSharedPointer<qint64>::create(0);
timeoutTimer->setInterval(timeoutMs);
QObject::connect(timeoutTimer, &QTimer::timeout, [process, promise, resolved, command, args, timeoutTimer, timeoutMs]() {
if (*resolved)
return;
*resolved = true;
QObject::connect(timeoutTimer, &QTimer::timeout, [process, promise, command, args, timeoutTimer]() {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1 %2' timed out after %3ms")
.arg(command)
.arg(args)
.arg(COMMAND_TIMEOUT_MS));
.arg(timeoutMs));
process->terminate();
QTimer::singleShot(1000, process, [process]() {
if (process->state() == QProcess::Running) {
if (process->state() != QProcess::NotRunning) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Forcefully killing process after timeout");
process->kill();
}
process->deleteLater();
});
promise->addResult(QString("Error: Command '%1 %2' timed out after %3 seconds. "
"The process has been terminated.")
.arg(command)
.arg(args.isEmpty() ? "" : args)
.arg(COMMAND_TIMEOUT_MS / 1000));
.arg(timeoutMs / 1000));
promise->finish();
process->deleteLater();
timeoutTimer->deleteLater();
});
QObject::connect(
process,
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
[this, process, promise, command, args, timeoutTimer, outputSize](
[this, process, promise, resolved, command, args, timeoutTimer](
int exitCode, QProcess::ExitStatus exitStatus) {
if (*resolved) {
process->deleteLater();
return;
}
*resolved = true;
timeoutTimer->stop();
timeoutTimer->deleteLater();
const QByteArray rawOutput = process->readAll();
*outputSize += rawOutput.size();
const QString output = sanitizeOutput(QString::fromUtf8(rawOutput), *outputSize);
const qint64 outputSize = rawOutput.size();
const QString output = sanitizeOutput(QString::fromUtf8(rawOutput), outputSize);
const QString fullCommand = args.isEmpty() ? command : QString("%1 %2").arg(command).arg(args);
@@ -244,7 +258,7 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' completed "
"successfully (output size: %2 bytes)")
.arg(fullCommand)
.arg(*outputSize));
.arg(outputSize));
promise->addResult(
QString("Command '%1' executed successfully.\n\nOutput:\n%2")
.arg(fullCommand)
@@ -254,7 +268,7 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
"exit code %2 (output size: %3 bytes)")
.arg(fullCommand)
.arg(exitCode)
.arg(*outputSize));
.arg(outputSize));
promise->addResult(
QString("Command '%1' failed with exit code %2.\n\nOutput:\n%3")
.arg(fullCommand)
@@ -265,7 +279,7 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' crashed or was "
"terminated (output size: %2 bytes)")
.arg(fullCommand)
.arg(*outputSize));
.arg(outputSize));
const QString error = process->errorString();
promise->addResult(
QString("Command '%1' crashed or was terminated.\n\nError: %2\n\nOutput:\n%3")
@@ -278,11 +292,13 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
process->deleteLater();
});
QObject::connect(process, &QProcess::errorOccurred, [process, promise, command, args, timeoutTimer](
QObject::connect(process, &QProcess::errorOccurred, [process, promise, resolved, command, args, timeoutTimer](
QProcess::ProcessError error) {
if (promise->future().isFinished()) {
if (*resolved) {
process->deleteLater();
return;
}
*resolved = true;
timeoutTimer->stop();
timeoutTimer->deleteLater();
@@ -292,7 +308,7 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
.arg(fullCommand)
.arg(error)
.arg(process->errorString()));
QString errorMessage;
switch (error) {
case QProcess::FailedToStart:
@@ -318,71 +334,46 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
.arg(process->errorString());
break;
}
promise->addResult(QString("Error: %1").arg(errorMessage));
promise->finish();
process->deleteLater();
});
QString fullCommand = command;
QStringList argsList;
if (!args.isEmpty()) {
fullCommand += " " + args;
argsList = QProcess::splitCommand(args);
}
#ifdef Q_OS_WIN
static const QStringList windowsBuiltinCommands = {
"dir", "type", "del", "copy", "move", "ren", "rename",
"dir", "type", "del", "copy", "move", "ren", "rename",
"md", "mkdir", "rd", "rmdir", "cd", "chdir", "cls", "echo",
"set", "path", "prompt", "ver", "vol", "date", "time"
};
const QString lowerCommand = command.toLower();
const bool isBuiltin = windowsBuiltinCommands.contains(lowerCommand);
if (isBuiltin) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Executing Windows builtin command '%1' via cmd.exe")
.arg(command));
process->start("cmd.exe", QStringList() << "/c" << fullCommand);
QStringList cmdArgs;
cmdArgs << "/c" << command;
cmdArgs.append(argsList);
process->start("cmd.exe", cmdArgs);
} else {
#endif
QStringList splitCommand = QProcess::splitCommand(fullCommand);
if (splitCommand.isEmpty()) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Failed to parse command");
promise->addResult(QString("Error: Failed to parse command '%1'").arg(fullCommand));
promise->finish();
process->deleteLater();
timeoutTimer->deleteLater();
return future;
}
const QString program = splitCommand.takeFirst();
process->start(program, splitCommand);
#ifdef Q_OS_WIN
process->start(command, argsList);
}
#else
process->start(command, argsList);
#endif
if (!process->waitForStarted(PROCESS_START_TIMEOUT_MS)) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Failed to start command '%1' within %2ms")
.arg(fullCommand)
.arg(PROCESS_START_TIMEOUT_MS));
const QString errorString = process->errorString();
promise->addResult(QString("Error: Failed to start command '%1': %2\n\n"
"Possible reasons:\n"
"- Command not found in PATH\n"
"- Insufficient permissions\n"
"- Invalid command syntax")
.arg(fullCommand)
.arg(errorString));
promise->finish();
process->deleteLater();
timeoutTimer->deleteLater();
return future;
}
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Process started successfully (PID: %1)")
.arg(process->processId()));
timeoutTimer->start();
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Process start requested for '%1'")
.arg(command));
return future;
}
@@ -414,19 +405,27 @@ bool ExecuteTerminalCommandTool::areArgumentsSafe(const QString &args) const
return true;
}
// Check for null bytes
if (args.contains(QChar('\0'))) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Null byte found in args");
return false;
}
static const QStringList dangerousPatterns = {
";", // Command separator
"&&", // AND operator
"||", // OR operator
"&", // Command separator / background execution
"|", // Pipe operator
">", // Output redirection
">>", // Append redirection
"<", // Input redirection
"`", // Command substitution
"$(", // Command substitution
"$()", // Command substitution
"\\n", // Newline (could start new command)
"\\r" // Carriage return
"${", // Variable expansion
"\n", // Newline (could start new command)
"\r", // Carriage return
#ifdef Q_OS_WIN
"^", // Escape character in cmd.exe (can bypass other checks)
"%", // Environment variable expansion on Windows
#endif
};
for (const QString &pattern : dangerousPatterns) {
@@ -456,9 +455,6 @@ QString ExecuteTerminalCommandTool::sanitizeOutput(const QString &output, qint64
QStringList ExecuteTerminalCommandTool::getAllowedCommands() const
{
static QString cachedCommandsStr;
static QStringList cachedCommands;
QString commandsStr;
#ifdef Q_OS_LINUX
@@ -471,28 +467,27 @@ QStringList ExecuteTerminalCommandTool::getAllowedCommands() const
commandsStr = Settings::toolsSettings().allowedTerminalCommandsLinux().trimmed(); // fallback
#endif
if (commandsStr == cachedCommandsStr && !cachedCommands.isEmpty()) {
return cachedCommands;
}
cachedCommandsStr = commandsStr;
cachedCommands.clear();
if (commandsStr.isEmpty()) {
return QStringList();
}
QStringList result;
const QStringList rawCommands = commandsStr.split(',', Qt::SkipEmptyParts);
cachedCommands.reserve(rawCommands.size());
result.reserve(rawCommands.size());
for (const QString &cmd : rawCommands) {
const QString trimmed = cmd.trimmed();
if (!trimmed.isEmpty()) {
cachedCommands.append(trimmed);
result.append(trimmed);
}
}
return cachedCommands;
return result;
}
int ExecuteTerminalCommandTool::commandTimeoutMs() const
{
return Settings::toolsSettings().terminalCommandTimeout() * 1000;
}
QString ExecuteTerminalCommandTool::getCommandDescription() const
@@ -518,7 +513,7 @@ QString ExecuteTerminalCommandTool::getCommandDescription() const
"Commands have a %2 second timeout. "
"Returns the command output (stdout and stderr) or an error message if the command fails.%3")
.arg(allowedList)
.arg(COMMAND_TIMEOUT_MS / 1000)
.arg(commandTimeoutMs() / 1000)
.arg(osInfo);
}

View File

@@ -46,12 +46,12 @@ private:
QString getCommandDescription() const;
QString sanitizeOutput(const QString &output, qint64 maxSize) const;
int commandTimeoutMs() const;
// Constants for production safety
static constexpr int COMMAND_TIMEOUT_MS = 30000; // 30 seconds
static constexpr qint64 MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10 MB
static constexpr int MAX_COMMAND_LENGTH = 1024;
static constexpr int MAX_ARGS_LENGTH = 4096;
static constexpr int PROCESS_START_TIMEOUT_MS = 3000;
};
} // namespace QodeAssist::Tools

View File

@@ -1,132 +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 <https://www.gnu.org/licenses/>.
*/
#include "ReadVisibleFilesTool.hpp"
#include "ToolExceptions.hpp"
#include <coreplugin/editormanager/editormanager.h>
#include <coreplugin/editormanager/ieditor.h>
#include <logger/Logger.hpp>
#include <projectexplorer/projectmanager.h>
#include <QJsonArray>
#include <QJsonObject>
#include <QtConcurrent>
namespace QodeAssist::Tools {
ReadVisibleFilesTool::ReadVisibleFilesTool(QObject *parent)
: BaseTool(parent)
, m_ignoreManager(new Context::IgnoreManager(this))
{}
QString ReadVisibleFilesTool::name() const
{
return "read_visible_files";
}
QString ReadVisibleFilesTool::stringName() const
{
return {"Reading currently opened and visible files in IDE editors"};
}
QString ReadVisibleFilesTool::description() const
{
return "Read content from all currently visible editor tabs, including unsaved changes. "
"Returns file paths and content. No parameters required.";
}
QJsonObject ReadVisibleFilesTool::getDefinition(LLMCore::ToolSchemaFormat format) const
{
QJsonObject definition;
definition["type"] = "object";
definition["properties"] = QJsonObject();
definition["required"] = QJsonArray();
switch (format) {
case LLMCore::ToolSchemaFormat::OpenAI:
return customizeForOpenAI(definition);
case LLMCore::ToolSchemaFormat::Claude:
return customizeForClaude(definition);
case LLMCore::ToolSchemaFormat::Ollama:
return customizeForOllama(definition);
case LLMCore::ToolSchemaFormat::Google:
return customizeForGoogle(definition);
}
return definition;
}
LLMCore::ToolPermissions ReadVisibleFilesTool::requiredPermissions() const
{
return LLMCore::ToolPermission::FileSystemRead;
}
QFuture<QString> ReadVisibleFilesTool::executeAsync(const QJsonObject &input)
{
Q_UNUSED(input)
return QtConcurrent::run([this]() -> QString {
auto editors = Core::EditorManager::visibleEditors();
if (editors.isEmpty()) {
QString error = "Error: No visible files in the editor";
throw ToolRuntimeError(error);
}
QStringList results;
for (auto editor : editors) {
if (!editor || !editor->document()) {
continue;
}
QString filePath = editor->document()->filePath().toFSPathString();
auto project = ProjectExplorer::ProjectManager::projectForFile(
editor->document()->filePath());
if (project && m_ignoreManager->shouldIgnore(filePath, project)) {
LOG_MESSAGE(
QString("Ignoring visible file due to .qodeassistignore: %1").arg(filePath));
continue;
}
QByteArray contentBytes = editor->document()->contents();
QString fileContent = QString::fromUtf8(contentBytes);
QString fileResult;
if (fileContent.isEmpty()) {
fileResult
= QString("File: %1\n\nThe file is empty or could not be read").arg(filePath);
} else {
fileResult = QString("File: %1\n\nContent:\n%2").arg(filePath, fileContent);
}
results.append(fileResult);
}
if (results.isEmpty()) {
QString error = "Error: All visible files are excluded by .qodeassistignore";
throw ToolRuntimeError(error);
}
return results.join("\n\n" + QString(80, '=') + "\n\n");
});
}
} // namespace QodeAssist::Tools

View File

@@ -1,45 +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 <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <context/IgnoreManager.hpp>
#include <llmcore/BaseTool.hpp>
namespace QodeAssist::Tools {
class ReadVisibleFilesTool : public LLMCore::BaseTool
{
Q_OBJECT
public:
explicit ReadVisibleFilesTool(QObject *parent = nullptr);
QString name() const override;
QString stringName() const override;
QString description() const override;
QJsonObject getDefinition(LLMCore::ToolSchemaFormat format) const override;
LLMCore::ToolPermissions requiredPermissions() const override;
QFuture<QString> executeAsync(const QJsonObject &input = QJsonObject()) override;
private:
Context::IgnoreManager *m_ignoreManager;
};
} // namespace QodeAssist::Tools

View File

@@ -33,7 +33,6 @@
#include "GetIssuesListTool.hpp"
#include "ListProjectFilesTool.hpp"
#include "ProjectSearchTool.hpp"
#include "ReadVisibleFilesTool.hpp"
#include "TodoTool.hpp"
namespace QodeAssist::Tools {
@@ -46,7 +45,6 @@ ToolsFactory::ToolsFactory(QObject *parent)
void ToolsFactory::registerTools()
{
registerTool(new ReadVisibleFilesTool(this));
registerTool(new ListProjectFilesTool(this));
registerTool(new GetIssuesListTool(this));
registerTool(new CreateNewFileTool(this));

View File

@@ -589,7 +589,7 @@ void QuickRefactorDialog::onOpenInstructionsFolder()
void QuickRefactorDialog::onOpenSettings()
{
Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_QUICK_REFACTOR_SETTINGS_PAGE_ID);
Settings::showSettings(Constants::QODE_ASSIST_QUICK_REFACTOR_SETTINGS_PAGE_ID);
}
QString QuickRefactorDialog::selectedConfiguration() const